探索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 和响应式编程作为替代方案。结构化并发结合虚拟线程,使并发编程更易编写、调试和理解,同时避免了响应式编程的一些复杂性。

相关推荐
wellc13 分钟前
SpringBoot集成Flowable
java·spring boot·后端
cwxcc22 分钟前
Google Core Web Vitals(核心网页指标)
前端·性能优化
Hui Baby1 小时前
springAi+MCP三种
java
hsjcjh1 小时前
【MySQL】C# 连接MySQL
java
敖正炀1 小时前
LinkedBlockingDeque详解
java
wangyadong3171 小时前
datagrip 链接mysql 报错
java
untE EADO1 小时前
Tomcat的server.xml配置详解
xml·java·tomcat
花椒技术1 小时前
从7S到4S,我们如何系统性降低直播播放延迟
性能优化·程序员
ictI CABL1 小时前
Tomcat 乱码问题彻底解决
java·tomcat
敖正炀1 小时前
DelayQueue 详解
java