探索Java 全新的线程模型——结构化并发

结构化并发(Structured Concurrency)是 Project Loom 发布的第二项成果,就在我们在第 1 章介绍的虚拟线程之后。本章我们将:

  • 了解什么是结构化并发
  • 通过示例使用结构化并发
  • 深入了解 StructuredTaskScope 提供的底层 API
  • 创建自定义的 StructuredTaskScope 实现
  • 了解结构化并发的替代方案

什么是结构化并发?

结构化并发让你可以通过明确的分支点(将执行拆分为多个任务)以及结果重新合并的点来推理并发代码。它对于并发的意义,就像 for 循环和 if 分支对于结构化编程的意义一样。结构化并发允许你将并发操作的生命周期限制在特定的作用域内。在结构化编程中,如果我们有一个 if 语句,那么在 if 块内部定义的变量的生命周期仅限于该块,它们无法泄露到 if 块之外。对于结构化并发也是一样:作用域之外,这些操作不存在,从而保证不会有"游离"的执行线程,避免内存泄漏或无谓的 CPU 消耗。

要充分理解结构化并发的威力和易用性,我们需要先看看它的对立面------非结构化并发是什么。

非结构化并发

先来看一下 Java 中的非结构化并发。以下示例使用了天气 API,我们尝试获取纽约的当前天气、天气预报和历史数据,通过向 ExecutorService 提交三条独立请求实现:

kotlin 复制代码
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
    var currentFuture  = es.submit(this::getCurrent);
    var forecastFuture = es.submit(this::getForecast);
    var historyFuture  = es.submit(this::getHistory);
    return new Result(currentFuture.get(),
                      forecastFuture.get(),
                      historyFuture.get());
}

如果其中某个请求失败怎么办?我们可能无法满足用户请求,因为并非所有数据都可用。但我们无法终止另外两个请求,尽管我们永远不会使用它们的结果。

同样,如果用户决定取消请求,我们也无法取消提交给 ExecutorService 的三个任务。

事情会根据哪个线程先启动以及哪个线程抛出异常而变得更加复杂,但线程泄漏仍然可能发生。

通过 ExecutorService 执行的任务通常被视为非结构化任务。它们是独立调用的,并且与同一 ExecutorService 启动的其他任务没有直接关系。

结构化并发

了解了非结构化并发后,我们来看看结构化并发如何改进它。结构化并发的核心思想是:一个任务可以包含子任务,而子任务本身又可以包含子任务,因此实际上形成了一棵树状的任务结构。该结构的生命周期绑定于定义它的代码块。在 Java 的结构化并发 API 中,这称为 StructuredTaskScope,我们将在下一节详细介绍。当这个作用域关闭时,保证所有任务和子任务要么已完成,要么已被取消。

StructuredTaskScope

StructuredTaskScope<T> 是位于 java.util.concurrent 包中的结构化并发 API 的核心类。它是一个小型 API,仅包含 6 个公共方法和 2 个受保护方法:

csharp 复制代码
public <U extends T> Subtask<U> fork(Callable<? extends U> task);
public StructuredTaskScope<T> join();
public StructuredTaskScope<T> joinUntil(Instant deadline);
public void close();
public void shutdown();
public boolean isShutdown();
protected void ensureOwnerAndJoined();
protected void handleComplete(StructuredTaskScope.SubTask<? extends T> subTask);

StructuredTaskScope 基本示例

下面是一个使用 StructuredTaskScope 的基础示例,仅用于说明。实际中,你更可能使用 StructuredTaskScope 的直接子类,它们对 handleComplete 方法有不同实现,或者你也可以自己编写子类(后面会讲到)。一般情况下不必自定义子类,但如果现有实现不满足需求,你可以自由创建自己的实现。

dart 复制代码
try (var scope = new StructuredTaskScope<String>()) {
    Supplier<String> currentSubtask = scope.fork(this::getCurrent);
    Supplier<String> forecastSubtask = scope.fork(this::getForecast);
    Supplier<String> historySubtask = scope.fork(this::getHistory);
    scope.join();
    return new Result(currentSubtask.get(),
                      forecastSubtask.get(),
                      historySubtask.get());
}

从结构上来看,它可以表示为一棵树状的任务结构。

让我们拆解一下这里发生的事情。首先,我们在 try-with-resources 块中创建了一个新的 StructuredTaskScope。这是很重要的,因为 StructuredTaskScope 实现了 AutoCloseable 接口。该接口保证了在 try-with-resources 块执行完毕后,运行时会自动调用 StructuredTaskScopeclose 方法。

接下来,我们通过将要执行的方法传递给 fork 方法,指定希望并发执行的三个任务。注意,fork 方法会返回一个 SubTask 对象,不过推荐将其强制转换为 Supplier,因为 SubTask 实现了 Supplier 接口。

最后,我们调用 join 方法。这会使当前线程等待作用域内的所有任务完成,或者作用域被关闭。

当所有任务完成后,线程继续执行,我们可以调用子任务的 get() 方法来获取结果。

由此可见,结构化并发的一般流程如下:

  1. 创建任务作用域(task scope),作为所有子任务的根。
  2. 在该作用域中创建任意数量的子任务。
  3. 创建作用域的线程加入该作用域(同时隐式加入所有子任务)。
  4. 创建作用域的线程阻塞,直到所有子任务完成。
  5. 创建作用域的线程处理可能的错误。
  6. 创建作用域的线程在完成后关闭作用域(自动完成)。

子任务(Subtasks)

我们需要进一步了解 join 方法返回的 Subtask<T> 接口,它也位于 java.util.concurrent 包中。

乍一看,join 方法似乎返回一个 Supplier<T>,这在本质上是正确的,因为 Subtask 实现了 Supplier<T> 函数式接口。语言设计者更倾向于将 join 的结果强制转换为 Supplier 而不是 SubTask

Subtask 本身是一个接口,更准确地说是一个 sealed 接口 。Sealed 接口允许更精细地控制哪些类可以实现它。在 Subtask 的情况下,只允许一个类实现它:SubtaskImpl

csharp 复制代码
public sealed interface Subtask<T> extends Supplier<T> permits SubtaskImpl

有人可能会说,如果只允许一个子类,那么接口不如直接实现为 final 类。但使用接口保留了未来扩展的可能性------将来只需在 permits 中加入新的类即可,而 final 类无法做到这一点。这是一个很好的示例,说明如何为未来扩展保持开放而不必现在就担心。

Subtask 持有一个枚举 State,表示子任务结果的状态,有三种可能值:

  • UNAVAILABLE:尚未完成,或任务作用域关闭后完成
  • SUCCESS:已完成,结果可用
  • FAILED:完成时抛出异常,结果不可用

当子任务状态为 SUCCESS 时,可以通过调用 get() 获取结果;如果尚未完成或失败,则调用 get() 会抛出 IllegalStateException

如果状态为 FAILED,可以通过 exception() 方法获取执行期间抛出的异常;如果尚未完成或已成功完成,调用 exception() 也会抛出 IllegalStateException

SubtaskImpl 是唯一允许实现 Subtask 接口的类,定义如下:

csharp 复制代码
private static final class SubtaskImpl<T> implements Subtask<T>, Runnable

可以看到,它实现了 Runnable 接口。当 StructuredTaskScopefork() 方法被调用时,它会接收一个 Callable,然后创建 SubtaskImpl 实例,并在 run() 方法中调用 call() 执行任务并获取结果,同时记录异常以便后续获取。

默认使用虚拟线程

结构化并发中的任务默认由 虚拟线程 支持。在上一章中,你已经了解了虚拟线程相比平台线程的诸多优势。因此虚拟线程作为默认选项也不足为奇。

StructuredTaskScope 的默认构造函数如下:

csharp 复制代码
public StructuredTaskScope() {
    this(null, Thread.ofVirtual().factory());
}

当然,你也可以使用平台线程,只需调用不同的构造函数:

arduino 复制代码
public StructuredTaskScope(String name, ThreadFactory factory)

如果想使用平台线程,可以这样:

csharp 复制代码
public StructuredTaskScope() {
    this(null, Thread.ofPlatform().factory());
}

不过除非有充分理由,否则不推荐使用平台线程。

处理超时

到目前为止,join 方法会等待任务完成,不管需要多长时间。但通常你希望限制等待时间,此时可以使用 joinUntil 方法,提供一个 Instant 类型的最大等待时间:

java 复制代码
public StructuredTaskScope<T> joinUntil(Instant deadline)
        throws InterruptedException, TimeoutException

结构化并发策略(Structured Concurrency Policies)

正如前文所述,StructuredTaskScope 并非设计为直接使用。它提供了两个子类实现两种并发设计模式:Invoke AllInvoke Any 。这些模式并不新鲜,在 Java 8 的 ExecutorService 中就已经存在,但行为可能因线程池类型和容量不同而有所差异。结构化并发确保任务的行为和作用域定义明确。

Invoke All 模式

Invoke All 是最常用的模式,允许启动多个子任务,然后等待所有任务成功完成,或任意子任务抛出异常。

该模式由 StructuredTaskScope 的静态内部类 ShutdownOnFailure 实现。如果任意子任务抛出异常,则认为无法提供有效结果,整个作用域关闭------因此命名为 ShutdownOnFailure

之前的示例可以改写为使用 ShutdownOnFailure 策略:

kotlin 复制代码
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {      // 1
    Supplier<CurrentWeather> currentSubtask = scope.fork(this::getCurrent);   // 2
    Supplier<Forecast> forecastSubtask = scope.fork(this::getForecast);
    Supplier<History> historySubtask = scope.fork(this::getHistory);
    scope.join();                                                     // 3
    scope.throwIfFailed();                                            // 4
    return new Result(currentSubtask.get(),                           // 5
                      forecastSubtask.get(),
                      historySubtask.get());
}

流程解析:

  1. 创建一个类型为 ShutdownOnFailureStructuredTaskScope
  2. fork 三个子任务。
  3. 调用 scope.join(),让拥有作用域的线程等待所有子任务完成或抛出异常。
  4. 当线程继续执行(所有子任务完成或取消)时,通过 scope.throwIfFailed() 检查是否有异常抛出。
  5. 此时可以安全调用子任务的 get() 方法,因为确认没有异常发生。

注意我们将作用域定义为 StructuredTaskScope.ShutdownOnFailureShutdownOnFailureStructuredTaskScope 内的静态 final 类,它重写了受保护方法 handleComplete(Subtask) 来实现与 ShutdownOnSuccess 不同的行为:

typescript 复制代码
@Override
protected void handleComplete(Subtask<?> subtask) {
    if (subtask.state() == Subtask.State.FAILED
        && firstException == null
        && FIRST_EXCEPTION.compareAndSet(this, null, subtask.exception())) {
            super.shutdown();
    }
}

一旦第一个子任务失败,StructuredTaskScope 就会调用 shutdown(),取消所有仍在执行的子任务,并等待所有子任务取消完成后再关闭上下文,无需手动干预,极大简化了代码。

这种模式通常用于需要同时获取多组相互关联的数据。例如,客户详情页包含客户地址、未完成订单列表、最近完成订单列表、欠款余额及退货列表。

通过结构化并发,我们可以并发获取这些信息,同时仍保证在继续执行前所有信息已被获取。它提供了一种非常易于阅读、理解和推理的顺序式代码流。

Invoke Any 模式

与 Invoke All 模式相反,Invoke Any 模式是一种竞赛(race):最先返回结果的任务获胜。其思路是同时启动多个任务,一旦任意任务返回结果,仍在执行的其他任务将被取消。如果第一个结果是异常,则所有其他任务也会被取消,最终的执行结果就是这个异常。

示例代码如下:

kotlin 复制代码
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<>()) {   // 1
    scope.fork(this::getWeatherData);                                // 2
    scope.fork(this::getOpenWeatherData);
    scope.join();                                                     // 3
    return scope.result();                                            // 4
}

在这个示例中,我们从两个不同的 API 获取天气数据。我们并不在意具体使用哪个 API,而是选择响应最快的那个。这意味着每次调用该方法时,返回的数据可能来自不同的 API,重点是速度,而不是具体数据。

类似的情境也可以应用于导航,例如从 A 点到 B 点获取路线。可以向多个提供商(如 Google Maps 和 Apple Maps)请求路线,并使用最先响应的结果。

代码流程解析:

  1. 创建类型为 ShutdownOnSuccessStructuredTaskScope
  2. fork 子任务,不需要保存它们的返回值引用,因为最多只有一个结果可用,API 会帮我们保存该结果。
  3. 调用 scope.join(),等待第一个结果返回。
  4. 返回结果,通过 scope.result() 获取。如果没有结果(没有子任务成功完成),默认会抛出 ExecutionException。可以通过重写带参数的 result() 方法自定义异常:
php 复制代码
public <X extends Throwable> T result(Function<Throwable, ? extends X> esf) throws X

一旦任意子任务返回结果,StructuredTaskScope 会自动取消其他子任务,无需手动干预,非常方便。

自定义策略实现

假设我们计划去滑雪,希望确保所选地区有雪。可以创建一个自定义任务作用域,遍历若干可能的城市,查询天气 API 是否有雪。

自定义 SnowPolicy 示例:

scala 复制代码
public class SnowScope<T> extends StructuredTaskScope<T> {
    private List<T> results = Collections.synchronizedList(new ArrayList<>());

    @Override
    protected void handleComplete(Subtask<? extends T> subtask) {
        if (subtask.state() == Subtask.State.SUCCESS) {
            T result = subtask.get();
            results.add(result);
        }
    }

    public List<T> results() {
        return this.results;
    }
}

只有成功返回的任务会被计入返回结果。

使用示例:

scss 复制代码
public class CustomPolicyExample {
    private static final String API_KEY = "YOUR API KEY";

    public static void main(String... args) throws Exception {
        if (args.length == 0) {
            System.out.println("Usage: java CustomPolicyExample <space-separated list of cities>");
            System.exit(0);
        }
        new CustomPolicyExample().run(List.of(args));
    }

    private void run(List<String> cities) throws Exception {
        try(var scope = new SnowScope<CityWeather>()) {
            for (var city : cities) {
                scope.fork(() -> getWeather(city));
            }
            scope.join();
            scope.results().stream()
                .filter(e -> e.current().condition().text().contains("snow"))
                .collect(Collectors.toList())
                .forEach(System.out::println);
        }
    }

    private CityWeather getWeather(String city) throws Exception {
        var uri = StringTemplate.STR."http://api.weatherapi.com/v1/current.json?key={API_KEY}&q={city}";
        var request = HttpRequest.newBuilder()
                .uri(new URI(uri))
                .GET()
                .build();
        try(var client = HttpClient.newHttpClient()) {
            var response = client.send(request, HttpResponse.BodyHandlers.ofString());
            CityWeather cw = new Gson().fromJson(response.body(), CityWeather.class);
            return cw;
        }
    }
}

程序从命令行接收城市列表,创建自定义作用域,对每个城市调用 fork,执行 getWeather 方法,然后调用 join 等待所有任务完成,最后筛选出包含"snow"的结果。

注意,我们使用 Gson 解析 API 返回的 JSON 数据,需要在 API_KEY 字段中设置自己的 API 密钥。

结构化并发的替代方案

结构化并发并非全新概念,Java 已经有一些替代实现。在 JEP-428(最初描述结构化并发的 Java Enhancement Proposal)中提到,其目标 不是 替换 java.util.concurrent 中的任何并发构造,也不是定义 Java 的最终结构化并发 API。

常见的替代方案包括:

  1. Completable Futures
  2. 响应式编程(Reactive Programming)

Completable Futures

Completable Futures 是 Java 8 引入的,提供了异步实现的 Future 接口,并允许操作链式调用。它基于 CompletionStage 概念,一个阶段(stage)可以被另一个阶段触发。

示例:

ini 复制代码
private void run() throws Exception {
    CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> "Hello");
    CompletableFuture<String> world = CompletableFuture.supplyAsync(() -> "World!");
    CompletableFuture<String> result = hello.thenCombine(world, (a, b) -> a + "," + b);
    System.out.println(result.get());
}

这里定义了两个 CompletionStage,第三个阶段组合前两个阶段的结果并执行函数,最终调用 get() 获取值。

最初的结构化并发设计中,fork 操作返回的是 Future,后来改为 Subtask,因为 fork 的结果已经是最终值,而 CompletableFuture 更适合用于尚未完成的异步计算。

响应式编程

响应式编程是近年来流行的编程范式,基于 2013 年发布的 Reactive Manifesto,核心特性包括:响应性(Responsiveness)、弹性(Resilience)、弹性伸缩(Elasticity)、消息驱动(Message-driven)。

概念包括:生产者(Producers)、消费者(Consumers)、处理器(Processors)、流(Streams)。

基本思路是响应事件而非等待事件,以非阻塞方式处理异步数据。

Java 中常用框架:

  • RxJava
  • Project Reactor
  • Akka
  • Spring Reactive Streams

示例:

typescript 复制代码
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;

public class ReactiveExample {
    public static void main(String[] args) {
        Flux<String> flux = Flux.just("R", "e", "a", "c", "t", "i", "v", "e");  // 1
        flux
            .map(s -> s.toUpperCase())                                          // 2
            .publishOn(Schedulers.parallel())                                   // 3
            .subscribe(System.out::println);                                     // 4
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

流程:创建数据流(1)、处理(2)、发布(3)、订阅(4)。

缺点:

  • 学习曲线陡峭,需要不同思维方式
  • 增加应用复杂性,难以推理程序流程
  • 异步和并发行为使调试困难

总结

本章介绍了 Java 的结构化并发 API,讲解了主要组件及基本流程,展示了默认策略及自定义策略的实现,并对比了 Completable Futures 和响应式编程作为替代方案。结构化并发结合虚拟线程,使并发编程更易编写、调试和理解,同时避免了响应式编程的一些复杂性。

相关推荐
浮游本尊7 分钟前
Java学习第11天 - Spring Boot高级特性与实战项目
java
浮游本尊8 分钟前
Java学习第12天 - Spring Security安全框架与JWT认证
java
Wgllss14 分钟前
完整烟花效果,Compose + 协程 + Flow + Channel 轻松实现
android·架构·android jetpack
David爱编程26 分钟前
happens-before 规则详解:JMM 中的有序性保障
java·后端
小张学习之旅28 分钟前
ConcurrentHashMap
java·后端
程序猿阿伟1 小时前
《支付回调状态异常的溯源与架构级修复》
后端·架构
熊文豪1 小时前
保姆级Maven安装与配置教程(Windows版)
java·windows·maven·maven安装教程·maven配置教程·maven安装与配置教程
SmalBox1 小时前
【渲染流水线】[逐片元阶段]-[深度写入]以UnityURP为例
架构
怀旧,2 小时前
【C++】 9. vector
java·c++·算法
猿java2 小时前
Elasticsearch有哪几种分页方式?该如何选择?
后端·elasticsearch·架构