《Java的函数式》第十三章: 异步任务

286 阅读28分钟

现代工作负载要求更加注重如何有效地使用可用的系统资源。异步任务是改善应用程序响应性、避免性能瓶颈的优秀工具。

Java 8引入了新的通用类型CompletableFuture,它改进了之前可用的Future类型,通过采用声明式和函数式的方法创建异步任务。

本章将解释为什么以及如何利用异步编程,以及CompletableFuture如何比之前JDK中包含的方式更灵活、更具功能性地处理异步任务。

同步与异步

同步和异步任务的概念不仅限于软件开发。

例如,面对面的会议或电话会议是一种同步活动,至少在你专心参与的情况下是这样。除了参与和可能做笔记之外,你不能做其他任何事情。在会议/电话会议结束之前,其他任务都会被阻塞。如果会议/电话会议改为邮件形式——就像我的大多数会议本应该如此——在恢复之前,你的当前任务不会被打断,也不需要立即处理。因此,邮件是非阻塞的通信方式。

这些原则在软件开发中同样适用。同步执行的任务按顺序依次执行,阻塞其他工作直到它们完成。从单线程的角度来看,阻塞任务意味着等待结果,可能会浪费资源,因为在任务完成之前无法做其他任何事情。

异步任务是指启动一个在“其他地方”处理的任务,并在完成时得到通知。这种任务通过使用并发技术在另一个线程中进行处理,从而不必等待它们完成,实现了非阻塞。因此,当前线程不会被阻塞,可以继续执行其他任务,如图13-1所示。

image.png

Java的Futures

Java 5引入了接口java.util.concurrent.Future作为异步计算的最终结果的容器类型。要创建一个Future,以Runnable或Callable形式提交的任务被提交到ExecutorService中,该服务在单独的线程中启动任务,但立即返回一个Future实例。这样,当前线程可以继续进行更多的工作,而无需等待Future计算的最终结果。

通过在Future实例上调用get方法可以检索结果,如果计算尚未完成,这可能会阻塞当前线程。示例13-1中可视化了一般流程的简单示例。

var executor = Executors.newFixedThreadPool(10); 

Callable<Integer> expensiveTask = () -> { 
    System.out.println("(task) start");

    TimeUnit.SECONDS.sleep(2);

    System.out.println("(task) done");

    return 42;
};

System.out.println("(main) before submitting the task");

var future = executor.submit(expensiveTask); 

System.out.println("(main) after submitting the task");

var theAnswer = future.get(); 

System.out.println("(main) after the blocking call future.get()");
// OUTPUT:
// (main) before submitting the task
// (task) start
// (main) after submitting the task
// ~~ 2 sec delay ~~
// (task) done
// (main) after the blocking call future.get()

尽管Future类型实现了作为异步计算的非阻塞容器的基本要求,但其功能集仅限于几个方法:检查计算是否完成、取消计算以及检索其结果。 为了拥有一个多功能的异步编程工具,还有很多功能值得期待:

  • 一种更简单的检索结果的方式,例如在完成或失败时进行回调。
  • 在函数式组合的精神下,链接和组合多个任务。
  • 集成的错误处理和恢复功能。
  • 无需ExecutorService手动创建或完成任务。

Java 8改进了Futures来解决缺失的功能,引入了接口CompletionStage及其唯一实现CompletableFuture,它们位于同一个包java.util.concurrent中。它们是构建具有比之前的Futures更丰富功能集的异步任务管道的多功能工具。Future是异步计算最终值的容器类型,而CompletionStage表示异步管道的单个阶段,拥有超过70个方法的大量API!

使用CompletableFuture设计异步管道

CompletableFutures的总体设计哲学与Streams类似:两者都是基于任务的管道,提供接受通用函数接口的参数化方法。新的API添加了大量的协调工具,返回CompletionStage或CompletableFuture的新实例。这种将异步计算和协调工具结合起来的容器提供了以前缺失的所有功能,以流畅可组合和声明性的API方式进行操作。

由于CompletableFuture API的复杂性以及异步编程的复杂心智模型,让我们从一个简单的比喻开始:做早餐。

想象中的早餐包括咖啡、吐司和鸡蛋。按照同步或阻塞的顺序准备早餐并没有太多意义。在开始煮咖啡或者吐司烤好之前等待会浪费可用资源,并且会增加不必要的准备时间,导致你等到坐下来吃饭时已经饿了。相反,你可以在咖啡机和烤面包机工作时开始煎鸡蛋,并且只有当烤面包机弹出或者咖啡机完成时才对它们做出反应。

同样的逻辑适用于编程。应该根据需要分配可用资源,而不是等待计算密集或长时间运行的任务而浪费资源。这种异步管道的基本概念在许多语言中以不同的、可能更常见的名称存在:Promises(承诺)。

承诺一个值

承诺是具有内置协调工具的异步管道的构建块,它允许链接和组合多个任务,包括错误处理。这样的构建块可以处于挂起状态(未解决)、已解决状态(已解决并完成计算)或拒绝状态(已解决,但处于错误状态)。在组合管道中在不同状态之间进行切换是通过在数据通道和错误通道之间进行切换来完成的,如图13-2所示。

image.png

数据通道是在一切顺利的情况下的正常路径。然而,如果一个承诺失败,管道会切换到错误通道。这样,一个失败不会像Streams那样导致整个管道崩溃,可以优雅地处理或甚至恢复,并将管道切换回数据通道。

正如你将看到的,CompletableFuture API就是另一个名称的承诺。

创建CompletableFuture

与其前身Future一样,新的CompletableFuture类型也没有提供任何构造函数来创建实例。通过将任务提交给ExecutorService来创建新的Future实例,该ExecutorService会返回一个已经启动了任务的实例。

CompletableFuture遵循相同的原则。然而,它并不一定需要显式地使用ExecutorService来调度任务,这要归功于它的静态工厂方法:

  • CompletableFuture runAsync(Runnable runnable)
  • CompletableFuture supplyAsync(Supplier supplier)

这两个方法也可以接受第二个参数,该参数接受java.util.concurrent.Executor,它是ExecutorService类型的基本接口。如果选择不使用Executor的变体,则会使用常见的ForkJoinPool,就像在“作为并行函数管道的流”中解释的那样,用于并行流管道。

创建CompletableFuture实例几乎等同于创建Future实例,如示例13-2所示。该示例没有使用类型推断,因此返回类型是可见的。通常情况下,你会更喜欢使用var关键字而不是显式指定类型。

// Future<T>

var executorService = ForkJoinPool.commonPool();

Future<?> futureRunnable =
  executorService.submit(() -> System.out.println("not returning a value"));

Future<String> futureCallable =
  executorService.submit(() -> "Hello, Async World!");

// CompleteableFuture<T>

CompletableFuture<Void> completableFutureRunnable =
  CompletableFuture.runAsync(() -> System.out.println("not returning a value"));

CompletableFuture<String> completableFutureSupplier =
  CompletableFuture.supplyAsync(() -> "Hello, Async World!");

尽管Future和CompletableFuture之间的实例创建方式相似,但后者更简洁,因为它不一定需要一个ExecutorService。然而,更大的区别在于,CompletableFuture实例提供了一个声明性和函数式管道的起点,它由CompletionStage实例组成,而不是像Future那样作为一个孤立的异步任务。

组合和合并任务

在开始使用CompletableFuture实例之后,现在是时候进一步组合和组成它们,创建一个更复杂的管道了。

用于构建异步管道的各种操作可以分为三类,取决于它们接受的参数和预期的用例:

  • 转换结果

类似于Streams和Optionals的map操作,CompletableFuture API提供了类似的thenApply方法,它使用一个Function<T, U>来转换类型为T的先前结果,并返回另一个CompletionStage。如果转换函数返回另一个CompletionStage,使用thenCompose方法可以避免额外的嵌套,类似于Stream和Optional的flatMap操作。

  • 消费结果

正如其名称所示,thenAccept方法需要一个Consumer来处理类型为T的先前结果,并返回一个新的CompletionStage。

  • 在完成后执行

如果不需要访问先前的结果,thenRun方法执行一个Runnable,并返回一个新的CompletionStage。

有太多的方法无法详细讨论每一个,特别是带有额外的-Async方法。大多数这些方法都有两个额外的-Async变体:一个与非Async版本匹配,另一个带有额外的Executor参数。

非Async方法在与前一个任务相同的线程中执行任务,尽管这并不能得到保证,正如后面的“关于线程池和超时”的解释中所述。-Async变体将使用由常见的ForkJoinPool创建的新线程,或者由提供的Executor创建的新线程。

为了保持简单,我将讨论非Async变体。

组合任务

组合任务会创建一个由连接的CompletionStage实例组成的串行管道。 所有的组合操作都遵循一个通用的命名方案:

<操作>[Async](参数[,Executor])<操作>[Async](参数 [, Executor])

<操作>名称派生自操作类型和其参数,主要使用前缀then加上它们所接受的函数接口的SAM名称:

  • CompletableFuture thenAccept(Consumer<? super T> action)
  • CompletableFuture thenRun(Runnable action)
  • CompletableFuture thenApply(Function<? super T,? extends U> fn)

由于API的命名规范,使用任何一种操作都会得到一个流畅而简单的调用链。例如,想象一个书签管理器,它会爬取网页以存储永久副本。整个任务可以异步运行,以免阻塞UI线程。任务本身包括三个步骤:下载网页、为离线使用准备内容,最后将其存储,如示例13-3所示。

var task = CompletableFuture.supplyAsync(() -> this.downloadService.get(url))
                            .thenApply(this.contentCleaner::clean)
                            .thenRun(this.storage::save);

组合操作只是一对一的操作,意味着它们接收前一个阶段的结果并执行它们的预期工作。如果您的任务管道需要多个流程汇聚在一起,您需要组合任务。

合并任务

将相互关联的CompletableFuture组合起来创建一个更复杂的任务可以非常有帮助。然而,有时候不同的任务不需要组合或可以串行运行。在这种情况下,您可以通过使用接受另一个阶段的操作来组合CompletionStage实例,除了它们通常的参数之外。

它们的命名方案与之前的一对一组合操作类似:

<操作><限制>[Async](other,argument[,Executor])<操作><限制>[Async](other, argument [, Executor])

额外的限制指示操作是否适用于两个阶段,还是任一阶段,使用恰当命名的后缀-Both和-Either。

截屏2023-06-21 15.51.45.png

与其他功能性Java特性一样,许多不同的操作是由Java的静态类型系统以及泛型类型的解析方式所决定的。与JavaScript等其他语言不同,方法不能在单个参数中接受多个类型或作为返回类型。

组合操作可以与组合操作轻松混合使用,如图13-3所示。

image.png

可用的操作提供了几乎适用于任何用例的各种功能。然而,在Java的异步API中还存在某些盲点,尤其是缺少一种特定的变体:使用返回另一个阶段的BiFunction组合两个阶段的结果,而不创建嵌套的CompletionStage。

thenCombine的行为类似于Java中的其他映射操作。在返回值嵌套的情况下,需要类似于flatMap的操作,而CompletableFuture类型中缺少此操作。相反,您需要使用额外的thenCompose操作来展平嵌套的值,如示例13-4所示。

CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> 42); 
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> 23); 

BiFunction<Integer, Integer, CompletableFuture<Integer>> task = 
  (lhs, rhs) -> CompletableFuture.supplyAsync(() -> lhs + rhs);

CompletableFuture<Integer> combined = future1.thenCombine(future2, task) 
                                             .thenCompose(Function.identity());

如果任务返回一个CompletableFuture,而不是依靠调用者在需要时将其包装成CompletableFuture来异步处理,这种方法非常有帮助。

同时运行多个CompletableFuture实例

前面讨论的操作允许您同时运行最多两个CompletableFuture实例以创建一个新的实例。然而,使用像thenCombine这样的组合操作处理超过两个实例而不创建嵌套的方法调用会变得非常困难。这就是为什么CompletableFuture类型具有两个静态便捷方法来处理多个实例的原因:

  • CompletableFuture allOf(CompletableFuture<?>... cfs)
  • CompletableFuture anyOf(CompletableFuture<?>... cfs)

    allOf和anyOf方法协调预先存在的实例。因此,它们都不提供匹配的-Async变体,因为每个给定的CompletableFuture实例已经有了其指定的执行器。协调性质的另一个方面是它们的限制性返回类型。因为两者都接受任何类型的CompletableFuture实例(由通配符<?>表示),无法确定整体结果的确切类型T,因为类型可以自由混合。allOf的返回类型是CompletableFuture,因此您无法在后续阶段中访问给定实例的任何结果。然而,可以创建支持以替代方式返回结果的辅助方法。我将在“创建一个CompletableFuture辅助方法”中向您展示如何实现这一点,但现在让我们首先了解CompletableFuture的其他操作。

    异常处理

    到目前为止,我向您展示的管道只顺利进行,没有任何问题。然而,如果管道中发生异常,Promise可能会被拒绝,或者在Java中称为complete exceptionality。 CompletableFuture API将异常视为一等公民和工作流程的重要组成部分,而不是像Streams或Optionals那样在发生异常时破坏整个管道。因此,异常处理并不强加给任务本身,并且有多个操作可用于处理可能被拒绝的Promise:

    • CompletionStage exceptionally(Function<Throwable, T> fn)
    • CompletionStage handle(BiFunction<T, Throwable, U> fn)
    • CompletionStage whenComplete(BiConsumer<T, Throwable> action)

    使用exceptionally操作将异常挂钩到管道中,如果在任何前一阶段中没有发生异常,它将以前一阶段的结果正常完成。在被拒绝的阶段中,其异常将应用于挂钩的fn以进行恢复。为了进行恢复,fn需要返回类型为T的任何值,这将使管道切换回数据通道。如果无法进行恢复,抛出一个新的异常或重新抛出已应用的异常将保持管道处于异常完成状态并处于错误通道上。

    更灵活的handle操作将exceptionally和thenApply的逻辑合并为单个操作。BiFunction参数取决于前一阶段的结果。如果它被拒绝,类型为Throwable的第二个参数是非空的。否则,类型为T的第一个参数具有值。请注意,它仍然可能是一个空值。

    最后一个操作whenComplete类似于handle,但不提供恢复被拒绝的Promise的方法。

    数据通道和错误通道再次讨论

    尽管我解释了Promise在技术上有两个通道,即数据通道和错误通道,但CompletableFuture管道实际上是一条直线的操作序列,就像流(Streams)一样。每个管道阶段都会寻找下一个兼容的操作,这取决于当前阶段已完成的状态。如果正常完成,接下来的then/run/apply等操作将执行。对于异常完成的阶段,这些操作将被“跳过”,而管道将继续寻找下一个exceptionally/handle/whenComplete等操作。

    CompletableFuture管道可能是通过流畅的调用创建的一条直线。然而,将其可视化为之前在图13-2中所做的两个通道,可以更好地了解发生的情况。每个操作都存在于数据通道或错误通道中,除了handle和whenComplete操作,它们存在于两个通道之间,因为它们无论管道的状态如何都会执行。

    拒绝的任务

    通过使用组合操作,可以将另一个CompletableFuture注入到直线型管道中。你可能会认为后缀“-Either”意味着任何一个管道都可以正常完成以创建一个新的非拒绝阶段。然而,你将会有一个惊喜! 如果前一阶段被拒绝,acceptEither操作将保持被拒绝状态,无论另一个阶段是否正常完成,如示例13-5所示。

    CompletableFuture<String> notFailed =
      CompletableFuture.supplyAsync(() -> "Success!");
    
    CompletableFuture<String> failed =
      CompletableFuture.supplyAsync(() -> { throw new RuntimeException(); });
    
    // NO OUTPUT BECAUSE THE PREVIOUS STAGE FAILED
    var rejected = failed.acceptEither(notFailed, System.out::println);
    
    // OUTPUT BECAUSE THE PREVIOUS STAGE COMPLETED NORMALLY
    var resolved = notFailed.acceptEither(failed, System.out::println);
    // => Success!
    

    要记住的要点是,除了错误处理操作外,所有操作都需要一个非拒绝的前一阶段才能正常工作,即使是-Either操作也是如此。如果有疑问,可以使用错误处理操作来确保管道仍然在数据通道上。

    终端操作

    到目前为止,任何操作都会返回另一个CompletionStage,以进一步扩展管道。基于Consumer的操作可能满足许多用例,但在某个时候,您需要实际的值,即使它可能会阻塞当前线程。

    CompletionStage类型本身与Future类型相比并没有提供任何额外的检索方法。然而,它的实现CompletableFuture提供了两个选项:getNow和join方法。这使得终端操作的数量增加到四个,如表13-2所示。

    截屏2023-06-21 16.03.04.png

    CompletableFuture类型还添加了另一种管道协调方法isCompletedExceptionally,使您总共有四种方法来影响或检索管道的状态,如表13-3所示。

    截屏2023-06-21 16.03.57.png

    这是一个非常庞大的API,涵盖了许多用例。但是,根据您的需求,可能会缺少一些边缘情况。但是,添加自己的辅助方法来填补任何空白是很容易的,所以让我们来做吧。

    创建一个CompletableFuture助手

    尽管CompletableFuture API非常庞大,但仍然缺少某些使用情况。例如,在之前的“组合任务”中提到的,静态助手方法allOf的返回类型是CompletableFuture,因此您无法在后续阶段访问给定实例的任何结果。它是一个灵活的协调方法,接受任何类型的CompletableFuture<?>作为参数,但代价是无法访问任何结果。为了弥补这一点,您可以根据需要创建一个助手方法来补充现有的API。

    让我们创建一个助手方法,类似于allOf,可以同时运行多个CompletableFuture实例,并且仍然可以访问它们的结果:

    static CompletableFuture<List<T>> eachOf(CompletableFuture<T> cfs...)
    

    所提议的助手方法eachOf类似于allOf,它运行所有给定的CompletableFuture实例。然而,与allOf不同的是,新的助手方法使用了泛型类型T,而不是?(问号)。这个对于特定类型的限制使得eachOf方法能够返回一个CompletableFuture<List<T>>,而不是一个没有结果的CompletableFuture<Void>

    助手的框架

    需要一个方便的类来保存任何辅助方法。这些辅助方法对于特定的边缘情况非常有用,这些情况无法用提供的API以简洁的方式解决,甚至根本无法解决。最惯用且安全的方法是使用一个带有私有构造函数的类,如下所示,以防止任何人意外地扩展或实例化该类型。

    public final class CompletableFutures {
    
      private CompletableFutures() {
        // suppress default constructor
      }
    }
    

    设计 eachOf

    eachOf 的目标与 allOf 方法几乎完全相同。这两个方法都协调一个或多个 CompletableFuture 实例。然而,eachOf 更进一步,还管理结果。这导致以下要求:

    1. 返回包含所有给定实例的 CompletableFuture,类似于 allOf。
    2. 允许访问成功完成实例的结果。

    第一个要求可以通过 allOf 方法实现。然而,第二个要求需要额外的逻辑。它要求您逐个检查给定的实例并汇总它们的结果。 在任何方式下,运行上一阶段完成后的任何逻辑最简单的方法是使用 thenApply 操作:

    public static
    <T> CompletableFuture<List<T>> eachOf(CompletableFuture<T>... cfs) {
      return CompletableFuture.allOf(cfs)
                              .thenApply(???);
    }
    

    根据您在本书中学到的知识,可以通过创建一个流式数据处理管道来汇总成功完成的 CompletableFuture 实例的结果。

    让我们逐步介绍创建这样一个管道所需的步骤。 首先,必须从给定的 CompletableFuture 实例创建流。它是一个可变参数方法参数,因此对应于一个数组。在处理可变参数时,使用 Arrays#stream(T[] arrays) 方法是显而易见的选择: Arrays.stream(cfs) 接下来,对成功完成的实例进行筛选。没有明确的方法可以询问一个实例是否正常完成,但是您可以通过 Predicate.not 来询问相反的情况: Arrays.stream(cfs) .filter(Predicate.not(CompletableFuture::isCompletedExceptionally))

    有两种方法可以立即从 CompletableFuture 中获取结果:get() 和 join()。在这种情况下,后者更可取,因为它不会抛出已检查的异常,从而简化了 Stream 管道,正如第 10 章讨论的那样:

    Arrays.stream(cfs)
          .filter(Predicate.not(CompletableFuture::isCompletedExceptionally))
          .map(CompletableFuture::join)
    

    使用 join 方法会阻塞当前线程以获取结果。然而,Stream 管道在 allOf 完成后运行,因此所有结果已经可用。通过事先过滤掉未成功完成的元素,不会抛出可能导致管道崩溃的异常。

    最后,将结果聚合为 List。可以使用 collect 操作来完成,或者如果您使用的是 Java 16+,还可以使用 Stream 类型的 toList 方法:

    Arrays.stream(cfs)
          .filter(Predicate.not(CompletableFuture::isCompletedExceptionally))
          .map(CompletableFuture::join)
          .toList();
    

    现在可以使用 Stream 管道在 thenApply 调用中收集结果。CompletableFuture 及其 eachOf 辅助方法的完整实现如示例 13-6 所示。

    public final class CompletableFutures {
    
      private final static Predicate<CompletableFuture<?>> EXCEPTIONALLY = 
        Predicate.not(CompletableFuture::isCompletedExceptionally);
    
      public static <T> CompletableFuture<List<T>> eachOf(CompletableFuture<T>... cfs) {
        Function<Void, List<T>> fn = unused -> 
          Arrays.stream(cfs)
                .filter(Predicate.not(EXCEPTIONALLY))
                .map(CompletableFuture::join)
                .toList();
    
        return CompletableFuture.allOf(cfs) 
                                .thenApply(fn);
      }
    
      private CompletableFutures() {
        // suppress default constructor
      }
    }
    

    完成了!我们为某些情况下创建了一个替代 allOf 的方法,以便轻松访问结果。 最终的实现是一种功能性问题解决方法的示例。每个任务都是独立的,可以单独使用。但是,通过组合它们,您可以创建由较小部分构建的更复杂的解决方案。

    改进CompletableFutures辅助类

    eachOf方法按照预期与allOf方法相辅相成。如果给定的任何CompletableFuture实例失败,返回的CompletableFuture<List>也将异常完成。 然而,还存在一些"发射并忘记"的使用情况,您只对成功完成的任务感兴趣,并不关心任何失败。然而,如果尝试使用get或类似的方法提取失败的CompletableFuture的值,它将抛出异常。因此,让我们添加一个基于eachOf的bestEffort辅助方法,它总是成功完成并只返回成功的结果。

    主要目标与eachOf几乎相同,只是如果allOf调用返回了异常完成的CompletableFuture,它必须进行恢复。通过插入一个exceptionally操作来添加异常处理是显而易见的选择:

    public static
    <T> CompletableFuture<List<T>> bestEffort(CompletableFuture<T>... cfs) {
      Function<Void, List<T>> fn = ...; // no changes to Stream pipeline
    
      return CompletableFuture.allOf(cfs)
                              .exceptionally(ex -> null)
                              .thenApply(fn);
    }
    

    异常的 lambda 表达式 ex -> null 乍一看可能有点奇怪。但如果你查看底层的方法签名,它的意图就变得更清晰了。 在这种情况下,exceptionally 操作需要一个 Function<Throwable, Void> 来通过返回类型为 Void 的值来恢复 CompletableFuture,而不是抛出异常。这通过返回 null 来实现。之后,使用来自 eachOf 的聚合 Stream 流水线来收集结果。

    现在我们有两个具有共享逻辑的辅助方法,将共同的逻辑提取到它们自己的方法中可能是有意义的。这反映了将孤立的逻辑组合起来创建更复杂和完整任务的函数式方法。CompletableFuture 的可能重构实现如示例 13-7 所示。

      public final class CompletableFutures {
    
      private final static Predicate<CompletableFuture<?>> EXCEPTIONALLY = 
        Predicate.not(CompletableFuture::isCompletedExceptionally);
    
      private static <T> Function<Void, List<T>>
                         gatherResultsFn(CompletableFuture<T>... cfs) { 
        return unused -> Arrays.stream(cfs)
                               .filter(Predicate.not(EXCEPTIONALLY))
                               .map(CompletableFuture::join)
                               .toList();
      }
    
      public static
      <T> CompletableFuture<List<T>> eachOf(CompletableFuture<T>... cfs) { 
        return CompletableFuture.allOf(cfs)
                                .thenApply(gatherResultsFn(cfs));
      }
    
      public static
      <T> CompletableFuture<List<T>> bestEffort(CompletableFuture<T>... cfs) { 
        return CompletableFuture.allOf(cfs)
                                .exceptionally(ex -> null)
                                .thenApply(gatherResultsFn(cfs));
      }
    
      private CompletableFutures() {
        // suppress default constructor
      }
    }
    

    重构后的 CompletableFutures 辅助类比以前更简单且更健壮。任何可共享的复杂逻辑都得到了重用,因此它在整个方法中提供一致的行为,并最大程度地减少了所需的文档说明,您应该确保添加以向任何调用者传达预期功能。

    手动创建和完成

    除了自己实现接口外,创建 Future 实例的唯一方法是将任务提交给 ExecutorService。CompletableFuture 的静态便捷工厂方法 runAsync 或 supplyAsync 非常相似。与它们的前身不同,它们不是创建实例的唯一方法。

    手动创建

    感谢它是一个实际的实现而不是一个接口,CompletableFuture类型具有一个构造函数,您可以使用它来创建一个未解决的实例,如下所示:

    CompletableFuture<String> unsettled = new CompletableFuture<>();
    

    然而,如果没有附加的任务,它将永远不会完成或失败。相反,您需要手动完成这样的任务。

    手动完成

    有几种方法可以解决现有的CompletableFuture实例并启动附加的流水线:

    • boolean complete(T value)
    • boolean completeExceptionally(Throwable ex)

    这两种方法在将阶段转换为预期状态时返回true。 Java 9引入了额外的complete方法来处理正常完成的阶段,形式为-Async变体和基于超时的方法:

    • CompletableFuture completeAsync(Supplier supplier)
    • CompletableFuture completeAsync(Supplier supplier, Executor executor)
    • CompletableFuture completeOnTimeout(T value, long timeout, TimeUnit unit)

    -Async变体使用供应商的结果在新的异步任务中完成当前阶段。 另一种方法,completeOnTimeout,在超时之前如果阶段没有完成,使用给定的值解决当前阶段。 除了创建一个新实例然后手动完成它之外,您还可以使用这些静态便捷工厂方法之一创建一个已完成的实例:

    • CompletableFuture completedFuture(U value)
    • CompletableFuture failedFuture(Throwable ex)(Java 9+)
    • CompletionStage completedStage(U value)(Java 9+)
    • CompletionStage failedStage(Throwable ex)(Java 9+)

    这样的已完成的future可以在任何组合操作中使用,或者作为CompletableFuture流水线的起点,我将在下一节中讨论。

    手动创建和完成实例的用例

    实质上,CompletableFuture API提供了一种简单的方式来创建具有多个步骤的异步任务流水线。通过手动创建和完成阶段,您可以对之后的流水线执行方式进行细粒度控制。例如,如果结果已知,您可以避免启动任务。或者您可以为常见任务创建一个部分流水线工厂。

    让我们看几个可能的用例。

    CompletableFutures作为返回值

    CompletableFuture类型非常适合作为可能昂贵或长时间运行的任务的返回值。

    想象一个天气预报服务,它调用一个REST API返回一个WeatherInfo对象。尽管天气随时间变化,但在更新信息之前,将WeatherInfo缓存一段时间是有意义的,可以通过另一个REST调用来更新信息。

    与简单的缓存查找相比,REST调用自然更昂贵并且需要更长的时间,因此可能会阻塞当前线程的时间太长,难以接受。将其包装在CompletableFuture中提供了一种将任务从当前线程转移的简单方法,以下是具有一个公共方法的通用WeatherService示例:

    public class WeatherService {
    
      public CompletableFuture<WeatherInfo> check(ZipCode zipCode) {
        return CompletableFuture.supplyAsync(
          () -> this.restAPI.getWeatherInfoFor(zipCode)
        );
      }
    }
    

    添加缓存需要两个方法,一个用于存储任何结果,另一个用于检索现有结果:

    public class WeatherService {
    
      private Optional<WeatherInfo> cached(ZipCode zipCode) {
        // ...
      }
    
      private WeatherInfo storeInCache(WeatherInfo info) {
        // ...
      }
    
      // ...
    }
    

    使用Optional为您提供了一个功能性的起点,以便稍后连接每个部分。对于示例的目的和意图,缓存机制的实际实现并不重要。

    实际的API调用也应进行重构,以创建更小的逻辑单元,从而导致一个公共方法和三个私有的独立操作。将结果存储在缓存中的逻辑可以作为CompletableFuture操作添加,使用thenApply和storeInCache方法,返回CompletableFuture:

    public class WeatherService {
    
      private Optional<WeatherInfo> cacheLookup(ZipCode zipCode) {
        // ...
      }
    
      private WeatherInfo storeInCache(WeatherInfo info) {
        // ...
      }
    
      private CompletableFuture<WeatherInfo> restCall(ZipCode zipCode) {
        Supplier<WeatherInfo> restCall = this.restAPI.getWeatherInfoFor(zipCode);
    
        return CompletableFuture.supplyAsync(restCall)
                                .thenApply(this::storeInCache);
      }
    
      public CompletableFuture<WeatherInfo> check(ZipCode zipCode) {
        // ...
      }
    }
    

    现在,所有部分都可以组合起来完成提供缓存天气服务的任务,如示例13-8所示。

    public class WeatherService {
    
      private Optional<WeatherInfo> cacheLookup(ZipCode zipCode) { 
        // ...
      }
    
      private WeatherInfo storeInCache(WeatherInfo info) { 
        // ...
      }
    
      private CompletableFuture<WeatherInfo> restCall(ZipCode zipCode) { 
        Supplier<WeatherInfo> restCall = () -> this.restAPI.getWeatherInfoFor(zipCode);
    
        return CompletableFuture.supplyAsync(restCall)
                                .thenApply(this::storeInCache);
      }
    
      public CompletableFuture<WeatherInfo> check(ZipCode zipCode) { 
        return cacheLookup(zipCode).map(CompletableFuture::completedFuture) 
                                   .orElseGet(() -> restCall(zipCode)); 
      }
    }
    

    以这种方式将CompletableFuture与Optional结合使用的优点在于,对于调用方来说,不管数据是通过REST加载还是直接来自缓存,背后发生了什么并不重要。每个私有方法都以最高效的方式执行单一任务,而唯一的公共方法将它们组合成一个异步任务流水线,只在绝对需要时才执行其昂贵的工作。

    待处理的CompletableFuture流水线

    一个待处理的CompletableFuture实例不会自动完成,并且没有任何状态。类似于流(Streams),在连接终端操作之前不会启动数据处理,CompletableFuture任务流水线在第一个阶段完成之前不会执行任何工作。因此,它为更复杂的任务流水线的第一个阶段提供了一个完美的起点,甚至可以作为预定义任务的脚手架,在稍后根据需要执行。

    想象一下,您想处理图像文件。涉及多个独立的可能失败的步骤。而不是直接处理文件,一个工厂提供了未解决的CompletedFuture实例,如示例13-9所示。

    public class ImageProcessor {
    
      public record Task(CompletableFuture<Path> start, 
                         CompletableFuture<InputStream> end) {
        // NO BODY
      }
    
      public Task createTask(int maxHeight,
                             int maxWidth,
                             boolean keepAspectRatio,
                             boolean trimWhitespace) {
        var start = new CompletableFuture<Path>(); 
    
        var end = unsettled.thenApply(...) 
                           .exceptionally(...)
                           .thenApply(...)
                           .handle(...);
    
        return new Task(start, end); 
      }
    }
    

    运行任务流水线是通过在第一个阶段start上调用任何一个complete方法来完成的。然后,最后一个阶段用于检索可能的结果,如下所示:

    // CREATING LAZY TASK
    var task = this.imageProcessor.createTask(800, 600, false, true);
    
    // RUNNING TASK
    var path = Path.of("a-functional-approach-to-java/cover.png");
    task.start().complete(path);
    
    // ACCESSING THE RESULT
    var processed = task.end().get();
    

    就像没有终端操作的流水线创建了一个用于多个项的惰性处理流水线一样,一个待处理的CompletableFuture流水线是一个惰性可用的任务流水线,适用于单个项。

    关于线程池和超时

    并发编程的最后两个方面不能被忽视:超时和线程池。

    默认情况下,所有的-Async操作使用JDK的共享ForkJoinPool。它是一个基于运行时设置的高度优化的线程池,具有合理的默认值。正如其名称所示,“common”池是一个共享池,也被JDK的其他部分使用,如并行流。不过,与并行流不同,异步操作可以使用自定义的Executor。这使得您可以使用适合您需求的线程池,而不会影响共享池。

    在运行任务时选择最高效的线程只是方程式的一半;考虑超时是另一半。一个永远不会完成或超时的CompletableFuture将一直保持待处理状态,阻塞其线程。如果您尝试检索其值,例如通过调用get方法,当前线程也会被阻塞。选择适当的超时时间可以防止线程被永久阻塞。然而,使用超时时间意味着您现在还必须处理可能的TimeoutException异常。

    在CompletableFuture中有多个可用的操作,包括中间操作和终端操作,如表13-4所列。

    截屏2023-06-21 16.32.07.png

    中间操作completeOnTimeout和orTimeout提供了类似拦截器的操作,可以在CompletableFuture流水线的任何位置处理超时。

    取消运行阶段的另一种选择是调用boolean cancel(boolean mayInterruptIfRunning)。它可以取消一个未完成的阶段及其依赖项,因此可能需要一些协调和跟踪来取消正确的阶段。

    关于异步任务的最后思考

    异步编程是并发编程中实现更好性能和响应性的重要方面。然而,理解异步代码的执行时间和执行线程并不容易。

    协调不同的线程对于Java来说并不新鲜。如果您不熟悉多线程编程,协调线程可能会很麻烦,而且很难做到正确和高效。这就是CompletableFuture API的优势所在。它将复杂的异步任务和协调它们的方式结合到了一个广泛、一致且易于使用的API中。这使您可以更轻松地将异步编程整合到代码中,而无需处理通常与多线程编程相关的样板代码和"护栏"。

    然而,就像其他编程技术一样,异步任务也有最佳适用的问题背景。如果滥用异步任务,可能会适得其反。

    运行异步任务适用于以下情况之一:

    • 需要同时执行多个任务,并且至少有一个任务能够取得进展。
    • 执行大量I/O、长时间运行的计算、网络调用或任何类型的阻塞操作的任务。
    • 任务之间大部分是独立的,不需要等待其他任务完成。

    即使使用了高级抽象,如CompletableFuture类型,多线程代码仍然以可能的效率为代价而牺牲了简单性。

    与其他并发或并行的高级API(如第8章讨论的并行流API)一样,协调多个线程涉及到非明显的成本。这样的API应该被有意识地选择为一种优化技术,而不是一种期望更高效地利用可用资源的通用解决方案。

    如果您对如何安全地处理多线程环境的细节感兴趣,我推荐阅读布莱恩·戈茨(Brian Goetz)的书《Java并发编程实战》(Java Concurrency in Practice)。布莱恩·戈茨是Oracle的Java语言架构师。尽管自2006年出版以来引入了许多新的并发特性,但这本书仍然是该领域的权威参考手册。