并发调用最难收拾的不是慢,而是有任务失联

2026-10-04 Aryee 8

一个接口要调三个下游:商品详情、库存、评价。三个调用互不依赖,并发发出去,等结果凑齐了拼响应。

这个场景太常见了,常见到大多数人不会去想它有什么难处。直到有一天,库存服务超时了,商品详情和评价其实已经拿到——但那个超时的调用还挂在那,占着线程、占着连接,没有人去收它。更麻烦的是,如果你决定「不等了,直接返回错误」,另外两个已经跑完的任务的资源也不会自动释放。

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 默认在虚拟线程上执行子任务(如果当前线程是虚拟线程的话)。

换句话说,虚拟线程是「单个任务的执行模型」,结构化并发是「多个任务的组织模型」。一个管效率,一个管正确性。

什么时候该用,什么时候不该用

结构化并发不是万能的。有四条判据可以帮你决定:

该用:

  1. 多个独立查询,结果需要聚合。 就像开头的例子——三个下游互不依赖,但响应里缺一不可。这是结构化并发最直接的适用场景。

  2. 多个校验/检查,任何一个失败就该停。 比如提交订单前同时检查库存、信用、风控,任何一个不过就不该继续。Join 策略天然支持这个语义。

  3. 多个数据源竞速,取最快的。 Shutdown 策略就是为这个设计的。

不该用:

  1. 任务之间有依赖(流水线)。 如果第二个查询要用第一个的结果,那是链式调用,不是并发。用 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 正式定稿。

如果你在生产环境要用,建议:

  1. 限定范围。 只在工具类或 service 层内部用,不要把 StructuredTaskScope 暴露到 API 边界。万一 API 变了,改动范围局限在几个类里。

  2. 封装成方法。 把「并发查三个下游」封装成一个 fetchAggregate(id) 方法,调用方只看到结果,不关心内部是结构化并发还是 CompletableFuture。这样将来迁移成本最低。

  3. 监控预览状态。 每个 Java 版本发布时检查 JEP 的变更日志。如果 API 有破坏性改动,提前评估影响面。

如果你的项目还在 Java 21 LTS,结构化并发暂时用不了(Java 21 是第二预览,API 差异较大)。建议等 Java 25 LTS 普及后再考虑——到那时 API 已经经历了四轮迭代,稳定性更高。

它改变的不是性能,是可推理性

结构化并发不会让你的代码跑得更快。它改变的是你写并发代码时的思考方式。

用 CompletableFuture 的时候,你要时刻记住:这三个 future 是独立的,一个失败了另外两个怎么办?调用方取消了怎么通知它们?结果散落在三个 lambda 里怎么凑到一起?

用结构化并发的时候,这些问题消失了。不是因为它们被「解决」了,而是因为它们不再存在——父子关系是显式的,生命周期是作用域管的,结果是局部变量。你不需要「记住」任何约定,因为结构本身就替你做了决定。

这就像 try-with-resources 和手动 finally { close() } 的区别。两者都能关资源,但前者让你不可能忘记关。结构化并发给你的,是并发任务的「结构化生命周期」——你写出来的代码,结构上就不可能留下失联的任务。

你手上那套系统,卡在哪一步?

留个手机号和方便的时间,我打过来先听你说现状与约束,再给可执行的判断:这套系统是该继续修、该动哪里,还是干脆重做一版设计更省。

约一次沟通不接纯 UI 外包,只做系统层面的问题。