Skip to main content

JEP 533:结构化并发(第七次预览)

引言

我们早就习惯了结构化的控制流:iffortry 这些代码块层层嵌套,进入和退出的时机清清楚楚。可一旦进入并发领域,这种清晰感就消失了——任务被抛向线程池,返回一堆彼此无关的 Future,谁也管不住谁。JEP 533 的结构化并发,就是要把控制流那种井然有序的"块结构",带回到并发世界里来。

非结构化并发会「漏」

想想我们平时是怎么写并发的:把任务提交给一个 ExecutorService,拿回一堆 Future。这种模型看似灵活,实则暗藏隐患。

  • **ExecutorService 返回的 Future 与调用方之间,没有任何生命周期关系。**方法返回了,Future 可能还在后台跑着;Future 的存活时间完全脱离了创建它的代码块。
  • **一个子任务失败了,它的兄弟任务还在继续跑,继续烧资源。**没有人告诉它们"其中一个已经失败了,你们可以停了"。
  • 取消要靠人工层层传递。你得手动把取消信号一级一级传下去,而这种手工传递常常会传丢——某个环节忘了处理,取消就静默失效了。
  • **栈轨迹和线程转储里,看不出调用方与子任务的关系。**出了问题去看线程转储,你只看到一堆孤立的线程,根本无从得知谁 fork 了谁、谁在等谁。父子关系彻底丢失了。

一句话概括:非结构化并发会"漏"——任务泄漏、资源泄漏、上下文泄漏。

结构化的核心想法

结构化并发的思路直白得令人安心:让并发任务的生命周期像代码块一样嵌套。

  • 任务在一个作用域(scope)里被拆成子任务,而这个作用域拥有它们。
  • join 之后离开作用域时,任何还没完成的子任务都会被自动收束、带回可控状态,不会有谁偷偷溜出去继续跑。
  • **子任务的生命周期,像代码块一样嵌套在调用它的代码块之内。**父块没结束,子任务就不可能还活着。
  • **这层父子关系对运行时和调试工具都是可见的。**它不只是程序员脑子里的一个心智模型,而是被运行时真正理解、并能反映在诊断工具里的结构。

API 的样子

具体到 API,结构化并发的写法非常符合 Java 的直觉:

  • try-with-resources 块里用 StructuredTaskScope.open(...) 打开一个作用域。
  • scope.fork(...) 在各自的虚拟线程上启动子任务。
  • scope.join() 按策略等待结果。
  • 策略由 Joiner 表达:你可以要求全部成功任一成功,或者定义你自己的规则。
try (var scope = StructuredTaskScope.open(
Joiner.allSuccessfulOrThrow())) {
Subtask<String> user = scope.fork(() -> findUser());
Subtask<Order> order = scope.fork(() -> fetchOrder());
scope.join();
return new Response(user.get(), order.get());
}

这段代码打开一个作用域,fork 出两个子任务——一个查用户、一个取订单——然后 join。这里用的 Joiner.allSuccessfulOrThrow() 策略意味着:**如果任何一个子任务失败,另一个会被自动取消,并抛出异常。**整段并发逻辑被 try 块牢牢框住,任何子任务都不可能泄漏到块外。

前后对比

ExecutorService(传统方式):

  • Future 的存活时间超出创建它的方法。
  • 错误要等到 get() 被调用时才浮现,往往已经晚了。
  • 取消与清理都得手工完成,也就随时可能被遗漏。

StructuredTaskScope(结构化并发):

  • **子任务不可能活得比作用域更久。**它们被 try 块的边界物理地约束着。
  • 失败、成功和短路行为,全由 Joiner 策略决定。
  • 短路与清理由 Joiner 统一负责,不再依赖你手工去写。

核心区别在于:传统模型里子任务逃出去,而结构化并发里它们根本没法逃出去。

预览状态

这已经是第七次预览了——一个相当高的数字,它告诉你这套 API 经历了大量的打磨和反复重塑。

  • 它在 JDK 27 中作为第七次预览发布,API 一路上被反复重新设计。
  • 使用时需要 --enable-preview 标志。
  • 它与 Project Loom 的虚拟线程是配套设计的:虚拟线程让你可以毫无心理负担地 fork 成千上万个子任务,而结构化并发确保这些子任务全都被妥善管理、不会失控。两者相辅相成。

结论

结构化并发让并发任务的生命周期,变得和代码块一样清晰可见。子任务被作用域牢牢框住——它们不会泄漏,也不会被遗忘。这不仅让并发代码更好写、更好读,也让它在出问题时更好调试:线程转储里终于能看清谁是谁的父任务了。