JEP 499:结构化并发(第四个预览版)
概括
_通过引入结构化_并发 API 来简化并发编程。结构化并发将在不同线程中运行的相关任务组视为单个工作单元,从而简化错误处理和取消、提高可靠性并增强可观察性。这是一个预览 API。
历史
结构化并发由JEP 428提出,并在JDK 19中作为孵化 API提供。它在JDK 20中由JEP 437重新孵化,并对继承范围值进行了小幅更新(JEP 429)。它首先通过JEP 453在JDK 21中预览,更改为返回 a而不是 a 。它通过JEP 462在JDK 22中重新预览,并通过JEP 480在JDK 23中重新预览,没有变化。StructuredTaskScope
::
fork(...)
Subtask
Future
我们在此建议在 JDK 24 中再次重新预览 API,不做任何更改,以便有更多时间获取实际使用反馈。
目标
-
推广一种并发编程风格,可以消除因取消和关闭而产生的常见风险,例如线程泄漏和取消延迟。
-
提高并发代码的可观察性。
非目标
-
我们的目的并不是替换
java.util.concurrent
包中的任何并发构造,例如ExecutorService
和Future
。 -
我们的目标并不是为 Java 平台定义最终的结构化并发 API。其他结构化并发构造可以由第三方库或未来的 JDK 版本定义。
-
定义一种在线程间共享数据流的方法(即通道)并不是我们的目标。我们可能会在将来提出这样做的建议。
-
我们的目标并不是用新的线程取消机制来取代现有的线程中断机制。我们可能会在将来提出这样的建议。
动机
开发人员通过将任务分解为多个子任务来管理程序的复杂性。在普通的单线程代码中,子任务按顺序执行。但是,如果子任务彼此足够独立,并且有足够的硬件资源,则可以通过并发执行子任务来使整个任务运行得更快(即延迟更低)。例如,如果每个 I/O 操作都在其自己的线程中并发执行,则由多个 I/O 操作的结果组成的任务将运行得更快。虚拟线程 ( JEP 444 ) 使为每个此类 I/O 操作专用一个线程变得经济高效,但管理可能产生的大量线程仍然是一项挑战。
非结构化并发ExecutorService
Java 5 中引入的APIjava.util.concurrent.ExecutorService
可帮助开发人员并发执行子任务。
例如,这里有一个方法,handle()
,它代表服务器应用程序中的任务。它通过向 提交两个子任务来处理传入的请求ExecutorService
。一个子任务执行方法findUser()
,另一个子任务执行方法fetchOrder()
。ExecutorService
立即为每个子任务返回一个Future
,并根据 的调度策略并发执行子任务Executor
。该handle()
方法通过阻止对其未来方法的调用来等待子任务的结果get()
,因此该任务被称为_加入_其子任务。
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()
,而没有任何任务-子任务关系的提示。
我们可以尝试在发生错误时明确取消其他子任务,从而做得更好,例如,通过使用包装任务try-finally
并在失败任务的 catch 块中调用cancel(boolean)
其他任务的 Future 方法。我们还需要在ExecutorService
a try
-with-resources 语句中使用,如JEP 444中的示例所示,因为Future
它没有提供等待已取消任务的方法。但所有这些都很难做到正确,而且它通常会使代码的逻辑意图更难辨别。跟踪任务间关系并手动添加所需的任务间取消边缘,对很多开发人员来说都是一项艰巨的任务。
需要手动协调生命周期的原因是ExecutorService
和Future
允许不受限制的并发模式。对所涉及的任何线程都没有任何约束或顺序。一个线程可以创建一个ExecutorService
,第二个线程可以向其提交工作,执行工作的线程与第一个或第二个线程都没有关系。此外,在一个线程提交工作之后,完全不同的线程可以等待执行结果。任何引用的代码都Future
可以加入它,即通过调用等待其结果get()
——即使是获取的线程以外的线程中的代码Future
。实际上,由一个任务启动的子任务不必返回到提交它的任务。它可以返回多个任务中的任意一个——也可能没有。
由于ExecutorService
和Future
允许这种非结构化使用,因此它们不会强制甚至跟踪任务和子任务之间的关系,即使这种关系很常见且有用。因此,即使子任务被提交并加入到同一个任务中,一个子任务的失败也不能自动导致另一个子任务的取消:在上述handle()
方法中, 的失败fetchOrder()
不能自动导致 的取消findUser()
。 的未来fetchOrder()
与 的未来无关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 的分层主管)为结构化并发中的错误处理设计提供了参考。
结构化并发源自以下简单原则:
如果一个任务分成多个并发的子任务,那么它们都会返回同一个位置,即任务的代码块。
在结构化并发中,子任务代表任务工作。任务等待子任务的结果并监视它们是否失败。与单线程代码的结构化编程技术一样,多线程结构化并发的强大功能来自两个理念:通过代码块明确定义执行流程的入口点和出口点,以及严格嵌套操作的生命周期,以反映其在代码中的语法嵌套。
由于代码块的入口点和出口点定义明确,因此并发子任务的生命周期被限制在其父任务的语法块中。由于同级子任务的生命周期嵌套在其父任务的生命周期中,因此可以将它们作为一个单元进行推理和管理。由于父任务的生命周期反过来嵌套在其父任务的生命周期中,因此运行时可以将任务层次结构具体化为一棵树,该树是单个线程调用堆栈的并发对应物。这允许代码将策略(例如截止日期)应用于整个任务子树,并允许可观察性工具将子任务显示为其父任务的下属。
结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,允许使用大量虚拟线程。除了数量众多之外,虚拟线程还足够便宜,可以表示任何并发行为单元,甚至涉及 I/O 的行为。这意味着服务器应用程序可以使用结构化并发来同时处理数千或数百万个传入请求:它可以将新的虚拟线程专用于处理每个请求的任务,当任务通过提交子任务进行并发执行而分散时,它可以将新的虚拟线程专用于每个子任务。在后台,任务-子任务关系通过安排每个虚拟线程携带对其唯一父级的引用而被具体化为一棵树,类似于调用堆栈中的框架引用其唯一调用者的方式。
总之,虚拟线程提供了丰富的线程。结构化并发可以正确而稳健地协调它们,并使可观察性工具能够按照开发人员理解的方式显示线程。在 Java 平台中拥有结构化并发的 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,如下所示:
-
使用 编译程序
javac --release 24 --enable-preview Main.java
并使用 运行它java --enable-preview Main
;或者, -
使用源代码启动器时,使用 运行程序
java --enable-preview Main.java
;或者, -
使用jshell时,使用 启动它
jshell --enable-preview
。
使用StructuredTaskScope
该StructuredTaskScope
API 为:
public class StructuredTaskScope<T> implements AutoCloseable {
public <U extends T> Subtask<U> fork(Callable<? extends U> task);
public void shutdown();
public StructuredTaskScope<T> join() throws InterruptedException;
public StructuredTaskScope<T> joinUntil(Instant deadline)
throws InterruptedException, TimeoutException;
public void close();
protected void handleComplete(Subtask<? extends T> handle);
protected final void ensureOwnerAndJoined();
}
代码使用的一般工作流程StructuredTaskScope
是:
-
创建作用域。创建该作用域的线程是其_所有者_。
-
使用该
fork(Callable)
方法在范围内分叉子任务。 -
在任何时候,任何子任务或范围的所有者都可以调用范围的
shutdown()
方法来取消未完成的子任务并防止分叉新的子任务。 -
范围的所有者将范围(即其所有子任务)作为一个单元加入。所有者可以调用范围的
join()
方法,等待所有子任务完成(成功或失败)或通过 取消shutdown()
。或者,它可以调用范围的joinUntil(java.time.Instant)
方法,等待截止期限。 -
加入后,处理子任务中的任何错误并处理其结果。
-
关闭范围,通常通过
try
-with-resources 隐式关闭。这将关闭范围(如果尚未关闭),并等待任何已取消但尚未完成的子任务完成。
每次调用都会fork(...)
启动一个新线程来执行子任务,默认情况下,子任务是虚拟线程。子任务可以创建自己的嵌套线程StructuredTaskScope
来分叉自己的子任务,从而创建一个层次结构。该层次结构反映在代码的块结构中,该结构限制了子任务的生命周期:一旦范围关闭,所有子任务的线程都保证终止,并且当块退出时不会留下任何线程。
作用域中的任何子任务、嵌套作用域中的任何子任务以及作用域的所有者都可以shutdown()
随时调用作用域的方法来表示任务已完成 — 即使其他子任务仍在执行。该shutdown()
方法会中断仍在执行子任务的线程,并导致join()
或joinUntil(Instant)
方法返回。因此,所有子任务都应以响应中断的方式编写。在调用后分叉的新子任务shutdown()
将处于状态UNAVAILABLE
并且不会运行。实际上,shutdown()
是顺序代码中语句的并发模拟break
。
在范围内调用join()
或joinUntil(Instant)
都是强制性的。如果范围的块在加入之前退出,则范围将等待所有子任务终止,然后抛出异常。
作用域所属的线程可能会在加入之前或加入时被中断。例如,它可能是已关闭的封闭作用域的子任务。如果发生这种情况join()
,joinUntil(Instant)
则将抛出异常,因为继续执行毫无意义。try
然后 -with-resources 语句将关闭作用域,这将取消所有子任务并等待它们终止。这会自动将任务的取消传播到其子任务。如果joinUntil(Instant)
方法的截止时间在子任务终止或被shutdown()
调用之前到期,则它将抛出异常,并且try
-with-resources 语句将再次关闭作用域。
当join()
成功完成时,每个子任务要么成功完成,要么失败,要么因为范围关闭而被取消。
加入后,作用域所有者将处理失败的子任务并处理成功完成的子任务的结果;这通常由关闭策略完成(见下文)。可以使用方法获取成功完成的任务的结果Subtask.get()
。该方法永远不会阻塞;如果在加入之前或子任务未成功完成时错误地调用该方法,get()
则会抛出一个。IllegalStateException
在范围中分叉的子任务继承绑定ScopedValue
(JEP 446)。如果范围的所有者从绑定中读取值,ScopedValue
则每个子任务都将读取相同的值。
如果范围的所有者本身是现有范围的子任务,即它是作为分叉子任务创建的,则该范围将成为新范围的父级。范围和子任务因此形成一棵树。
的结构化使用StructuredTaskScope
在运行时强制执行。例如,尝试fork(Callable)
从不在范围树层次结构中的线程(即所有者、子任务和嵌套范围中的子任务(子子任务))调用将失败并出现异常。在try
-with-resources 块之外使用范围并在未调用的情况下返回close()
,或者未维护正确的调用嵌套close()
,可能会导致范围的方法抛出StructureViolationException
。
StructuredTaskScope
强制执行并发操作的结构和顺序。因此,它不实现ExecutorService
或Executor
接口,因为这些接口的实例通常以非结构化方式使用(见下文ExecutorService
)。但是,将使用但会受益于结构的代码迁移到使用是很简单的事情StructuredTaskScope
。
实际上,大多数用户StructuredTaskScope
不会StructuredTaskScope
直接使用该类,而是使用下一节中介绍的两个实现关闭策略的子类之一。在其他情况下,用户可能会编写自己的子类来实现自定义关闭策略。
关机政策
处理并发子任务时,通常使用_短路模式_来避免做不必要的工作。有时,如果其中一个子任务失败(即_调用所有_),或者如果其中一个子任务成功(即_调用任何_),则取消所有子任务是有意义的。 和 的两个子类StructuredTaskScope
分别ShutdownOnFailure
通过ShutdownOnSuccess
当第一个子任务失败或成功时关闭范围的策略来支持这些模式。
关闭策略还提供了集中处理异常和可能成功结果的方法。这符合结构化并发的精神,根据该精神,整个范围被视为一个单元。
这是一个StructuredTaskScope
具有失败时关闭策略(也用于handle()
上面的示例)的方法,它同时运行一组任务,如果其中任何一个任务失败,它就会失败:
<T> List<T> runAll(List<Callable<T>> tasks)
throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
List<? extends Supplier<T>> suppliers = tasks.stream().map(scope::fork).toList();
scope.join()
.throwIfFailed(); // Propagate exception if any subtask fails
// Here, all tasks have succeeded, so compose their results
return suppliers.stream().map(Supplier::get).toList();
}
}
这是一个StructuredTaskScope
具有成功关闭策略的程序,它返回第一个成功的子任务的结果:
<T> T race(List<Callable<T>> tasks, Instant deadline)
throws InterruptedException, ExecutionException, TimeoutException {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
for (var task : tasks) {
scope.fork(task);
}
return scope.joinUntil(deadline)
.result(); // Throws if none of the subtasks completed successfully
}
}
一旦一个子任务成功,该范围就会自动关闭,取消未完成的子任务。如果所有子任务都失败或超过给定的截止期限,则任务失败。例如,这种模式在需要一组冗余服务中的任何一个结果的服务器应用程序中非常有用。
虽然这两种关闭策略都是开箱即用的,但开发人员可以创建抽象其他模式的自定义策略(见下文)。
处理结果
通过关闭策略 (例如,使用 ) 加入并集中处理异常之后,如果子任务的结果未按策略 (例如,由 ) 处理,则范围的所有者可以使用从调用 返回的对象ShutdownOnFailure::throwIfFailed
来处理子任务的结果。Subtask
fork(...)
ShutdownOnSuccess::result()
Subtask
通常,作用域所有者将调用的唯一方法是get()
方法。所有其他Subtask
方法通常仅在handleComplete(...)
自定义关闭策略的方法的实现中使用(见下文)。事实上,我们建议将引用Subtask
返回的变量的fork(...)
类型设置为,例如,Supplier<String>
而不是Subtask<String>
(当然,除非您选择使用var
)。如果关闭策略本身处理子任务结果(如的情况ShutdownOnSuccess
),则应完全避免使用Subtask
返回的对象,并将方法视为返回。子任务应将作用域所有者在策略集中处理异常后应处理的任何信息作为其结果返回。fork(...)``fork(...)``void
如果作用域所有者处理子任务异常以产生复合结果,而不是使用关闭策略,那么异常可以作为子任务的值返回。例如,这里有一个方法,它并行运行一系列任务,并返回一个已完成的列表Future
,其中包含每个任务各自的成功或异常结果:
<T> List<Future<T>> executeAll(List<Callable<T>> tasks)
throws InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
List<? extends Supplier<Future<T>>> futures = tasks.stream()
.map(task -> asFuture(task))
.map(scope::fork)
.toList();
scope.join();
return futures.stream().map(Supplier::get).toList();
}
}
static <T> Callable<Future<T>> asFuture(Callable<T> task) {
return () -> {
try {
return CompletableFuture.completedFuture(task.call());
} catch (Exception ex) {
return CompletableFuture.failedFuture(ex);
}
};
}
自定义关闭策略
StructuredTaskScope
可以进行扩展,并handleComplete(...)
重写其受保护的方法,以实现除 和 之外的策略ShutdownOnSuccess
。ShutdownOnFailure
例如,子类可以
- 收集成功完成的子任务的结果并忽略失败的子任务,
- 当子任务失败时收集异常,或者
- 当某些条件出现时,调用该
shutdown()
方法关闭并join()
唤醒。
当子任务完成时,即使shutdown()
已经被调用,它也会handleComplete(...)
以如下形式报告给方法Subtask
:
public sealed interface Subtask<T> extends Supplier<T> {
enum State { SUCCESS, FAILED, UNAVAILABLE }
State state();
Callable<? extends T> task();
T get();
Throwable exception();
}
该handleComplete(...)
方法用于在调用之前已成功完成(SUCCESS
状态)或未成功完成(FAILED
状态)的子任务。仅当子任务处于状态时才可调用该方法;在其他情况下调用或将导致它们抛出。状态表示以下之一:(1) 子任务已分叉但尚未完成;(2) 子任务在关闭后完成,或 (3) 子任务在关闭后分叉,因此尚未启动。从不为处于状态的子任务调用该方法。shutdown()``get()``SUCCESS``exception()``FAILED``get()``exception()``IllegalStateException``UNAVAILABLE``handleComplete(...)``UNAVAILABLE
子类通常会定义方法,以便将结果、状态或其他结果提供给方法join()
返回后执行的代码。收集结果并忽略失败的子任务的子类可以定义返回结果集合的方法。实施在子任务失败时关闭的策略的子类可以定义方法来获取第一个失败的子任务的异常。
以下是一个StructuredTaskScope
子类的示例,用于收集成功完成的子任务的结果。它定义了results()
主任务用来检索结果的方法。
class MyScope<T> extends StructuredTaskScope<T> {
private final Queue<T> results = new ConcurrentLinkedQueue<>();
MyScope() { super(null, Thread.ofVirtual().factory()); }
@Override
protected void handleComplete(Subtask<? extends T> subtask) {
if (subtask.state() == Subtask.State.SUCCESS)
results.add(subtask.get());
}
@Override
public MyScope<T> join() throws InterruptedException {
super.join();
return this;
}
// Returns a stream of results from the subtasks that completed successfully
public Stream<T> results() {
super.ensureOwnerAndJoined();
return results.stream();
}
}
此自定义策略可以按如下方式使用:
<T> List<T> allSuccessful(List<Callable<T>> tasks) throws InterruptedException {
try (var scope = new MyScope<T>()) {
for (var task : tasks) scope.fork(task);
return scope.join()
.results().toList();
}
}
扇入场景
上述示例主要针对_扇出_场景,即管理多个并发传出 I/O 操作。在_扇入_StructuredTaskScope
场景中也很有用,即管理多个并发传入 I/O 操作。在此类场景中,我们通常会创建未知数量的子任务来响应传入的请求。
下面是一个服务器示例,它分叉子任务来处理内部的传入连接StructuredTaskScope
:
void serve(ServerSocket serverSocket) throws IOException, InterruptedException {
try (var scope = new StructuredTaskScope<Void>()) {
try {
while (true) {
var socket = serverSocket.accept();
scope.fork(() -> handle(socket));
}
} finally {
// If there's been an error or we're interrupted, we stop accepting
scope.shutdown(); // Close all active connections
scope.join();
}
}
}
从并发性的角度来看,此场景的不同之处并不在于请求的方向,而在于任务的持续时间和数量。与前面的示例不同,作用域的所有者的持续时间不受限制 — 只有在被中断时才会停止。子任务的数量也是未知的,因为它们是根据外部事件动态分叉的。
所有连接处理子任务都是在范围内创建的,因此很容易在线程转储中看到它们的用途,线程转储会将它们显示为范围所有者的子任务。也很容易将整个服务作为一个单元关闭。
可观察性
我们扩展了JEP 444添加的新 JSON 线程转储格式,以显示StructuredTaskScope
线程的层次结构分组:
$ jcmd <pid> Thread.dump_to_file -format=json <file>
每个作用域的 JSON 对象都包含作用域中分叉的线程数组及其堆栈跟踪。作用域的所属线程通常会在连接方法中被阻止,以等待子任务完成;线程转储通过显示结构化并发所施加的树状层次结构,可以轻松查看子任务的线程正在做什么。作用域的 JSON 对象还引用了其父级,因此可以从转储中重建程序的结构。
该com.sun.management.HotSpotDiagnosticsMXBean
API 还可用于通过平台MBeanServer
和本地或远程 JMX 工具直接或间接地生成此类线程转储。
为什么不fork(...)
返回a Future
?
当StructuredTaskScope
API 处于孵化阶段时,该fork(...)
方法返回一个Future
。这提供了一种熟悉感,因为它fork(...)
与现有ExecutorService::submit
方法相似。但是,考虑到StructuredTaskScope
的用法与 不同ExecutorService
(如上所述,以结构化方式使用), 的使用Future
带来的困惑多于清晰度。
-
熟悉的用法
Future
涉及调用其get()
方法,该方法会阻塞直至结果可用。但在 的上下文中StructuredTaskScope
,Future
以这种方式使用不仅不鼓励,而且适得其反。结构化Future
对象应仅在返回后进行查询join()
,此时它们已知已完成或已取消,并且应该使用的方法不是熟悉的,get()
而是新引入的resultNow()
,它永远不会阻塞。 -
一些开发人员想知道为什么
fork(...)
不返回更强大的CompletableFuture
对象。由于Future
返回的fork(...)
应该只在已知完成后使用,CompletableFuture
因此不会带来任何好处,因为它的高级功能仅对未完成的未来有用。此外,CompletableFuture
是为异步编程范例设计的,而StructuredTaskScope
鼓励阻塞范例。Future
简而言之,其CompletableFuture
设计目的是提供在结构化并发中适得其反的自由度。 -
结构化并发是指将在不同线程中运行的多个任务视为单个工作单元,而 则
Future
在将多个任务视为单个任务时最为有用。作用域应该只阻塞一次以等待其子任务的结果,然后应该集中处理异常。因此,在绝大多数情况下,返回时应该调用的唯一方法Future
是fork(...)
。resultNow()
这与 的普通用法有显著不同Future
,并且该Future
接口会分散人们对其在此上下文中的正确使用的注意力。
在当前 API 中,行为与 API 孵化时Subtask::get()
完全相同。Future::resultNow()
替代方案
- 增强
ExecutorService
接口。我们为此接口创建了一个原型实现,该实现始终强制执行结构并限制哪些线程可以提交任务。但是,我们发现它存在问题,因为JDK 和生态系统中ExecutorService
(及其父接口Executor
)的大多数用途都不是结构化的。将相同的 API 重复用于更受限制的概念必然会引起混淆。例如,ExecutorService
在大多数情况下,将结构化实例传递给接受此类型的现有方法几乎肯定会引发异常。