跳到主要内容

JEP 462:结构化并发(第二预览版)

概括

_通过引入结构化_并发 API 来简化并发编程。结构化并发将在不同线程中运行的相关任务组视为单个工作单元,从而简化错误处理和取消、提高可靠性并增强可观察性。这是一个预览 API

历史

结构化并发由JEP 428提出,并在JDK 19中作为孵化 API提供。它由JEP 437JDK 20中重新孵化,并进行了较小的更新以继承范围值(JEP 429)。它首先通过JEP 453在JDK 21中预览,并更改为返回 a而不是.我们在此建议在 JDK 22 中重新预览 API,不做任何更改,以获得更多反馈。StructuredTaskScope::fork(...)SubtaskFuture

目标

  • 推广一种并发编程风格,可以消除因取消和关闭而产生的常见风险,例如线程泄漏和取消延迟。

  • 提高并发代码的可观察性。

非目标

  • java.util.concurrent替换包中的任何并发结构(例如ExecutorService和 )并不是目标Future

  • 为 Java 平台定义明确的结构化并发 API 并不是我们的目标。其他结构化并发构造可以由第三方库或在未来的 JDK 版本中定义。

  • 定义在线程(即通道)之间共享数据流的方法并不是目标。我们可能会建议将来这样做。

  • 用新的线程取消机制取代现有的线程中断机制并不是目标。我们可能会建议将来这样做。

动机

开发人员通过将任务分解为多个子任务来管理复杂性。在普通的单线程代码中,子任务顺序执行。然而,如果子任务彼此充分独立,并且如果有足够的硬件资源,则可以通过同时执行子任务来使整体任务运行得更快(即,具有更低的延迟)。例如,如果每个 I/O 操作在自己的线程中并发执行,则由多个 I/O 操作组成的任务将运行得更快。虚拟线程 ( JEP 444 ) 使为每个此类 I/O 操作专用一个线程变得经济高效,但管理可能产生的大量线程仍然是一个挑战。

非结构化并发ExecutorService

java.util.concurrent.ExecutorServiceJava 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。实际上,由一个任务启动的子任务不必返回到提交它的任务。它可以返回许多任务中的任何一个,甚至不返回任何任务。

因为ExecutorServiceFuture允许这种非结构化使用,所以它们不强制甚至不跟踪任务和子任务之间的关系,即使这样的关系是常见且有用的。因此,即使在同一个任务中提交并加入子任务,一个子任务的失败也不会自动导致另一个子任务的取消:在上述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,默认禁用

要使用该StructuredTaskScopeAPI,您必须启用预览 API,如下所示:

  • 使用 编译程序javac --release 21 --enable-preview Main.java并使用java --enable-preview Main;运行它或者,

  • 使用源代码启动器时,使用java --source 21 --enable-preview Main.java;运行程序或者,

  • 使用jshell时,以 启动jshell --enable-preview

使用StructuredTaskScope

APIStructuredTaskScope是:

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是:

  1. 创建一个范围。创建作用域的线程是它的_所有者_。

  2. 使用该fork(Callable)方法在范围内派生子任务。

  3. 任何时候,任何子任务或作用域的所有者都可以调用作用域的shutdown()方法来取消未完成的子任务并防止分叉新的子任务。

  4. 范围的所有者将范围(即其所有子任务)作为一个单元加入。所有者可以调用作用域的join()方法,等待所有子任务完成(成功或失败)或通过 取消shutdown()。或者,它可以调用作用域的joinUntil(java.time.Instant)方法来等待截止日期。

  5. 加入后,处理子任务中的任何错误并处理其结果。

  6. 关闭范围,通常通过try-with-resources 隐式关闭。如果范围尚未关闭,这将关闭范围,并等待任何已取消但尚未完成的子任务完成。

每次调用都会fork(...)启动一个新线程来执行子任务,默认情况下是虚拟线程。子任务可以创建自己的嵌套StructuredTaskScope来派生自己的子任务,从而创建层次结构。该层次结构反映在代码的块结构中,它限制了子任务的生命周期:一旦作用域关闭,所有子任务的线程都保证终止,并且当块退出时,不会留下任何线程。

作用域中的任何子任务、嵌套作用域中的任何子任务以及作用域的所有者都可以shutdown()随时调用作用域的方法来表示任务已完成 - 即使其他子任务仍在执行时也是如此。该shutdown()方法会中断仍在执行子任务的线程,并导致join()orjoinUntil(Instant)方法返回。因此,所有子任务都应该以响应中断的方式编写。调用后派生的新子任务shutdown()将处于该UNAVAILABLE状态并且不会运行。实际上,它是顺序代码中语句shutdown()的并发模拟。break

在某个范围内调用join()或是强制的。joinUntil(Instant)如果作用域的块在加入之前退出,则作用域将等待所有子任务终止,然后抛出异常。

作用域所属的线程有可能在加入之前或加入时被中断。例如,它可能是已关闭的封闭范围的子任务。如果发生这种情况,join()则会joinUntil(Instant)抛出异常,因为没有继续下去的意义。然后-with try-resources 语句将关闭作用域,这将取消所有子任务并等待它们终止。这具有自动将任务的取消传播到其子任务的效果。如果该joinUntil(Instant)方法的截止时间在子任务终止或shutdown()调用之前到期,那么它将抛出异常,并且try-with-resources 语句将再次关闭作用域。

成功完成后join(),每个子任务要么成功完成,要么失败,或者因为范围被关闭而被取消。

一旦加入,范围的所有者将处理失败的子任务并处理成功完成的子任务的结果;这通常是通过关闭策略来完成的(见下文)。通过该方法可以获得成功完成的任务的结果Subtask.get()。该get()方法永远不会阻塞;IllegalStateException如果在加入之前错误地调用或者子任务未成功完成时,它会抛出一个异常。

在作用域中分叉的子任务继承绑定ScopedValueJEP 446)。如果范围的所有者从边界读取值ScopedValue,则每个子任务将读取相同的值。

如果作用域的所有者本身就是现有作用域的子任务,即它是作为分叉子任务创建的,则该作用域将成为新作用域的父级。范围和子任务因此形成一棵树。

的结构化使用StructuredTaskScope是在运行时强制执行的。例如,尝试fork(Callable)从不在作用域的树形层次结构中的线程(即所有者、子任务以及嵌套作用域中的子任务(子子任务))进行调用将会失败并出现异常。在try-with-resources 块之外使用作用域并在不调用close()或不维护close()调用的正确嵌套的情况下返回,可能会导致作用域的方法抛出StructureViolationException.

StructuredTaskScope强制并发操作的结构和顺序。因此,它不实现ExecutorServiceExecutor接口,因为这些接口的实例通常以非结构化方式使用(见下文)。然而,将使用 的代码迁移到使用 很简单ExecutorService,但会从结构中受益StructuredTaskScope

实际上,大多数用途StructuredTaskScope不会StructuredTaskScope直接使用该类,而是使用下一节中描述的两个子类之一来实现关闭策略。在其他场景中,用户可能会编写自己的子类来实现自定义关闭策略。

关停政策

在处理并发子任务时,通常使用_短路模式_来避免做不必要的工作。有时,如果其中一个子任务失败(即_调用 all_),或者如果其中一个子任务成功(即_调用 any_),则取消所有子任务是有意义的。和StructuredTaskScope的两个子类通过分别在第一个子任务失败或成功时关闭范围的策略来支持这些模式。ShutdownOnFailureShutdownOnSuccess

关闭策略还提供了集中的方法来处理异常,并可能获得成功的结果。这符合结构化并发的精神,根据该精神,整个作用域被视为一个单元。

这是一个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
}
}

一旦一个子任务成功,该范围就会自动关闭,取消未完成的子任务。如果所有子任务都失败或者给定的截止日期已过,则任务将失败。例如,此模式在需要来自任何一个冗余服务集合的结果的服务器应用程序中非常有用。

虽然这两个关闭策略是开箱即用的,但开发人员可以创建抽象其他模式的自定义策略(见下文)。

处理结果

通过关闭策略(例如, with )加入并集中处理异常后,作用域的所有者可以使用从调用返回的对象来ShutdownOnFailure::throwIfFailed处理子任务的结果(如果策略未处理它们)(例如,通过)。Subtaskfork(...)ShutdownOnSuccess::result()

Subtask通常,范围所有者将调用的唯一方法是get()方法。所有其他方法通常仅在实现自定义关闭策略的方法Subtask时使用(见下文)。事实上,我们建议引用 a返回的变量键入为,例如,而不是(当然,除非您选择使用)。如果关闭策略本身处理子任务结果(如以下情况),则应完全避免由 返回的对象,并且该方法将被视为返回。子任务应返回范围所有者在策略集中异常处理后应处理的任何信息作为其结果。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(...)的子任务,会调用该方法。只有子任务处于状态时才能调用该方法,并且只有子任务处于状态时才能调用该方法;调用或在其他情况下将导致他们抛出.该状态表示以下情况之一:(1)子任务已分叉但尚未完成; (2) 子任务在关闭后完成,或者 (3) 子任务在关闭后被分叉,因此尚未启动。状态中的子任务永远不会调用该方法。SUCCESS``FAILED``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 操作。在管理多个并发传入 I/O 操作的_扇入_StructuredTaskScope场景中也很有用。在这种情况下,我们通常会创建未知数量的子任务来响应传入的请求。

下面是一个服务器的示例,它派生子任务来处理 a 内的传入连接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 对象包含作用域中分叉的线程数组及其堆栈跟踪。作用域的拥有线程通常会被阻塞在 join 方法中,等待子任务完成;线程转储通过显示结构化并发所施加的树层次结构,可以轻松查看子任务的线程正在执行的操作。作用域的 JSON 对象还具有对其父级的引用,以便可以从转储中重建程序的结构。

APIcom.sun.management.HotSpotDiagnosticsMXBean还可用于直接或间接通过平台MBeanServer和本地或远程 JMX 工具生成此类线程转储。

为什么不fork(...)返回 a Future

StructuredTaskScopeAPI 正在孵化时,该fork(...)方法返回一个Future.通过与fork(...)现有方法相似,这提供了一种熟悉感ExecutorService::submit。然而,考虑到StructuredTaskScope的使用方式与ExecutorService如上所述的结构化方式不同,使用Future带来的混乱多于清晰度。

  • 熟悉的用法Future包括调用它的get()方法,该方法会阻塞直到有结果可用。但在大背景下StructuredTaskScopeFuture这样使用不仅不被鼓励而且会适得其反。结构化Future对象应该只在返回后查询join(),此时它们已知已完成或取消,并且应该使用的方法不是熟悉的get()而是新引入的resultNow(),它永远不会阻塞。

  • 一些开发人员想知道为什么fork(...)不返回功能更强大的CompletableFuture对象。由于Future返回的fork(...)只能在已知已完成后使用,CompletableFuture因此不会提供任何好处,因为其高级功能仅对未完成的 future 有帮助。此外,CompletableFuture它是为异步编程范例而设计的,而StructuredTaskScope鼓励阻塞范例。

    Future简而言之,它们CompletableFuture旨在提供在结构化并发中适得其反的自由度。

  • 结构化并发是将在不同线程中运行的多个任务视为单个工作单元,而Future在将多个任务视为单独的任务时最有用。作用域应该只阻塞一次以等待其子任务的结果,然后它应该集中处理异常。因此,在绝大多数情况下,应该在Futurefrom 返回时调用的唯一方法fork(...)resultNow().这是与 的普通使用相比的显着变化Future,并且该Future界面分散了其在此上下文中正确使用的注意力。

在当前 API 中,其行为与 API 孵化时Subtask::get()完全相同。Future::resultNow()

备择方案

  • 增强ExecutorService界面。我们对该接口的实现进行了原型设计,该接口始终强制执行结构并限制哪些线程可以提交任务。然而,我们发现它是有问题的,因为JDK 和生态系统中的大多数使用ExecutorService(及其父接口)都不是结构化的。Executor将相同的 API 重复用于限制性更大的概念必然会引起混乱。例如,ExecutorService在大多数情况下,将结构化实例传递给接受该类型的现有方法几乎肯定会引发异常。