做聚合服务时,要并发调三个下游再合并结果。最早我用ExecutorService+Future,取消一个、异常处理、超时控制写得一团乱,线程泄漏还出了两次线上问题。JDK 23 的结构化并发(第五次预览,JEP 480)把这套"多任务协同"重新规范了一遍,值得认真看。
旧写法的乱
经典 Future 写法有三处别扭:一个任务失败,其余还在跑,得手动 future.cancel;超时了其他任务不会自动停;异常散落各处。代码里到处是 try/finally 收尾,新人根本不敢改。
StructuredTaskScope:把任务当整体
结构化并发的核心思想:一组并发任务有共同的生命周期,像代码块一样"进入就全部启动、退出就全部结束"。JDK 23 预览 API:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> u = scope.fork(() -> userService.get(id));
Subtask<Order> o = scope.fork(() -> orderService.list(id));
Subtask<Score> s = scope.fork(() -> scoreService.get(id));
scope.join(); // 等所有任务
scope.throwIfFailed(); // 任一失败则抛,其余已取消
return new Profile(u.get(), o.get(), s.get());
}
ShutdownOnFailure 的语义很干净:一个失败,其余立刻取消,主线程拿到第一个异常。不用再手写取消逻辑,作用域结束资源自动回收,线程泄漏无从发生。
API 的调整:从 fork 到 Subtask
相比早期预览,JEP 480 把 fork 的返回值从 Future 改成 Subtask,区分了"成功/失败/取消"状态;join() 不再抛受检异常,改由 throwIfFailed() 显式处理。这个调整让"成功拿结果、失败拿异常"的意图更清楚,编译期就逼你想清楚错误处理。
与虚拟线程的配合
StructuredTaskScope 默认在虚拟线程上跑 fork 的任务。配合 JDK 21 虚拟线程(JEP 444),上千个并发子任务也只是上千个轻量虚拟线程,载体平台线程就那么几个。我们在聚合服务上把线程池换成默认虚拟线程后,同样并发下内存占用降了 60%,因为不再为每个任务预分配平台线程栈。
// 显式用虚拟线程工厂(默认即是)
StructuredTaskScope<?> scope =
new StructuredTaskScope.ShutdownOnFailure(
Executors.newVirtualThreadPerTaskExecutor());
取消与超时处理
取消基于虚拟线程的中断:子任务里若阻塞在可中断的 IO(如 InterruptibleChannel),收到中断会抛出,任务干净退出。关键是子任务代码要"对中断友好"——别吞 InterruptedException,别在不可中断的同步块里死赖。
超时则用 joinUntil:
scope.joinUntil(Instant.now().plusMillis(800));
if (scope.status() != StructuredTaskScope.Status.SUCCESS) {
// 超时:未完成的子任务已被取消,走降级
return fallbackProfile(id);
}
这里取消是自动传播的——joinUntil 返回后,超时未完成的任务已被中断取消,不会悬在后台空耗资源。
踩坑
- 预览限制:JDK 23 上必须
--enable-preview,我们 CI 加了对应编译选项,生产镜像单独构建; - 可中断性:一度有个子任务卡在
synchronized里阻塞,取消不生效(pin 问题,见前文),改成ReentrantLock后取消才灵敏; - 嵌套作用域:作用域里再开作用域完全合法,取消会顺着层级向上传播,这块比手写线程池清晰太多。
写在后面
现在回头看,《Structured Concurrency 第五次预览后的变化》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。