JDK 25 结构化并发实战:一个任务失败,其他任务还在跑,线程就这样被吃光了

2026-09-30 0 850

上个月出了个挺典型的生产事故。我们的商品详情页有一个聚合接口,一个请求要同时去调三个下游服务:库存、价格、评价。三个都要拿到,页面才能渲染完整。任何一个挂了,整个页面报错——用户看到的是错误页,不是半成品。

这个接口用 CompletableFuture.allOf() 写的,大概长这样:

CompletableFuture<Stock> stockF = CompletableFuture.supplyAsync(
    () -> stockClient.get(skuId), pool);
CompletableFuture<Price> priceF = CompletableFuture.supplyAsync(
    () -> priceClient.get(skuId), pool);
CompletableFuture<Review> reviewF = CompletableFuture.supplyAsync(
    () -> reviewClient.get(skuId), pool);

CompletableFuture.allOf(stockF, priceF, reviewF)
    .orTimeout(800, TimeUnit.MILLISECONDS)
    .join();

return new Detail(stockF.join(), priceF.join(), reviewF.join());

看起来很合理,对吧?三个任务并行,超时统一 800ms,谁慢谁拖后腿。上线的时候压测过,P99 在 400ms 左右,没问题。

出事那天是评价服务那边抽风。运维同事半夜打电话过来说商品详情页大面积 500。我第一反应是去看监控,结果发现评价服务的响应时间确实炸了,但订单服务和价格服务也是异常的——它们的 QPS 涨了三倍多,延迟也跟着上去,但订单和价格的业务量根本没变化。

一句话概括:评价服务挂了,把库存和价格也一起拖垮了。

问题出在哪里

分析这个现象其实不复杂。

CompletableFuture.allOf() 的行为是”等所有任务结束”,注意是”所有”,不是”任何一个失败就结束”。如果评价服务卡在那里跑十秒,另外两个任务就算 50ms 就完成了,也不会去通知 allOf 提前结束。orTimeout 确实会在这个超时时间到达时抛异常给调用方,但它不会去取消那三个正在跑的 supplyAsync 任务。

也就是说:

  • 调用方的线程在 800ms 之后就已经超时返回了,用户看到错误页
  • 但 pool 里的三个任务还在跑,占用着三个线程
  • 评价服务慢,占用三秒;价格服务正常,早就在 50ms 内完成了
  • 但价格服务的任务占的线程释放了,紧接着下一个来了同样的请求,又发起三个任务
  • 评价服务还没恢复,任务一直积压,线程池逐渐被评价任务占满
  • 库存和价格的调用也需要线程,但线程池没位置了,只能排队
  • 结果就是库存和价格也跟着变慢、超时

整个过程一个下游挂掉,三个下游一起崩。这就是典型的任务泄漏导致的级联故障。

要修这个,传统思路是手动处理:给每个 Future 显式注册 whenComplete,发现有任务超时就把另外两个 cancel(true),还要保证 cancel 之后它们真的会中断而不是继续跑完。supplyAsync 返回的 CompletableFuture 在接到 cancel 时不一定能中断底层的任务——这点儿细节就够写一段代码处理了。

而 JDK 25 里的结构化并发(StructuredTaskScope)就是专门来收拾这类问题的。

结构化并发是什么

名字听起来有点学术,但实质很好理解:一个作用域里创建的子任务,生命周期不能超过这个作用域。

以前的并发写法是”发射后不管”——你丢一个任务出去,它爱跑多久跑多久,父任务完全不管它。结构化并发反过来:父任务提出一组子任务,等父任务决定结束时,所有子任务也必须结束。没结束的会被打断。

这个性质用一个词概括就是”作用域绑定”。Java 里对应的 API 是 StructuredTaskScope,JDK 25 里是第五次预览。它的基本形状是:

try (var scope = StructuredTaskScope.open(...)) {
    Subtask<A> ta = scope.fork(() -> taskA());
    Subtask<B> tb = scope.fork(() -> taskB());
    scope.join();
    // 到这里,两个任务都结束了(要么都成功,要么处理异常)
    return combine(ta.get(), tb.get());
}
// 离开 try 块时,作用域关闭,所有子任务保证已经结束

几个关键词:

open(...) 里传入的是”策略”,也就是”什么时候算结束”。常用两种:

  • Joiner.awaitAllSuccessfulOrThrow():所有任务都成功才继续,任何一个失败就取消其他所有任务并抛异常。
  • Joiner.anySuccessfulResultOrThrow():任何一个成功就返回,其他任务全部取消。适合”多副本查询,取最快的一个”场景。

fork 派生一个新任务,返回一个 Subtask。注意是 Subtask 不是 Future——它不是用来”发射后不管”的,只能在作用域里使用。

join() 等待策略判定结果。这个方法会在策略满足时返回。

整个机制的核心价值:当 join() 抛异常或者结束的时候,作用域里所有还没完成的任务都会被自动取消。不用写 cancel,不用写 whenComplete,不用怕漏掉某一个。

把前面那段代码用结构化并发重写一下:

try (var scope = StructuredTaskScope.open(
        Joiner.<Object>awaitAllSuccessfulOrThrow())) {
    Subtask<Stock> stockT = scope.fork(() -> stockClient.get(skuId));
    Subtask<Price> priceT = scope.fork(() -> priceClient.get(skuId));
    Subtask<Review> reviewT = scope.fork(() -> reviewClient.get(skuId));

    scope.join();

    return new Detail(stockT.get(), priceT.get(), reviewT.get());
}

如果评价服务超时,它会一直等下去吗?不会。这里需要一个超时。结构化并发提供了 joinUntil(Instant):

try (var scope = StructuredTaskScope.open(
        Joiner.<Object>awaitAllSuccessfulOrThrow())) {
    Subtask<Stock> stockT = scope.fork(() -> stockClient.get(skuId));
    Subtask<Price> priceT = scope.fork(() -> priceClient.get(skuId));
    Subtask<Review> reviewT = scope.fork(() -> reviewClient.get(skuId));

    scope.joinUntil(Instant.now().plusMillis(800));

    return new Detail(stockT.get(), priceT.get(), reviewT.get());
}

joinUntil 到时间点还没结束,就会抛 TimeoutException,然后离开 try 块关闭作用域。关闭作用域的那一瞬间,三个子任务会被全部取消——包括那两个早就完成的、和那个还在跑的评价任务。

这才是真正的”一个超时,三个都收工”。之前占用线程池的问题就这么没了。

更深一层:取消是怎么传播下去的

上面这段代码看着简单,但背后做的事其实不少。我拆解一下,因为理解了这个,才能理解为什么”结构化并发 + 虚拟线程“是真正的黄金搭档。

当 joinUntil 抛出 TimeoutException 时,作用域进入”shutting down”状态。这时候它会遍历所有还活着的子任务,调用它们的 cancel 方法。

对于跑在虚拟线程上的任务,cancel 会往那个虚拟线程发一个中断信号。这是关键——子任务必须是”响应中断”的写法才能被真正取消。

什么样的代码不响应中断?典型的是:

  • 用了 catch (InterruptedException e) {} 然后什么都不做就吞掉了
  • 用了 Lock.lock() 而不是 lockInterruptibly()
  • 正在做大量 CPU 计算,且没有定期检查中断标志
  • 在一个 synchronized 块里卡着

我们项目里就中过第一个。调下游有个 SDK 是早期封装的,把 InterruptedException 吞掉了:

try {
    return httpClient.send(request, bodyHandler);
} catch (InterruptedException e) {
    throw new RuntimeException("interrupted", e);   // 中断标志被清了
}

重新包装成 RuntimeException 本身没问题,但它丢掉了中断标志。上层如果想靠 Thread.currentThread().isInterrupted() 判断是否被取消,就会拿到 false,继续跑下去。

正确的写法要恢复中断状态:

try {
    return httpClient.send(request, bodyHandler);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new RuntimeException("interrupted", e);
}

一行代码,效果差很多。结构化并发把”取消”这个能力送到你手上,但前提是你的底层代码要配合。不然作用域里的 cancel 发出去也没人听。

第二个场景:任一成功就返回

结构化并发还有一个很实用的模式:多副本查询,谁先返回用谁的结果。以前这套要用 CompletableFuture.anyOf() 加一堆手动取消,现在一行策略搞定。

try (var scope = StructuredTaskScope.open(
        Joiner.<Config>anySuccessfulResultOrThrow())) {
    scope.fork(() -> configFromLocal());
    scope.fork(() -> configFromRemote());
    scope.fork(() -> configFromCache());

    return scope.join();   // 直接返回最早成功的那个结果
}

注意这里 join() 返回的直接是结果,不是 Subtask。因为策略决定了只有一个结果要返回,anySuccessfulResultOrThrow 会帮你把那个结果的 Subtask.get() 调好返回。剩下还在跑的两个任务会被自动取消。

这种模式在配置读取、多数据中心查询、容灾降级这些场景特别顺手。以前写这套代码至少三十行,还要保证取消逻辑不漏。

踩过的几个坑

这套 API 我用了几个月,总结几个绕不开的问题。

第一个坑:作用域里不能访问外部的结构化变量

作用域里的子任务是并发跑的,访问外部可变状态会有一致性问题。结构化并发本身不阻止你,但在 review 的时候很容易漏掉。我自己的习惯是尽量把每个 fork 的 lambda 写成纯函数——输入是什么就返回什么,不依赖外部对象的状态。

第二个坑:异常类型会被包装

join() 在失败时抛的是 ExecutionException,里面包着真正的原因。这一点和 Future.get() 一样,不熟悉的话容易在日志里看到一层没用的包装。

try {
    scope.join();
} catch (ExecutionException e) {
    Throwable cause = e.getCause();   // 这里才是真正的异常
    log.error("task failed", cause);
    throw new BizException("聚合失败", cause);
}

好处是 API 层可以直接定义统一的异常处理,不用在每个 Subtask.get() 上做 try-catch。

第三个坑:StructuredTaskScope 本身不限制并发度

如果在一个循环里 fork 一百个任务,它会老老实实造一百个。结构化并发管的是”作用域内的任务要一起结束”,不是”同时最多跑几个”。要限流还得用 Semaphore 或者 newFixedThreadPool 那套。

我们那个聚合接口只有三个下游,不涉及这个问题。但另一个批量出口的服务里,一个作用域里 fork 了两百个导出任务,直接打满下游。后来加了 Semaphore(10) 控住。

第四个坑:预览 API 的写法

StructuredTaskScope 目前还是预览状态,用的时候需要开 --enable-preview。而且从 JDK 21 到 JDK 25,API 有过几次调整,网上大部分教程都是基于旧版本的 ShutdownOnFailure 写法,直接抄过来会编译不过。

现在(JDK 25)的正确写法是 StructuredTaskScope.open(Joiner.xxx()),返回的是 Subtask 而不是 Future。写代码之前建议先看一遍你所用 JDK 版本对应的 JEP,别抄别人 2023 年的示例。

结构化并发和虚拟线程的关系

经常有人问:既然虚拟线程已经让线程变得便宜了,为什么还需要结构化并发?不就是多开几个线程嘛。

这两个解决的是不同层面的问题。

虚拟线程解决的是”创建线程的物理成本”。原来一个线程两兆栈,创建销毁都要花钱,所以得池化、得小心管理。现在一个虚拟线程很小,想创建多少创建多少。

结构化并发解决的是”任务之间的逻辑关系”。不管线程多便宜,你都需要回答”父任务结束时子任务怎么办”这个问题。以前靠手工写的 cancel 逻辑和超时逻辑,本质就是在补这个关系,但补得不完整、容易漏、容易错。

两者合起来才是完整的。结构化并发把虚拟线程的成本优势释放出来,虚拟线程让结构化并发可以”开五个任务看看谁快”而不心疼——你算算传统线程池要同时开五个并行调用得占多少资源,就知道为什么以前没人这么写。

用我自己的感受来说:虚拟线程让”每次请求都开几个线程”变得可接受,结构化并发让”每次请求都开几个线程”变得安全。前者是成本上的解放,后者是正确性上的保障。

什么场景不适合用

结构化并发很好用,但它不是所有并发场景的答案。我自己总结了几个不该用的情形。

任务之间没有”父子”关系的时候。 比如一个后台任务 Scheduler 每隔一分钟起来扫一次数据,它不是由某个请求派生的,也不应该随着某个请求的结束而结束。这种应该继续用传统的线程池或者定时任务框架。

任务的执行会超过作用域生命周期的时候。 结构化并发的核心约束是”作用域关闭,任务一起结束”。如果一个子任务是要”发出一个事件、后续异步处理”,那它本质上不适合放在作用域里。这种情况应该改为”在一个独立的作用域里跑一次快速的发送操作”,而不是”作用域里跑一个长任务”。

已经在用响应式框架的时候。 Reactor、RxJava 这些框架自己有作用域和取消机制(比如 Flux.zipWith、Mono.zipDelayError),而且它们的取消模型是数据流而不是线程。混用两套机制会让取消传播变得混乱。要么全程响应式,要么全程结构化并发。

只有一两个下游调用的时候。 如果只是并发调两个服务,用 CompletableFuture.thenCombine 就够了,不需要为了”新特性”而引入结构化并发。一个原则是:如果取消和异常传播不是关键需求,可以不引入。

改完了之后

回到开头那个事故。同样用结构化并发改写完之后,我特意做了一次模拟:让评价服务故意卡住 5 秒,然后压测 500 并发。

改造前:库存和价格的 QPS 会短暂飙到 3 倍,线程池在 30 秒内被占满,整个服务进入雪崩。

改造后:评价服务卡住的瞬间,请求在 800ms 超时,三个子任务同时被取消。库存和价格的 QPS 只上升了不到 10%,波动两秒就恢复正常。整个服务稳如老狗。

更直观的对比指标:线程池里的活跃线程数。改造前在异常情况下会一路爬到 200,改造后始终稳定在个位数。原因很简单——任务被及时取消了,线程不再被白占。

最后说两句

结构化并发是我最近几年看到的最”顺理成章”的 Java 新特性。它没有发明什么新概念,只是把我们本来就该做对的事情——”父任务管好子任务的生命周期”——变成了语言层面能保证的东西。

以前很多团队是靠规范保证这件事的:review checklist 上写一条”任何 Future 都要有对应的 cancel 逻辑”。但规范是软约束,漏了就漏了,生产事故才告诉你漏了。

现在编译器帮你把这个约束下沉到 API 层面。写不出来泄漏的代码,不是因为大家更小心了,而是因为语言的形状让泄漏变成了不可能。

如果你的项目里有那种”一个请求聚合并发调多个下游”的接口,而且是最近几年写的,多半可以翻出来看看取消逻辑有没有漏洞。有漏洞的话,结构化并发是一个很值得投入的重构方向。哪怕 API 还是预览状态,收益也远大于那点迁移成本。

毕竟线上出一次雪崩的代价,比升级一遍 JDK 大太多了。

JDK 25 结构化并发实战:一个任务失败,其他任务还在跑,线程就这样被吃光了
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java JDK 25 结构化并发实战:一个任务失败,其他任务还在跑,线程就这样被吃光了 https://www.taomawang.com/server/java/2840.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务