并发调用最难收拾的不是慢,而是有任务失联
一个接口要调三个下游:商品详情、库存、评价。三个调用互不依赖,并发发出去,等结果凑齐了拼响应。
这个场景太常见了,常见到大多数人不会去想它有什么难处。直到有一天,库存服务超时了,商品详情和评价其实已经拿到——但那个超时的调用还挂在那,占着线程、占着连接,没有人去收它。更麻烦的是,如果你决定「不等了,直接返回错误」,另外两个已经跑完的任务的资源也不会自动释放。
CompletableFuture 能帮你把三个调用并发出去,但它不帮你收拾残局。结构化并发(Structured Concurrency)要解决的就是这个残局。
CompletableFuture 的三个静默缺陷
先看一个典型的并发聚合写法:
CompletableFuture<Product> productF = fetchProduct(id);
CompletableFuture<Integer> stockF = fetchStock(id);
CompletableFuture<List<Review>> reviewsF = fetchReviews(id);
CompletableFuture.allOf(productF, stockF, reviewsF)
.thenApply(v -> new Response(productF.join(), stockF.join(), reviewsF.join()))
.exceptionally(e -> fallback(e));
能跑。但有三处会静默出错:
一、异常吞没。 fetchProduct 抛异常时,allOf 的 future 会失败,但 stockF 和 reviewsF 还在跑。exceptionally 只处理最终结果,不管中间过程——那两个还在跑的调用会继续占用下游资源,直到它们自己结束。
二、取消不传播。 你在网关层设了超时,超时后返回 504。但三个 future 不会因为调用方放弃了就自动停下来。除非你显式地在超时回调里对每个 future 调 cancel(true)——而大多数人不会写这个。
三、结果散落。 三个结果分别锁在三个 CompletableFuture 里,要凑到一起得在 lambda 闭包里访问它们。如果中间还要做校验、转换,代码会越写越绕,最后变成一坨 thenCompose 和 thenCombine 的嵌套。
这三个问题的根是同一个:这三个任务之间没有结构。 它们是三个独立的 future,碰巧被放在了一起,但语言层面不知道它们是一组。
结构化并发:把「一组」写进语法里
JEP 499 引入的 StructuredTaskScope 做的事情很简单:给一组并发任务建立一个作用域,作用域关闭的时候,里面的任务必须全部结束。
try (var scope = new StructuredTaskScope.Join()) {
Subtask<Product> product = scope.fork(() -> fetchProduct(id));
Subtask<Integer> stock = scope.fork(() -> fetchStock(id));
Subtask<List<Review>> reviews = scope.fork(() -> fetchReviews(id));
scope.join();
return new Response(product.get(), stock.get(), reviews.get());
}
try-with-resources 保证作用域一定会关闭。scope.join() 等所有子任务完成。如果当前线程被中断(比如 HTTP 请求超时),作用域会向每个还没结束的子任务发出中断信号。
和前面那段 CompletableFuture 比,有三个实质变化:
异常会传播。 product.get() 如果子任务抛了异常,会直接抛出来。你不需要 exceptionally,普通的 try-catch 就能接住。而作用域关闭时,还没结束的子任务会被中断——不用你手动取消。
取消会传播。 调用方放弃这个请求(线程被中断),作用域里的每个子任务都会收到中断。没有「调用方都走了,子任务还在跑」的情况。
结果是局部变量。 product、stock、reviews 是 try 块里的局部变量,scope.join() 之后它们的值已经确定。编译器能帮你检查:如果你在 join() 之前就取结果,会编译报错。
两种策略:等全部 vs 取最先
StructuredTaskScope 提供两种内置策略,对应两种最常见的并发模式。
Join:等所有子任务完成。 上面那个例子就是。任何一个子任务抛异常,join() 会把它重新抛出来,同时作用域关闭、中断其余子任务。语义是「全部成功才算成功」。
Shutdown:取最先完成的那个。 典型的竞速场景——多个数据源查同一份数据,谁先回来用谁的:
try (var scope = new StructuredTaskScope.Shutdown()) {
Subtask<User> primary = scope.fork(() -> fetchFromPrimary(userId));
Subtask<User> secondary = scope.fork(() -> fetchFromReplica(userId));
Subtask<User> cache = scope.fork(() -> fetchFromCache(userId));
scope.join();
return Stream.of(primary, secondary, cache)
.filter(Subtask::isCompletedSuccessfully)
.findFirst()
.map(Subtask::get)
.orElseThrow();
}
Shutdown 的语义是:第一个子任务完成时,作用域立即关闭,其余还在跑的被中断。你不需要自己写「谁先到用谁」的逻辑——作用域替你做了。
这两种策略覆盖了绝大多数并发聚合场景。如果你需要更复杂的逻辑(比如「至少 N 个成功」),可以继承 StructuredTaskScope 自己写策略,但大多数时候不需要。
它和虚拟线程是两件事
上一篇《WebFlux 还在用,但选型的理由变了》里拆过虚拟线程:它解决的是「一个任务阻塞时不浪费线程」的问题。结构化并发解决的是「多个并发任务的生命周期管理」的问题。两者正交互补:
- 虚拟线程让每个子任务在阻塞时不占载体线程,效率高。
- 结构化并发让这组子任务的生命周期可预测,出错时能收拾干净。
在 Spring Boot 4 里,虚拟线程已经是默认配置。结构化并发还在预览,需要 --enable-preview。但两者可以同时用:作用域的 fork 默认在虚拟线程上执行子任务(如果当前线程是虚拟线程的话)。
换句话说,虚拟线程是「单个任务的执行模型」,结构化并发是「多个任务的组织模型」。一个管效率,一个管正确性。
什么时候该用,什么时候不该用
结构化并发不是万能的。有四条判据可以帮你决定:
该用:
-
多个独立查询,结果需要聚合。 就像开头的例子——三个下游互不依赖,但响应里缺一不可。这是结构化并发最直接的适用场景。
-
多个校验/检查,任何一个失败就该停。 比如提交订单前同时检查库存、信用、风控,任何一个不过就不该继续。
Join策略天然支持这个语义。 -
多个数据源竞速,取最快的。
Shutdown策略就是为这个设计的。
不该用:
- 任务之间有依赖(流水线)。 如果第二个查询要用第一个的结果,那是链式调用,不是并发。用
thenCompose或者直接顺序写(虚拟线程下顺序写的性能和并发差不多)。
还有一条隐含的判据:如果你的项目跑在 WebFlux + Reactor 上,结构化并发用处不大。 Reactor 的 Mono.zip() 和 Flux.merge() 已经在做同样的事——把多个异步操作的生命周期绑在一起、处理异常传播和取消。我的这个 CMS 就是这种情况:全链路 Reactor,结构化并发没有用武之地。它的主战场是 Spring MVC + 虚拟线程那一类项目。
生产采用:预览状态的应对
结构化并发在 Java 25 LTS 上仍然是预览(JEP 499,第四预览)。这意味着:
- 需要
--enable-preview启动参数。 - API 可能在后续版本微调(虽然从 JEP 453 到 499,核心 API 已经相当稳定)。
- Oracle 计划在 Java 27 正式定稿。
如果你在生产环境要用,建议:
-
限定范围。 只在工具类或 service 层内部用,不要把
StructuredTaskScope暴露到 API 边界。万一 API 变了,改动范围局限在几个类里。 -
封装成方法。 把「并发查三个下游」封装成一个
fetchAggregate(id)方法,调用方只看到结果,不关心内部是结构化并发还是CompletableFuture。这样将来迁移成本最低。 -
监控预览状态。 每个 Java 版本发布时检查 JEP 的变更日志。如果 API 有破坏性改动,提前评估影响面。
如果你的项目还在 Java 21 LTS,结构化并发暂时用不了(Java 21 是第二预览,API 差异较大)。建议等 Java 25 LTS 普及后再考虑——到那时 API 已经经历了四轮迭代,稳定性更高。
它改变的不是性能,是可推理性
结构化并发不会让你的代码跑得更快。它改变的是你写并发代码时的思考方式。
用 CompletableFuture 的时候,你要时刻记住:这三个 future 是独立的,一个失败了另外两个怎么办?调用方取消了怎么通知它们?结果散落在三个 lambda 里怎么凑到一起?
用结构化并发的时候,这些问题消失了。不是因为它们被「解决」了,而是因为它们不再存在——父子关系是显式的,生命周期是作用域管的,结果是局部变量。你不需要「记住」任何约定,因为结构本身就替你做了决定。
这就像 try-with-resources 和手动 finally { close() } 的区别。两者都能关资源,但前者让你不可能忘记关。结构化并发给你的,是并发任务的「结构化生命周期」——你写出来的代码,结构上就不可能留下失联的任务。
本文作者:Aryee 发布时间:2026-10-04 07:11
本文出处:ARYEE.cn 固定链接:https://www.aryee.cn/archives/structured-concurrency-lost-tasks.html
转载或引用请连这一行一起带走;文中的代码与结论按当时环境成立,组件升级后请以官方文档为准。