承接的问题
15.1 讲作用域的形状,15.2 讲 Joiner 的语义。剩下最现实的问题:线上跑着的 ExecutorService 代码要不要改?怎么改?
先把预期摆正。很多资料把迁移说成「代码更短、更清晰」,本机实测并不支持这个说法:同一段三下游扇出,用 StructuredTaskScope 写出来非空行只少 6 行,token 反而多 8 个,因为泛型签名 Stream<StructuredTaskScope.Subtask<String>> 很占 token。真正的差别在行为:同样的失败,一个 828ms 才返回且副作用已经扩散,另一个 140ms 返回且兄弟任务全部取消。
所以这一节不写「怎么改写得漂亮」,只写三件事:迁移前后到底差在哪、哪些代码根本不能迁、preview 阶段迁移要付什么代价。
同一段扇出,两种写法
场景固定:reserve 成功后向 mail、sms、push 三个下游发通知,全部成功才算成功。push 在第 120ms 返回 503,另外两个各睡 800ms。先看迁移前的写法:
public void fanout(String memberId, String text) {
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
List<Future<String>> futures = new ArrayList<>();
try {
for (String name : CHANNELS) {
StructuredNotifier.Channel channel = channels.get(name);
futures.add(pool.submit(() -> {
channel.send(memberId, text);
return name;
}));
}
for (Future<String> future : futures) {
future.get();
}
} catch (ExecutionException e) {
futures.forEach(f -> f.cancel(true));
Throwable cause = e.getCause();
if (cause instanceof LoanException loan) {
throw loan;
}
throw new LoanException(ErrorCode.CONFLICT, "通知扇出失败: " + cause);
} catch (InterruptedException e) {
futures.forEach(f -> f.cancel(true));
Thread.currentThread().interrupt();
throw new LoanException(ErrorCode.INTERRUPTED, "通知扇出被中断");
} finally {
pool.shutdown();
}
}
这段已经算「写得讲究」:有 cancel(true)、有异常解包、有 shutdown。迁移后(StructuredNotifier.fanout,15.2 给过完整类):
public void fanout(String memberId, String text) {
try (var scope = StructuredTaskScope.<String, Stream<StructuredTaskScope.Subtask<String>>>open(
StructuredTaskScope.Joiner.allSuccessfulOrThrow(),
cfg -> cfg.withThreadFactory(Thread.ofVirtual().name("notify-", 0).factory()))) {
for (String name : CHANNELS) {
Channel channel = channels.get(name);
scope.fork(() -> {
channel.send(memberId, text);
return name;
});
}
scope.join().toList(); // 全部成功才会走到这里
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LoanException(ErrorCode.INTERRUPTED, "通知扇出被中断");
} catch (StructuredTaskScope.FailedException e) {
Throwable cause = e.getCause();
if (cause instanceof LoanException loan) {
throw loan;
}
throw new LoanException(ErrorCode.CONFLICT, "通知扇出失败: " + cause);
}
}
DemoMigrate 把两个版本跑在同一个失败场景上。本机输出:
[push] + 145ms 503
[sms ] + 827ms ok
[mail] + 827ms ok
executor + 828ms code=CONFLICT msg=push 下游 503
[push] + 135ms 503
[mail] + 139ms INTERRUPTED
[sms ] + 139ms INTERRUPTED
scope + 140ms code=CONFLICT msg=push 下游 503
度量(token 用 cl100k_base 分词,它不认 Java 语法,只看相对量级):
| 版本 | 非空行 | 字符 | token | 失败到返回 | 兄弟任务 |
|---|---|---|---|---|---|
ExecutorNotifier.fanout | 29 | 1166 | 222 | 828ms | 未被取消,跑满 827ms |
StructuredNotifier.fanout | 23 | 1084 | 230 | 140ms | 139ms 被中断 |
结论有点反直觉:迁移换来的不是代码量,是失败语义。 行数少 6 行,token 多 8 个;但一次下游故障的代价从 828ms(还发了两次不该发的通知)降到 140ms(另外两个立刻停)。
为什么 executor 版本即使写了 futures.forEach(f -> f.cancel(true)) 也救不回来?因为 get() 是按提交顺序阻塞的:先等 mail 的 800ms,等到了才发现 push 早就失败了,这时 sms/mail 早就跑完,cancel(true) 对已完成的任务毫无作用。要提前发现失败,得改用 CompletionService 或 CompletableFuture.anyOf,那就等于把调度逻辑重写一遍。这是迁移最实在的收益:不是少写代码,是不用自己实现「谁先失败谁触发取消」。
四条硬边界
边界一:作用域不能跨,子任务不能借
Subtask 是作用域的成员,不是通用句柄。三条实测约束:
- 只有 owner 线程能
fork。 换个线程(哪怕是虚拟线程)往同一个作用域里 fork,抛java.lang.WrongThreadException: Current thread not owner。想把任务从别的线程塞进来,只能回到submit。 join之后作用域冻结。 再fork抛IllegalStateException: Already joined or scope is closed(15.1 实测)。- 关闭后能读结果,但不能取消。 作用域关闭后再
get()一个已成功的Subtask,本机实测仍然拿得到值;而被取消的那个是UNAVAILABLE,get()抛IllegalStateException: Result is unavailable or subtask did not complete successfully。
三条约束跑一遍就能看清边界在哪:
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class ScopeBoundary {
public static void main(String[] args) throws Exception {
System.out.println("A. 非 owner 线程 fork");
try (var scope = StructuredTaskScope.<String>open()) {
Thread other = Thread.ofVirtual().start(() -> {
try {
scope.fork(() -> "borrowed");
} catch (RuntimeException e) {
System.out.println(" " + e.getClass().getName() + ": " + e.getMessage());
}
});
other.join();
scope.join();
}
System.out.println("B. 作用域关闭后读结果");
Subtask<String> done;
Subtask<String> cancelled;
try (var scope = StructuredTaskScope.<String, String>open(
StructuredTaskScope.Joiner.anySuccessfulResultOrThrow())) {
done = scope.fork(() -> "fast");
cancelled = scope.fork(() -> {
Thread.sleep(Duration.ofSeconds(5));
return "slow";
});
scope.join();
}
System.out.println(" done.state=" + done.state() + " done.get=" + done.get());
System.out.println(" cancelled.state=" + cancelled.state());
try {
cancelled.get();
} catch (RuntimeException e) {
System.out.println(" cancelled.get -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
}
本机输出:
A. 非 owner 线程 fork
java.lang.WrongThreadException: Current thread not owner
B. 作用域关闭后读结果
done.state=SUCCESS done.get=fast
cancelled.state=UNAVAILABLE
cancelled.get -> IllegalStateException: Result is unavailable or subtask did not complete successfully
也就是说,Subtask 可以安全地传给别的组件读结果,但它对生命周期的控制权永远留在原作用域。想把「A 作用域的任务交给 B 作用域等」这种结构表达出来,API 层面没有入口——这是设计,不是缺失。
边界二:异常一定会被包一层
executor 版本里,业务异常被包在 ExecutionException 里;作用域版本里,被包在 FailedException 里。两者都需要 getCause() 解包,但解包的必要性不一样:
ExecutionException只有在你主动get()时才出现,很多代码根本不get,于是异常静默丢失;FailedException是join()抛出来的,你想忽略都难——这既是优点也是迁移成本:所有catch (LoanException e)的分支都要改成先接FailedException再解包。
还有一个更容易漏的点:Subtask.get() 在 FAILED 状态下抛的是 IllegalStateException,不是原始异常。原来写 catch (LoanException e) 的地方,换成 task.get() 之后会变成 catch (IllegalStateException e),编译不报错、测试可能也过,直到线上才发现错误码全丢了。
FailedException 还只保留一个 cause:三个下游同时失败时,本机实测 getSuppressed().length == 0,另外两个异常不会挂上来。要完整失败列表,只能在每个子任务内部记录。
边界三:虚拟线程不是迁移理由
作用域的子任务默认跑在虚拟线程上(15.1 实测 virtual=true),但这不代表「迁移是为了用虚拟线程」——第 14 章的 newVirtualThreadPerTaskExecutor() 已经给你虚拟线程了。两者的分工是:
| 关注点 | 虚拟线程 | 作用域 |
|---|---|---|
| 线程成本 | 每个任务一条,栈按需增长 | 每个子任务一条虚拟线程,不改变成本模型 |
| 任务边界 | 不管 | 由代码块定义,关闭即取消 |
| 失败传播 | 不管 | 由 Joiner 定义 |
| 并发上限 | 不管 | 不管(仍需 Semaphore,14.3) |
所以迁移的判断标准不是「有没有用虚拟线程」,而是「这段代码是否需要『任务必须在离开前结束』」。已经是虚拟线程池 + 手工取消 + awaitTermination 的代码,迁移收益最大;纯 CPU 计算、本来就用 ForkJoinPool 的代码,迁移只会多一层包装。
边界四:preview 的代价是构建链
StructuredTaskScope 在 JDK 25 仍是 preview,未来版本 API 可能变化,代价不只是加个参数:
# 故意不加 --enable-preview,看编译器怎么说
$JAVA_HOME/bin/javac --release 25 -d out MigrateDemo.java
MigrateDemo.java:4: 错误: StructuredTaskScope 是预览 API,默认情况下处于禁用状态。
import java.util.concurrent.StructuredTaskScope.Subtask;
^
(请使用 --enable-preview 以启用预览 API)
编译时加了参数还不够,class 文件会带上 preview 标记,换个 JVM 启动就报错:
错误: 加载主类 MigrateDemo 时出现 LinkageError
java.lang.UnsupportedClassVersionError: Preview features are not enabled for MigrateDemo (class file version 69.65535). Try running with '--enable-preview'
major version: 69 是 JDK 25,minor: 65535 是 preview 标记。这意味着:编译产物只能由同一个大版本的 JDK 加载,且启动命令必须带 --enable-preview;容器镜像、java -jar 启动脚本、CI 的测试命令都要同步改。API 本身也还可能变——本卷用到的 open/Joiner 就是这一轮 preview 的新形状,更早的 preview 版本里「一个类一种策略」的写法已经被替换掉了。在生产环境迁移前,先确认你能接受「每次升级 JDK 都要重新验证这套 API」这件事。
在 libraryd-conc 里落地
迁移顺序建议按「扇出 → 超时 → 上下文」三步走,每一步都能独立回滚:
- 先迁纯扇出:
StructuredNotifier(15.2)替换ExecutorNotifier。这一步只改调用结构,不改业务逻辑,用同一个DemoMigrate就能对比 828ms 与 140ms。 - 再补超时:给
Configuration加withTimeout(Duration.ofSeconds(2)),把「下游卡死」从线程池的awaitTermination里挪出来。15.1 的StructuredLoanService已经是这个形状。 - 最后处理上下文:
ScopedValue(7.3)在作用域内自动传给子任务,ThreadLocal不会。第 13.3 节的装饰器包装可以整段删掉——这一步改动最大,放最后。
三步都完成后,conc-core 里同时存在 VirtualLoanService(14 章,ExecutorService)与 StructuredLoanService,让调用方按场景选:
// 需要「任务边界」的请求路径
LoanService service = new StructuredLoanService(books, loans, members);
// 后台批处理:任务要比请求活得久,继续用执行器
try (ExecutorService batch = Executors.newVirtualThreadPerTaskExecutor()) {
isbns.forEach(isbn -> batch.submit(() -> reindex(isbn)));
}
编译与运行(preview 参数两处都不能少):
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
mvn -q compile -Dmaven.compiler.enablePreview=true
java --enable-preview --source 25 -cp target/classes com.plume.conc.core.DemoMigrate
注意第二步的 withTimeout 不是银弹:它只保证「时间到了 join 返回」,被取消的子任务如果卡在不可中断的调用里,close() 还是会等(15.2 的取消语义一)。超时能给的是「调用方不被拖死」,不是「下游立刻停手」。
什么时候不要用
- 任务要比请求活得久:审计、埋点、异步索引。迁移过去就得为它们单独开一个作用域,还得保证不随着请求线程结束被取消,等于把「发射后不管」硬塞进「必须结束」的模型里。
- 需要队列和背压:作用域没有队列、没有最大并发、没有拒绝策略。上游流量突增时,
fork就是无限创建虚拟线程,第 10 章的线程池参数在这里全部失效,只能用Semaphore兜底。 - 任务集合动态增长:
join之后不能fork,分页拉取、递归展开这类场景用ForkJoinPool或CompletionService更自然。 - 团队还在 JDK 21/24:preview API 的形态在版本之间变过(策略类的合并就是例子),跨版本维护的成本高于收益,等转正再迁。
- 只是想把代码写短:本机实测 token 反而多 8 个。收益在语义,不在篇幅。
常见错误与边界
一、迁移时只替换了 API,没替换错误处理。 把 submit 换成 fork、get 换成 join,但 catch (ExecutionException) 没改成 FailedException,编译直接失败;如果两边都 catch (Exception),则会在运行时把 FailedException 当成业务异常上报,错误码全部变成未知。
二、在子任务里 fork。 实测抛 WrongThreadException,而且这个异常发生在子任务内部,会被 FailedException 包一层,堆栈看起来像「下游调用失败」,很容易误判。
三、以为 close() 是即时的。 close() 返回时子任务确实都结束了,但「结束」可能是被中断后还在跑 finally。有资源释放逻辑的子任务,要把清理放在 finally 里并保证它不阻塞。
四、把 withTimeout 当熔断。 超时只影响这一次 join,不统计失败率、不打开熔断器。熔断是另一层的事(卷三第 12 章展开)。
五、class 文件跨环境复用。 preview 的 class 文件 minor=65535,本机实测换 JVM 启动直接 UnsupportedClassVersionError。构建产物不要跨 JDK 版本分发,Docker 镜像里的 JRE 必须和编译 JDK 同版本。
六、以为 --enable-preview 只影响编译。 运行也要带;mvn test 需要额外配 argLine,否则测试跑起来全是 UnsupportedClassVersionError。
小结
- 同一段三下游扇出:executor 版本 29 行 / 222 token / 828ms 且未取消兄弟任务;作用域版本 23 行 / 230 token / 140ms 且 139ms 取消兄弟任务。迁移买的是失败语义,不是代码量。
- 四条硬边界:作用域不可跨(只有 owner 能 fork,实测
WrongThreadException)、异常必包一层(FailedException,且只带一个 cause)、虚拟线程不是迁移理由、preview 让构建链绑定单一 JDK 版本。 - 异常解包是迁移中最容易漏的一步:
Subtask.get()在失败时抛IllegalStateException,业务异常在exception()里;join()抛的FailedException需要getCause()。 - 迁移顺序:扇出 → 超时 → 上下文,每步可独立回滚;「任务要比请求活得久」的场景留在执行器里。
- preview 风险具体到命令:编译、运行、测试三处都要
--enable-preview,class 文件69.65535不能跨 JDK 复用。
第 15 章到此结束。从第 1 章的线程模型到这里的结构化并发,卷一的主线是「让并发代码的边界可被检查」;接下来附录 A 是并发 API 速查,附录 B 是诊断命令与 JFR 事件。
阅读导航:上一节 15.2 Joiner、失败传播与取消 | 下一节 目录与学习路径
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。