DEEPDIVE · CONCURRENCY · JEP 453 / 446
结构化并发:让任务有作用域
JDK 21 · 深钻主题 05 / 08 · 预计 50 分钟
一句话结构化并发把一组并发子任务放进一个作用域:父任务等待所有子任务结束,任一失败就取消其余,不再留下孤儿线程。JDK 21 中它是预览 API,配合虚拟线程和 ScopedValue 构成新的并发编程模型。
本质:并发任务应该有明确的开始与结束。作用域结束时,所有子任务要么完成,要么被取消——错误和取消都沿任务树向上传播,而不是消失在某个线程池里。
学完你要能自己回答
- 结构化并发解决了
ExecutorService.invokeAll的什么问题? - ShutdownOnFailure 与 ShutdownOnSuccess 分别适合什么场景?
- 作用域关闭时,未完成的子任务会怎样?异常如何传播?
- ScopedValue 和 ThreadLocal 的核心差异是什么?为什么它适合虚拟线程?
- JDK 21 中这些 API 处于什么状态?生产使用要注意什么?
01 任务树:父子同生共死02 两种关闭策略
任务树:父子同生共死01
传统线程池里,提交任务像把信投进邮筒:投出去之后,任务和提交者就没有结构关系了。提交者可能已经返回,任务还在跑;任务失败了,异常可能只躺在 Future 里没人取。
结构化并发把任务组织成一棵树:fork 创建子任务,join 等待它们,作用域结束时子任务必须全部终止。父任务不会在子任务还活着时悄悄离开。
| 维度 | 线程池 + Future | 结构化并发 |
|---|---|---|
| 生命周期 | 提交后脱离,可能泄漏 | 作用域内,关闭时强制收敛 |
| 错误传播 | 每个 Future 单独检查 | 策略统一处理,失败可取消兄弟任务 |
| 取消 | 手动 cancel,容易遗漏 | 作用域关闭自动取消未完成任务 |
| 可观测性 | 线程池队列 + 零散 Future | 任务树清晰,父子关系明确 |
| 适用场景 | 长期服务、任务队列 | 请求内 fan-out/fan-in、短生命周期任务 |
结构化并发的「结构化」来自结构化编程:控制流有明确的进入和退出。并发也应该有。
两种关闭策略02
ShutdownOnFailure:任一失败,全部取消
适合「所有子任务都成功才有意义」的场景,例如同时查用户和订单,任一查询失败就返回错误,不必等另一个慢查询。
ShutdownOnSuccess:任一成功,其余取消
适合「多副本取最快」的场景,例如向两个镜像发起同一请求,谁先成功用谁;也适合 hedged request(延迟触发备份请求)。
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> loadUser(id));
var order = scope.fork(() -> loadOrder(id));
scope.join().throwIfFailed();
return new Response(user.get(), order.get());
}
join() 等待所有子任务结束或作用域被关闭;throwIfFailed() 把第一个失败包装成 ExecutionException 抛出。try-with-resources 的 close() 会确保没有遗留任务。
01 API 形状与执行模型02 取消与异常传播的细节03 ScopedValue:不可变上下文
API 形状与执行模型01
public class StructuredTaskScope<T> implements AutoCloseable {
public <U extends T> Subtask<U> fork(Callable<? extends U> task);
public StructuredTaskScope<T> join() throws InterruptedException;
public StructuredTaskScope<T> joinUntil(Instant deadline)
throws InterruptedException, TimeoutException;
public void shutdown();
public void close();
}
public interface Subtask<T> {
State state(); // SUCCESS / FAILED / UNAVAILABLE
T get(); // 仅 SUCCESS 时可用,否则抛 IllegalStateException
Throwable exception(); // 仅 FAILED 时可用
}
- 默认情况下,
fork在虚拟线程上执行子任务;可以通过构造器传入自定义ThreadFactory。 join只能由创建作用域的所有者线程调用;在子任务里调用会抛IllegalStateException。close会先shutdown,再等待所有子任务终止;如果所有者线程被中断,close仍会等待。- 子任务的异常不会自动打印;必须通过
throwIfFailed()、result()或Subtask.exception()显式获取。
取消与异常传播的细节02
- 任一子任务失败,
ShutdownOnFailure调用shutdown(),中断其他子任务。 - 被中断的子任务如果响应中断(如阻塞 I/O、
Thread.sleep),会尽快结束;不响应中断的任务可能让close()等待较久。 - 所有者调用
throwIfFailed()时,抛出第一个失败的ExecutionException,原异常在 cause 中。 - 如果所有子任务都成功,
throwIfFailed()什么也不做,可以安全读取get()。
取消是协作式的:作用域只能中断子任务,不能强制杀死。子任务必须正确响应
InterruptedException,否则结构化并发也救不了它。ScopedValue:不可变上下文03
ThreadLocal 是「每个线程一个可变副本」,在虚拟线程数量巨大时内存开销明显,而且容易忘记清理。ScopedValue 是「在某个作用域内绑定一个不可变值」,子任务自动继承,作用域结束即失效。
private static final ScopedValue<String> USER = ScopedValue.newInstance();
void handle() {
ScopedValue.where(USER, "alice").run(() -> {
System.out.println(USER.get()); // alice
// fork 出的子任务也能读到 USER
});
// 作用域外 USER.isBound() == false
}
| ThreadLocal | ScopedValue | |
|---|---|---|
| 可变性 | 可变,可 set/remove | 绑定后不可变 |
| 生命周期 | 跟随线程,需手动清理 | 跟随作用域,自动失效 |
| 虚拟线程开销 | 每个虚拟线程一份 | 共享绑定,按作用域继承 |
| 继承 | InheritableThreadLocal 有坑 | 子任务结构化继承 |
| 状态 | 正式 API | JDK 21 预览 |
01 从 invokeAll 迁移到作用域02 可运行示例:并发加载与快速失败
从 invokeAll 迁移到作用域01
// Before:超时与取消要自己拼
List<Callable<Result>> tasks = List.of(this::loadA, this::loadB);
List<Future<Result>> futures = executor.invokeAll(tasks, 1, TimeUnit.SECONDS);
for (Future<Result> f : futures) {
if (f.isCancelled()) throw new TimeoutException();
results.add(f.get());
}
// After:作用域负责生命周期,超时用 joinUntil
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var a = scope.fork(this::loadA);
var b = scope.fork(this::loadB);
scope.joinUntil(Instant.now().plusSeconds(1)).throwIfFailed();
return new Pair(a.get(), b.get());
}
joinUntil超时会抛TimeoutException,随后close()取消未完成任务。- 如果某个子任务失败,
ShutdownOnFailure会取消另一个,避免继续做无用功。 - 不要在外层再套一个线程池等待;作用域自己管理子任务线程。
可运行示例:并发加载与快速失败02
import java.util.concurrent.*;
public class StructuredDemo {
record User(String id) {}
record Order(String id) {}
static User loadUser() throws Exception {
Thread.sleep(100);
return new User("u-1");
}
static Order loadOrder() throws Exception {
Thread.sleep(150);
return new Order("o-1");
}
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(StructuredDemo::loadUser);
var order = scope.fork(StructuredDemo::loadOrder);
scope.join().throwIfFailed();
System.out.println(user.get() + " / " + order.get());
}
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> { Thread.sleep(300); return "slow"; });
scope.fork(() -> { Thread.sleep(100); return "fast"; });
scope.join();
System.out.println("winner=" + scope.result());
}
}
}
javac --enable-preview --release 21 StructuredDemo.java
java --enable-preview StructuredDemo
User[id=u-1] / Order[id=o-1] winner=fast
第二个作用域的慢任务会被取消/中断,但示例中的 Thread.sleep 会响应中断;如果子任务捕获并吞掉中断,close() 可能等待它自然结束。
01 先猜再看02 费曼自检:生命周期与传播
先猜再看01
先猜再看作用域里一个子任务失败后,另一个子任务还在阻塞,会发生什么?
ShutdownOnFailure 会调用 shutdown 并中断另一个子任务。若它响应中断,会尽快结束;若吞掉中断,close 仍要等它结束,结构化并发无法强制终止。
先猜再看ScopedValue 的值能被 sub-task 修改吗?
不能。ScopedValue 绑定后不可变;子任务只能读取当前绑定。需要不同值就开一个新的 where 作用域。
先猜再看结构化并发能替代线程池吗?
不能完全替代。它适合请求内短生命周期的 fan-out/fan-in;长期服务、任务队列、定时调度仍然需要线程池或调度器。
费曼自检:生命周期与传播02
01. 结构化并发最重要的不变量是什么?
作用域关闭时,所有子任务都已终止;父任务不会在子任务仍运行时返回或抛出。
02. join 与 close 的分工?
join 等待任务结束或策略触发 shutdown;close 负责最终收敛,确保没有遗留任务,并释放资源。
03. 为什么子任务抛出的异常不会自动打印?
异常被保存在 Subtask 中,由策略决定如何聚合和传播;必须显式调用 throwIfFailed/result/exception 才能观察到。
04. ScopedValue 为什么比 ThreadLocal 更适合虚拟线程?
它按作用域共享不可变绑定,不需要每个虚拟线程复制一份可变状态,也不会因忘记 remove 而泄漏。
05. JDK 21 使用结构化并发需要注意什么?
它是预览 API,需要 --enable-preview;API 在后续版本可能调整,生产使用要评估升级成本。
学习进度
记录保存在本机浏览器 · 不上传