JEP 453:结构化并发(预览版)
概括
_通过引入结构化_并发 API 来简化并发编程。结构化并发将在不同线程中运行的相关任务组视为单个工作单元,从而简化错误处理和取消、提高可靠性并增强可观察性。这是一个预览 API。
历史
结构化并发由JEP 428提出,并在JDK 19中作为孵化 API提供。它由JEP 437在JDK 20中重新孵化,并进行了较小的更新以继承范围值(JEP 429)。
我们在这里建议将结构化并发作为java.util.concurrent
包中的预览 API。唯一显着的变化是该方法返回 a而不是 a ,如下所述。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()
。我们不想要求开发人员手动管理此类取消,而是希望可靠地实现自动化。
任务结构应反映代码结构
与 下的随心所欲的线程分类相反ExecutorService
,单线程代码的执行始终强制执行任务和子任务的层次结构。{...}
方法的主体块对应于任务,块内调用的方法对应于子任务。被调用的方法必须返回调用它的方法,或者向调用它的方法抛出异常。它不能比调用它的方法寿命更长,也不能向其他方法返回或抛出异常。因此,所有子任务都在该任务之前完成,每个子任务都是其父任务的子任务,并且每个子任务相对于其他子任务和任务的生命周期由代码的语法块结构控制。
例如,在这个单线程版本中,handle()
任务-子任务关系从语法结构中显而易见:
Response handle() throws IOException {
String theUser = findUser();
int theOrder = fetchOrder();
return new Response(theUser, theOrder);
}
在子任务完成fetchOrder()
之前,无论成功还是不成功,我们都不会启动子任务。findUser()
如果findUser()
失败,那么我们fetchOrder()
根本不会开始,并且handle()
任务隐式失败。子任务只能返回其父任务这一事实很重要:这意味着父任务可以隐式地将一个子任务的失败视为取消其他未完成的子任务的触发器,然后自行失败。
在单线程代码中,任务-子任务层次结构在运行时在调用堆栈中具体化。因此,我们免费获得了相应的控制错误传播的父子关系。当观察单个线程时,层次关系很明显:(findUser()
以及后来的fetchOrder()
)出现从属于handle()
。这使得回答“handle()
现在正在做什么?”这个问题变得很容易。
如果任务及其子任务之间的父子关系从代码的语法结构中显而易见并且在运行时具体化(就像单线程代码一样),那么并发编程将会更容易、更可靠、更容易观察。语法结构将描述子任务 的生命周期,并启用线程间层次结构的运行时表示,类似于线程内调用堆栈。该表示将实现错误传播和取消以及对并发程序的有意义的观察。
(Java 平台已经有一个 API 用于对并发任务施加结构,即java.util.concurrent.ForkJoinPool
,它是并行流背后的执行引擎。但是,该 API 是为计算密集型任务而不是涉及 I/O 的任务而设计的。)
结构化并发
_结构化并发_是一种并发编程方法,它保留了任务和子任务之间的自然关系,从而使并发代码更具可读性、可维护性和可靠性。 “结构化并发”一词由Martin Sústrik创造,并由Nathaniel J. Smith推广。来自其他语言的思想,例如 Erlang 的分层管理器,为结构化并发中的错误处理设计提供了信息。
结构化并发源自以下简单原则:
如果一个任务分成并发的子任务,那么它们都返回到同一个地方,即任务的代码块。
在结构化并发中,子任务代表任务工作。该任务等待子任务的结果并监视它们是否失败。与单线程中代码的结构化编程技术一样,多线程结构化并发的强大功能来自两个想法:(1) 代码块执行流的明确定义的入口点和出口点,以及 (2)操作生命周期的严格嵌套,其方式反映了它们在代码中的语法嵌套。
由于代码块的入口点和出口点已明确定义,因此并发子任务的生命周期仅限于其父任务的语法块 。由于同级子任务的生命周期嵌套在其父任务的生命周期内,因此可以将它们作为一个单元进行推理和管理。因为父任务的生命周期又嵌套在其父任务的生命周期内,所以运行时可以将任务的层次结构具体化为树,该树是单个线程的调用堆栈的并发对应项。这允许代码将策略(例如截止日期)应用于整个任务子树,并允许可观察性工具将子任务呈现为从属于其父任务。
结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享相同的操作系统线程,从而允许大量虚拟线程。除了数量充足之外,虚拟线程还足够便宜,可以表示任何并发的行为单元,甚至涉及 I/O 的行为。这意味着服务器应用程序可以使用结构化并发来同时处理数千或数百万个传入请求:它可以专用一个新的虚拟线程来处理每个请求的任务,并且当任务通过提交子任务进行并发执行而扇出时,它可以可以为每个子任务分配一个新的虚拟线程。在幕后,通过安排每个虚拟线程携带对其唯一父级的引用,将任务-子任务关系具体化为树,类似于调用堆栈中的帧引用其唯一调用者的方式。
总之,虚拟线程提供了大量的线程。结构化并发可以正确、稳健地协调它们,并使可观察性工具能够按照开发人员理解的方式显示线程。在 JDK 中拥有用于结构化并发的 API 将使构建可维护、可靠和可观察的服务器应用程序变得更加容易。
描述
结构化并发API的主要类StructuredTaskScope
在包中java.util.concurrent
。此类允许开发人员将任务构建为一系列并发子任务,并将它们作为一个单元进行协调。子任务在它们自己的线程中执行,方法是单独_分叉_它们,然后将它们_连接_为一个单元,并且可能将它们作为一个单元取消。子任务的成功结果或异常由父任务聚合和处理。StructuredTaskScope
将子任务的生命周期限制在一个明确的词法范围内,任务与其子任务的所有交互(分叉、加入、取消、处理错误和组合结果)都在该范围内发生。
这是handle()
之前的示例,编写用于使用StructuredTaskScope
(下面ShutdownOnFailure
解释):
Response handle() throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<String> user = scope.fork(() -> findUser());
Supplier<Integer> order = scope.fork(() -> fetchOrder());
scope.join() // Join both subtasks
.throwIfFailed(); // ... and propagate errors
// Here, both subtasks have succeeded, so compose their results
return new Response(user.get(), order.get());
}
}
try
与原始示例相反,理解这里涉及的线程的生命周期很容易:在所有条件下,它们的生命周期都限制在词法范围内,即-with-resources 语句的主体。此外,使用StructuredTaskScope
确保了许多有价值的特性:
-
短路错误处理— 如果一个
findUser()
或fetchOrder()
一个子任务失败,则另一个尚未完成的任务将被取消。 (这是由 实施的关闭策略管理的ShutdownOnFailure
;其他策略也是可能的)。 -
取消传播— 如果线程运行
handle()
在调用之前或期间被中断join()
,则当线程退出作用域时,两个子任务都会自动取消。 -
清晰性——上面的代码有一个清晰的结构:设置子任务,等待它们完成或被取消,然后决定是成功(并处理已经完成的子任务的结果)还是失败(和子任务已经完成,所以没有什么需要清理的)。
-
可观察性——线程转储,如下所述,清楚地显示任务层次结构,其中线程正在运行
findUser()
并fetchOrder()
显示为范围的子级。
StructuredTaskScope 是一个预览 API,默认禁用
要使用该StructuredTaskScope
API,您必须启用预览 API,如下所示: