关于后端:掌握JDK21全新结构化并发编程轻松提升开发效率

47次阅读

共计 13833 个字符,预计需要花费 35 分钟才能阅读完成。

1 概要

通过引入结构化并发编程的 API,简化并发编程。结构化并发将在不同线程中运行的相干工作组视为单个工作单元,从而简化错误处理和勾销操作,进步可靠性,并加强可察看性。这是一个预览版的 API。

2 历史

结构化并发是由 JEP 428 提出的,并在 JDK 19 中作为孵化 API 公布。它在 JDK 20 中被 JEP 437 从新孵化,通过对作用域值(JEP 429)进行轻微更新。

咱们在这里提议将结构化并发作为 JUC 包中的预览 API。惟一重要变动是 StructuredTaskScope::fork(…) 办法返回一个 [子工作],而不是一个 Future,如上面所探讨的。

3 指标

推广一种并发编程格调,能够打消因为勾销和敞开而产生的常见危险,如线程透露和勾销提早。

进步并发代码的可察看性。

4 非指标

不替换 JUC 包中的任何并发结构,如 ExecutorService 和 Future。

不定义 Java 平台的最终结构化并发 API。其余结构化并发结构能够由第三方库定义,或在将来的 JDK 版本中定义。

不定义在线程之间共享数据流的办法(即通道)。会在将来提出这样做。

不必新的线程勾销机制替换现有的线程中断机制。会在将来提出这样做。

5 动机

开发人员通过将工作合成为多个子工作来治理复杂性。在一般的单线程代码中,子工作按程序执行。然而,如果子工作彼此足够独立,并且存在足够的硬件资源,那么通过在不同线程中并发执行子工作,能够使整个工作运行得更快(即具备较低的提早)。例如,将多个 I / O 操作的后果组合成一个工作,如果每个 I / O 操作都在本人的线程中并发执行,那么工作将运行得更快。虚构线程(JEP 444)使得为每个此类 I / O 操作调配一个线程成为一种具备老本效益的办法,然而治理可能会产生大量线程依然是一个挑战。

6 ExecutorService 非结构化并发

java.util.concurrent.ExecutorService API 是在 Java 5 中引入的,它帮忙开发人员以并发形式执行子工作。

如下 handle() 的办法,它示意服务器应用程序中的一个工作。它通过将两个子工作提交给 ExecutorService 来解决传入的申请。

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();   // 退出 findUser
    int theOrder = order.get();    // 退出 fetchOrder
    return new Response(theUser, theOrder);
}

因为子工作并发执行,每个子工作都可独立地胜利或失败。在这个上下文中,” 失败 ” 意味着抛出异样。通常,像 handle() 这样的工作应该在任何一个子工作失败时失败。当呈现失败时,了解线程的生命周期会变得非常复杂:

  • findUser() 抛异样,那么调用 user.get()handle() 也会抛出异样,然而 fetchOrder() 会持续在本人的线程中运行。这是线程透露,最好状况下浪费资源,最坏状况下 fetchOrder() 的线程可能会烦扰其余工作。
  • 如执行 handle() 的线程被中断,这个中断不会流传到子工作。findUser()fetchOrder() 的线程都会透露,即便在 handle() 失败后依然持续运行。
  • 如果 findUser() 执行工夫很长,然而在此期间 fetchOrder() 失败,那么 handle() 将不必要地期待 findUser(),因为它会在 user.get() 上阻塞,而不是勾销它。只有在 findUser() 实现并且 user.get() 返回后,order.get() 才会抛出异样,导致 handle() 失败。

每种 case 下,问题在于咱们的程序在逻辑上被结构化为工作 - 子工作关系,但这些关系只存在于开发人员的头脑中。这不仅减少谬误可能性,还会使诊断和排除此类谬误变得更加艰难。例如,线程转储等可察看性工具会在不相干的线程调用栈中显示 handle()findUser()fetchOrder(),而没有工作 - 子工作关系的提醒。

可尝试在谬误产生时显式勾销其余子工作,例如通过在失败的工作的 catch 块中应用 try-finally 包装工作,并调用其余工作的 Futurecancel(boolean) 办法。咱们还须要在 try-with-resources 语句中应用 ExecutorService,就像

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {IntStream.range(0, 10_000).forEach(i -> {executor.submit(() -> {Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

因为 Future 没有提供期待被勾销的工作的办法。但所有这些都很难做到,并且往往会使代码的逻辑用意变得更加难以了解。跟踪工作之间的关系,并手动增加所需的工作间勾销边缘,是对开发人员的一种很大要求。

无限度的并发模式

这种须要手动协调生命周期的需要是因为 ExecutorServiceFuture 容许无限度的并发模式。在波及的所有线程中,没有限度或程序:

  • 一个线程能够创立一个 ExecutorService
  • 另一个线程能够向其提交工作
  • 执行工作的线程与第一个或第二个线程没有任何关系

线程提交工作之后,一个齐全不同的线程能够期待执行的后果。具备对 Future 的援用的任何代码都能够退出它(即通过调用 get() 期待其后果),甚至能够在与获取 Future 的线程不同的线程中执行代码。实际上,由一个工作启动的子工作不用返回到提交它的工作。它能够返回给许多工作中的任何一个,甚至可能是没有返回给任何工作。

因为 ExecutorServiceFuture 容许这种无构造的应用,它们既不强制执行也不跟踪工作和子工作之间的关系,只管这些关系是常见且有用的。因而,即便子工作在同一个工作中被提交和退出,一个子工作的失败也不能主动导致另一个子工作的勾销。在上述的 handle() 办法中,fetchOrder() 的失败不能主动导致 findUser() 的勾销。fetchOrder()FuturefindUser()Future 没有关系,也与最终通过其 get() 办法退出它的线程无关。与其要求开发人员手动治理这种勾销,咱们心愿可能牢靠地自动化这一过程。

工作构造应反映代码构造

ExecutorService 下的自在线程组合相同,单线程代码的执行总是强制执行工作和子工作的层次结构。办法的代码块 {...} 对应一个工作,代码块外部调用的办法对应子工作。调用的办法必须返回给调用它的办法,或者抛出异样给调用它的办法。它不能生存于调用它的办法之外,也不能返回或抛出异样给其余办法。因而,所有子工作在工作之前实现,每个子工作都是其父工作的子工作,每个子工作的生命周期绝对于其余子工作和工作来说,都由代码块构造的语法规定来治理。

如单线程版本的 handle() 中,工作 - 子工作关系在语法结构显著:

Response handle() throws IOException {String theUser = findUser();
    int theOrder = fetchOrder();
    return new Response(theUser, theOrder);
}

咱们不会在 findUser() 子工作实现之前启动 fetchOrder() 子工作,无论 findUser() 是胜利还是失败。如果 findUser() 失败,咱们基本不会启动 fetchOrder(),而且 handle() 工作会隐式地失败。一个子工作只能返回给其父工作,这是很重要的:这意味着父工作能够将一个子工作的失败隐式地视为触发来勾销其余未实现的子工作,而后本人失败。

单线程代码中,工作 - 子工作档次关系在运行时的调用栈中失去体现。因而,咱们取得了相应的父子关系,这些关系治理着谬误流传。察看单个线程时,档次关系不言而喻:findUser()(及起初的 fetchOrder())仿佛是在 handle() 下执行的。这使得答复问题 “handle() 正在解决什么?” 很容易。

如工作和子工作之间的父子关系在代码的语法结构中显著,并且在运行时失去了体现,那并发编程将更加容易、牢靠且易于察看,就像单线程代码一样。语法结构将定义子工作的生命周期,并使得可能在运行时创立一个相似于单线程调用栈的线程层次结构的示意。这种示意将实现谬误流传、勾销以及对并发程序的有意义的察看。

7 结构化并发

结构化并发是一种并发编程办法,它放弃了工作和子工作之间的天然关系,从而实现了更具可读性、可维护性和可靠性的并发代码。” 结构化并发 ” 这个术语由 Martin Sústrik 提出,并由 Nathaniel J. Smith 推广。从其余编程语言中的概念,如 Erlang 中的档次监控者,能够理解到结构化并发中错误处理的设计思维。

结构化并发源于一个简略的准则:

如果一个工作合成为并发的子工作,那么所有这些子工作都会返回到同一个中央,即工作的代码块。

在结构化并发中,子工作代表工作工作。工作期待子工作的后果并监督它们的失败状况。与单线程代码中的结构化编程技术相似,结构化并发在多线程中的威力来自于两个思维:

  • 为代码块中的执行流程定义明确的进入和退出点
  • 在严格的操作生命周期嵌套中,以反映它们在代码中的语法嵌套形式

因为代码块的进入和退出点被明确定义,因而并发子工作的生命周期被限定在其父工作的语法块中。因为同级子工作的生命周期嵌套在其父工作的生命周期之内,因而能够将它们作为一个单元进行推理和治理。因为父工作的生命周期,顺次嵌套在其父工作的生命周期之内,运行时能够将工作层次结构实现为树状构造,相似于单线程调用栈的并发对应物。这容许代码为工作子树利用策略,如截止工夫,并容许可察看性工具将子工作出现为父工作的上司。

结构化并发非常适合虚构线程,这是由 JDK 实现的轻量级线程。许多虚构线程能够共享同一个操作系统线程,从而能够反对十分大量的虚构线程。除此外,虚构线程足够便宜,能够示意任何波及 I / O 等并发行为。这意味着服务器应用程序能够应用结构化并发来同时解决成千上万甚至百万个传入申请:它能够为解决每个申请的任务分配一个新的虚构线程,当一个工作通过提交子工作进行并发执行时,它能够为每个子任务分配一个新的虚构线程。在幕后,工作 - 子工作关系通过为每个虚构线程提供一个对其惟一父工作的援用来实现为树状构造,相似于调用栈中的帧援用其惟一的调用者。

总之,虚构线程提供了大量的线程。结构化并发能够正确且弱小地协调它们,并使可察看性工具可能依照开发人员的了解显示线程。在 JDK 中领有结构化并发的 API 将使构建可保护、牢靠且可察看的服务器应用程序变得更加容易。

8 形容

结构化并发 API 的次要类是 java.util.concurrent 包中的 StructuredTaskScope。该类容许开发人员将一个工作结构化为一组并发的子工作,并将它们作为一个单元进行协调。子工作通过别离分叉它们并将它们作为一个单元退出,可能作为一个单元勾销,来在它们本人的线程中执行。子工作的胜利后果或异样由父工作汇总并解决。StructuredTaskScope 将子工作的生命周期限度在一个清晰的词法作用域内,在这个作用域中,工作与其子工作的所有交互(分叉、退出、勾销、处理错误和组合后果)都产生。

后面提到的 handle() 示例,应用 StructuredTaskScope 编写:

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()             // 退出两个子工作
             .throwIfFailed();   // ... 并流传谬误

        // 两个子工作都胜利实现,因而组合它们的后果
        return new Response(user.get(), order.get());
    }
}

与原始示例相比,了解波及的线程的生命周期在这里变得更加容易:在所有状况下,它们的生命周期都限度在一个词法作用域内,即 try-with-resources 语句的代码块内。此外,应用 StructuredTaskScope 能够确保一些有价值的属性:

  1. 错误处理与短路 — 如果 findUser()fetchOrder() 子工作中的任何一个失败,另一个如果尚未实现则会被勾销。(这由 ShutdownOnFailure 实现的敞开策略来治理;还有其余策略可能)。
  2. 勾销流传 — 如果在运行 handle() 的线程在调用 join() 之前或之中被中断,则线程在退出作用域时会主动勾销两个子工作。
  3. 清晰性 — 上述代码具备清晰的构造:设置子工作,期待它们实现或被勾销,而后决定是胜利(并解决曾经实现的子工作的后果)还是失败(子工作曾经实现,因而没有更多须要清理的)。
  4. 可察看性 — 如下所述,线程转储分明地显示了工作层次结构,其中运行 findUser()fetchOrder() 的线程被显示为作用域的子工作。

9 冲破预览版限度

StructuredTaskScope 是预览版 API,默认禁用。要应用 StructuredTaskScope API,需启用预览 API:

  1. 应用 javac --release 21 --enable-preview Main.java 编译程序,而后应用 java --enable-preview Main 运行它;或
  2. 当应用源代码启动器时,应用 java --source 21 --enable-preview Main.java 运行程序
  3. IDEA 运行时,勾选即可:

10 应用 StructuredTaskScope

10.1 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();}

10.2 工作流程

  1. 创立一个作用域。创立作用域的线程是其所有者。
  2. 应用 fork(Callable) 办法在作用域中分叉子工作。
  3. 在任何工夫,任何子工作,或者作用域的所有者,都能够调用作用域的 shutdown() 办法来勾销未实现的子工作并阻止分叉新的子工作。
  4. 作用域的所有者将作用域(即所有子工作)作为一个单元退出。所有者能够调用作用域的 join() 办法,期待所有子工作已实现(无论胜利与否)或通过 shutdown() 被勾销。或者,它能够调用作用域的 joinUntil(java.time.Instant) 办法,期待直到截止工夫。
  5. 退出后,解决子工作中的任何谬误并解决其后果。
  6. 敞开作用域,通常通过隐式应用 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 强制执行构造和程序并发操作。因而,它不实现 ExecutorServiceExecutor 接口,因为这些接口的实例通常以非结构化形式应用(见下文)。然而,将应用 ExecutorService 的代码迁徙到应用 StructuredTaskScope 并从构造上受害是间接的。

实际上,大多数应用 StructuredTaskScope 的状况下,可能不会间接应用 StructuredTaskScope 类,而是应用下一节形容的两个实现了敞开策略的子类之一。在其余状况下,用户可能会编写本人的子类来实现自定义的敞开策略。

11 敞开策略

在解决并发子工作时,通常会应用短路模式来防止不必要的工作。有时,例如,如果其中一个子工作失败,就会勾销所有子工作(即同时调用所有工作),或者在其中一个子工作胜利时勾销所有子工作(即同时调用任何工作)。StructuredTaskScope 的两个子类,ShutdownOnFailureShutdownOnSuccess,反对这些模式,并提供在第一个子工作失败或胜利时敞开作用域的策略。

敞开策略还提供了集中处理异样以及可能的胜利后果的办法。这合乎结构化并发的精力,即整个作用域被视为一个单元。

11.1 案例

下面的 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();  // 任何子工作失败,抛异样
        // 在这里,所有工作都已胜利实现,因而组合后果
        return suppliers.stream().map(Supplier::get).toList();}
}

在第一个胜利的子工作返回后果后返回该后果:

<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();  // 如果没有任何子工作胜利实现,抛出异样}
}

一旦有一个子工作胜利,此作用域将主动敞开,勾销未实现的子工作。如果所有子工作失败或给定的截止工夫过来,工作将失败。这种模式在须要从一组冗余服务中取得任何一个服务的后果的服务器应用程序中十分有用。

尽管这俩敞开策略已内置,但开发人员能够创立自定义策略来形象其余模式。

11.2 处理结果

在通过敞开策略(例如,通过 ShutdownOnFailure::throwIfFailed)进行集中异样解决和退出之后,作用域的所有者能够应用从调用 fork(...) 返回的 [Subtask] 对象解决子工作的后果,如果这些后果没有被策略解决(例如,通过 ShutdownOnSuccess::result())。

通常状况下,作用域所有者将只调用 get() 办法的 Subtask 办法。所有其余的 Subtask 办法通常只会在自定义敞开策略的 handleComplete(...) 办法的实现中应用。实际上,咱们倡议将援用由 fork(...) 返回的 Subtask 的变量类型定义为 Supplier<String> 而不是 Subtask<String>(除非当然抉择应用 var)。如果敞开策略自身解决子工作后果(如在 ShutdownOnSuccess 的状况下),则应完全避免应用由 fork(...) 返回的 Subtask 对象,并将 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);
       }
   };
}

11.3 自定义敞开策略

StructuredTaskScope 能够被扩大,并且能够笼罩其受爱护的 handleComplete(...) 办法,以实现除 ShutdownOnSuccessShutdownOnFailure 之外的其余策略。子类能够,例如:

  • 收集胜利实现的子工作的后果,并疏忽失败的子工作,
  • 在子工作失败时收集异样,或者
  • 在呈现某种条件时调用 shutdown() 办法以敞开并导致 join() 办法唤醒。

当一个子工作实现时,即便在调用 shutdown() 之后,它也会作为一个 Subtask 报告给 handleComplete(...) 办法:

public sealed interface Subtask<T> extends Supplier<T> {enum State { SUCCESS, FAILED, UNAVAILABLE}

    State state();
    Callable<? extends T> task();
    T get();
    Throwable exception();}

当子工作在 SUCCESS 状态或 FAILED 状态下实现时,handleComplete(...) 办法将被调用。如果子工作处于 SUCCESS 状态,能够调用 get() 办法,如果子工作处于 FAILED 状态,则能够调用 exception() 办法。在其余状况下调用 get()exception() 会引发 IllegalStateException 异样。UNAVAILABLE 状态示意以下状况之一:(1)子工作被 fork 但尚未实现;(2)子工作在敞开后实现,或者(3)子工作在敞开后被 fork,因而尚未启动。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;
    }

    // 返回从胜利实现的子工作获取的后果流
    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 中 fork 子工作以解决传入连贯:

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 {
            // 如果产生谬误或被中断,咱们进行承受连贯
            scope.shutdown();  // 敞开所有流动连贯
            scope.join();}
    }
}

从并发的角度来看,这种状况与申请的方向不同,但在持续时间和工作数量方面是不同的,因为子工作是依据内部事件动静 fork 的。

所有解决连贯的子工作都在作用域内创立,因而在线程转储中很容易看到它们在一个作用域的所有者的子线程。作用域的所有者也很容易被当作一个单元敞开整个服务。

可察看性

咱们扩大了由 JEP 444 增加的新的 JSON 线程转储格局,以显示 StructuredTaskScope 将线程分组成层次结构:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

每个作用域的 JSON 对象蕴含一个线程数组,这些线程在作用域中被 fork,并附带它们的堆栈跟踪。作用域的所有者线程通常会在 join() 办法中被阻塞,期待子工作实现;线程转储能够通过显示由结构化并发所施加的树状层次结构,轻松地查看子工作的线程正在做什么。作用域的 JSON 对象还具备对其父级的援用,以便能够从转储中从新构建程序的构造。

com.sun.management.HotSpotDiagnosticsMXBean API 也能够用来生成这样的线程转储,能够通过平台的 MBeanServer 和本地或近程的 JMX 工具间接或间接地应用它。

为什么 fork(...) 没有返回 Future

StructuredTaskScope API 处于孵化状态时,fork(...) 办法返回了 Future。这使得 fork(...) 更像是现有的 ExecutorService::submit 办法,从而提供了一种相熟的感觉。然而,思考到 StructuredTaskScope 的应用形式与 ExecutorService 齐全不同 — 即以上文形容的结构化形式应用 — 应用 Future 带来的更多困惑远远超过了清晰性。

相熟的 Future 的应用波及调用其 get() 办法,它会阻塞直到后果可用。但在 StructuredTaskScope 的上下文中,以这种形式应用 Future 不仅是不激励的,而且是不切实际的。Structured Future 对象应该只有在 join() 返回之后查问,此时它们已知已实现或勾销,而应应用的办法不是相熟的 get(),而是新引入的 resultNow(),它永远不会阻塞。

一些开发人员想晓得为什么 fork(...) 没有返回更弱小的 CompletableFuture 对象。因为应该只有在已知它们已实现时才应用 fork(...) 返回的 Future,因而 CompletableFuture 不会提供任何益处,因为其高级性能只对未实现的 futures 有用。此外,CompletableFuture 是为异步编程范例设计的,而 StructuredTaskScope 激励阻塞范例。

总之,FutureCompletableFuture 的设计旨在提供在结构化并发中是无害的自由度。

结构化并发是将在不同线程中运行的多个工作视为单个工作单元,而 Future 次要在将多个工作视为独自工作时有用。因而,作用域只应该阻塞一次以期待其子工作的后果,而后集中处理异样。因而,在绝大多数状况下,从 fork(...) 返回的 Future 上惟一应该调用的办法是 resultNow()。这是与 Future 的失常用法的显著变动,而 Subtask::get() 办法的行为与在 API 孵化期间 Future::resultNow() 的行为完全相同。

代替计划

加强 ExecutorService 接口。咱们对该接口进行了原型实现,该接口始终强制执行结构化并限度了哪些线程能够提交工作。然而,咱们发现这在 JDK 和生态系统中的大多数应用状况下都不是结构化的。在齐全不同的概念中重用雷同的 API,会导致混同。例如,将结构化 ExecutorService 实例传递给现有承受此类型的办法,简直必定会在大多数状况下抛出异样。

本文由博客一文多发平台 OpenWrite 公布!

正文完
 0