JEP 462:结构化并发(第二预览版)
概括
_通过引入结构化_并发 API 来简化并发编程。结构化并发将在不同线程中运行的相关任务组视为单个工作单元,从而简化错误处理和取消、提高可靠性并增强可观察性。这是一个预览 API。
历史
结构化并发由JEP 428提出,并在JDK 19中作为孵化 API提供。它由JEP 437在JDK 20中重新孵化,并进行了较小的更新以继承范围值(JEP 429)。它首先通过JEP 453在JDK 21中预览,并更改为返回 a而不是.我们在此建议在 JDK 22 中重新预览 API,不做任何更改,以获得更多反馈。StructuredTaskScope
::
fork(...)
Subtask
Future
目标
-
推广一种并发编程风格,可以消除因取消和关闭而产生的常见风险,例如线程泄漏和取消延迟。
-
提高并发代码的可观察性。
非目标
-
java.util.concurrent
替换包中的任何并发结构(例如ExecutorService
和 )并不是目标Future
。 -
为 Java 平台定义明确的结构化并发 API 并不是我们的目标。其他结构化并发构造可以由第三方库或在未来的 JDK 版本中定义。
-
定义在线程(即通道)之间共享数据流的方法并不是目标。我们可能会建议将来这样做。
-
用新的线程取消机制取代现有的线程中断机制并不是目标。我们可能会建议将来这样做。
动机
开发人员通过将任务分解为多个子任务来管理复杂性。在普通的单线程代码中,子任务顺序执行。然而,如果子任务彼此充分独立,并且如果有足够的硬件资源,则可以通过同时执行子任务来使整体任务运行得更快(即,具有更低的延迟)。例如,如果每个 I/O 操作在自己的线程中并发执行,则由多个 I/O 操作组成的任务将运行得更快。虚拟线程 ( JEP 444 ) 使为每个此类 I/O 操作专用一个线程变得经济高效,但管理可能产生的大量线程仍然是一个挑战。
非结构化并发ExecutorService
java.util.concurrent.ExecutorService
Java 5 中引入的 API 可以帮助开发人员同时执行子任务。
例如,这里有一个方法handle()
,它代表服务器应用程序中的任务。它通过将两个子任务提交到ExecutorService
.一个子任务执行该方法findUser()
,另一个子任务执行该方法fetchOrder()
。ExecutorService
立即返回每个Future
子任务,并根据 的调度策略并发执行子任务Executor
。该handle()
方法通过阻止调用子任务的 futureget()
方法来等待子任务的结果,因此该任务被称为_加入_其子任务。
Response handle() throws ExecutionException, InterruptedException {
Future<String> user = esvc.submit(() -> findUser());
Future<Integer> order = esvc.submit(() -> fetchOrder());
String theUser = user.get(); // Join findUser
int theOrder = order.get(); // Join fetchOrder
return new Response(theUser, theOrder);
}
由于子任务是并发执行的,因此每个子任务可以独立成功或失败。 (在此上下文中,失败意味着抛出异常。)通常,handle()
如果某个任务的任何子任务失败,则此类任务应该失败。当发生故障时,了解线程的生命周期可能会非常复杂:
-
如果
findUser()
抛出异常,那么handle()
在调用时会抛出异常user.get()
,但fetchOrder()
会继续在自己的线程中运行。这是一个_线程泄漏_,充其量只是浪费资源;最坏的情况是,该fetchOrder()
线程会干扰其他任务。 -
如果线程执行
handle()
被中断,中断不会传播到子任务。findUser()
和线程都会泄漏,即使失败fetchOrder()
后也会继续运行。handle()
-
如果
findUser()
执行时间较长,但fetchOrder()
同时失败,则将通过阻塞而不是取消来handle()
不必要地等待。只有完成并返回后才会抛出异常,导致失败。findUser()``user.get()``findUser()``user.get()``order.get()``handle()
在每种情况下,问题在于我们的程序在逻辑上是由任务子任务关系构成的,但这些关系只存在于开发人员的脑海中。
这不仅会产生更多的错误空间,而且会使诊断和排除此类错误变得更加困难。例如,线程转储等可观察性工具将在不相关线程的调用堆栈上显示handle()
、findUser()
、 和fetchOrder()
,而不会提示任务-子任务关系。
我们可能会尝试通过在发生错误时显式取消其他子任务来做得更好,例如,通过在失败任务的 catch 块中包装任务try-finally
并调用cancel(boolean)
其他任务的 future 方法。我们还需要使用ExecutorService
内部的try
-with-resources 语句,如JEP 425中的示例所示,因为Future
它没有提供等待已取消任务的方法。但所有这些 都很难正确执行,并且常常使代码的逻辑意图更难以辨别。跟踪任务间关系,并手动添加回所需的任务间取消边,是许多开发人员面临的问题。
需要手动协调生命周期是因为允许ExecutorService
不受限制Future
的并发模式。对所涉及的任何线程都没有限制或顺序。一个线程可以创建ExecutorService
,第二个线程可以向其提交工作,并且执行该工作的线程与第一个或第二个线程没有任何关系。此外,在一个线程提交工作后,另一个完全不同的线程可以等待执行结果。任何引用 a 的代码Future
都可以加入它(即,通过调用 等待其结果get()
),甚至是获取 .a 的线程以外的线程中的代码Future
。实际上,由一个任务启动的子任务不必返回到提交它的任务。它可以返回许多任务中的任何一个,甚至不返回任何任务。
因为ExecutorService
并Future
允许这种非结构化使用,所以它们不强制甚至不跟踪任务和子任务之间的关系,即使这样的关系是常见且有用的。因此,即使在同一个任务中提交并加入子任务,一个子任务的失败也不会自动导致另一个子任务的取消:在上述handle()
方法中, 的失败fetchOrder()
不会自动导致 的取消findUser()
。 for 的未来fetchOrder()
与 for 的未来无关findUser()
,也与最终通过其方法加入它的线程无关get()
。我们不想要求开发人员手动管理此类取消,而是希望可靠地实现自动化。