16.1 Future接口

Java 5引入了Future接口,它的设计初衷是对将来某个时刻会发生的结果进行建模。举个例子,调用方发起远程服务查询时,它是无法立刻得到查询结果的。采用Future接口可以对异步计算进行建模,返回一个指向执行结果的引用,运算结束后,调用方可以通过该引用访问执行的结果。在Future中触发那些可能耗时的调用,能够将调用线程解放出来,让它们继续执行其他有价值的工作,不必呆呆等待耗时的操作完成。打个比方,你可以把这个过程想象成你拿了一袋子衣服到中意的干洗店去洗。干洗店的员工会给你张发票,告诉你什么时候衣服会洗好(这就是一个Future事件)。衣服干洗的同时,你可以去做其他的事情。Future的另一大优点是它比更底层的Thread更好用。要使用Future,通常你只需要将耗时的操作封装在一个Callable对象中,再将它提交给ExecutorService,就万事大吉了。下面这段代码展示了Java 8之前使用Future的一个例子。

代码清单 16-1 使用Future以异步的方式执行一个耗时的操作

  1. ExecutorService executor = Executors.newCachedThreadPool(); ←---- 创建ExecutorService,通过它你可以向线程池提交任务
  2. Future<Double> future = executor.submit(new Callable<Double>() { ←---- ExecutorService提交一个Callable对象
  3. public Double call() {
  4. return doSomeLongComputation(); ←---- 以异步方式在新的线程中执行耗时的操作
  5. }});
  6. doSomethingElse(); ←---- 异步操作进行的同时,你可以做其他的事情
  7. try {
  8. Double result = future.get(1, TimeUnit.SECONDS); ←---- 获取异步操作的结果,如果最终被阻塞,无法得到结果,那么在最多等待1秒钟之后退出
  9. } catch (ExecutionException ee) {
  10. // 计算抛出一个异常
  11. } catch (InterruptedException ie) {
  12. // 当前线程在等待过程中被中断
  13. } catch (TimeoutException te) {
  14. // 在Future对象完成之前超过已过期
  15. }

正像图16-1介绍的那样,这种编程方式让你的线程可以在ExecutorService以并发方式调用另一个线程执行耗时操作的同时,去执行一些其他的任务。接着,如果你已经运行到没有异步操作的结果就无法继续任何有意义的工作时,可以调用它的get方法去获取操作的结果。如果操作已经完成,该方法会立刻返回操作的结果,否则它会阻塞你的线程,直到操作完成,返回相应的结果。

16.1 Future接口 - 图1

图 16-1 使用Future以异步方式执行长时间的操作

你能想象这种场景存在怎样的问题吗?如果该长时间运行的操作永远不返回了会怎样?为了处理这种可能性,虽然Future提供了一个无须任何参数的get方法,还是推荐大家使用重载版本的get方法,它接受一个超时的参数,通过它,你可以定义你的线程等待Future结果的最长时间,就像代码清单16-1中那样,而不是永无止境地等待下去。

16.1.1 Future接口的局限性

通过第一个例子,我们知道Future接口提供了方法来检测异步计算是否已经结束(使用isDone方法),等待异步操作结束,以及获取计算的结果。但是这些特性还不足以让你编写简洁的并发代码。比如,我们很难表述Future结果之间的依赖性;从文字描述上这很简单,“当长时间计算任务完成时,请将该计算的结果通知到另一个长时间运行的计算任务,这两个计算任务都完成后,将计算的结果与另一个查询操作结果合并”。但是,使用Future中提供的方法完成这样的操作又是另外一回事。这也是我们需要更具描述能力的特性的原因,比如下面这些。

  • 将两个异步计算合并为一个——这两个异步计算之间相互独立,同时第二个又依赖于第一个的结果。
  • 等待Future集合中的所有任务都完成。
  • 仅等待Future集合中最快结束的任务完成(有可能因为它们试图通过不同的方式计算同一个值),并返回它的结果。
  • 通过编程方式完成一个Future任务的执行(即以手工设定异步操作结果的方式)。
  • 应对Future的完成事件(即当Future的完成事件发生时会收到通知,并能使用Future计算的结果进行下一步的操作,不只是简单地阻塞等待操作的结果)。

这一章中,你会了解新的CompletableFuture类(它实现了Future接口)如何利用Java 8的新特性以更直观的方式将上述需求都变为可能。StreamCompletableFuture的设计都遵循了类似的模式:它们都使用了Lambda表达式以及流水线的思想。从这个角度,你可以说CompletableFutureFuture的关系就跟StreamCollection的关系一样。

16.1.2 使用CompletableFuture构建异步应用

为了展示CompletableFuture的强大特性,我们会创建一个名为“最佳价格查询器”(best-price-finder)的应用,它会查询多个在线商店,依据给定的产品或服务找出最低的价格。这个过程中,你会学到几个重要的技能。

  • 首先,你会学到如何为你的客户提供异步API(如果你拥有一间在线商店的话,这是非常有帮助的)。
  • 其次,你会掌握如何让你使用了同步API的代码变为非阻塞代码。你会了解如何使用流水线将两个接续的异步操作合并为一个异步计算操作。这种情况肯定会出现,比如,在线商店返回了你想要购买商品的原始价格,并附带着一个折扣代码——最终,要计算出该商品的实际价格,你不得不访问第二个远程折扣服务,查询该折扣代码对应的折扣比率。
  • 你还会学到如何以响应式的方式处理异步操作的完成事件,以及随着各个商店返回它的商品价格,最佳价格查询器如何持续地更新每种商品的最佳推荐,而不是等待所有的商店都返回他们各自的价格(这种方式存在着一定的风险,一旦某家商店的服务中断,用户就可能遭遇白屏)。

同步API与异步API

同步API其实只是对传统方法调用的另一种称呼:你调用了某个方法,调用方在被调用方执行的过程中会等待,被调用方执行结束返回,调用方取得被调用方的返回值并继续运行。即使调用方和被调用方在不同的线程中运行,调用方还是需要等待被调用方结束运行,这就是阻塞式调用名字的由来。

与此相反,异步API会直接返回,或者至少在被调用方计算完成之前,将它剩余的计算任务交由另一个线程去做,该线程和调用方是异步的——这就是非阻塞式调用的由来。执行剩余计算任务的线程会将它的计算结果返回给调用方。返回的方式要么是通过回调函数,要么是由调用方再次执行一个“等待,直到计算完成”的方法调用。这种风格的计算在I/O系统程序设计中很常见:你发起了一次磁盘访问,如果你同时还有很多其他计算任务,那这次访问与其他计算任务会异步执行,你完成其他任务没有别的事情做时,会等待磁盘块载入内存。注意,阻塞和非阻塞通常用于描述操作系统的某种I/O实现。然而,这些术语也常常等价地用在非I/O的上下文中,即“异步调用”和“同步调用”。