结构化并发功能[(JEP-428])旨在通过将运行在不同线程(从同一个父线程分叉出来的)中的多个任务视为一个工作单元来简化Java并发程序。将所有这些子线程视为一个单元,将有助于将所有线程作为一个单元进行管理;因此,取消和错误处理可以更可靠地进行。
错误处理和任务取消的可靠性将消除常见的风险,如线程泄漏和取消延迟。
1.传统并发性的问题
1.1.线程泄密
在传统的[多线程编程]中(非结构化并发),如果一个应用程序需要执行一个复杂的任务,它会将程序分成多个较小的独立的子任务单元。然后,应用程序将所有的任务提交给*[ThreadPoolExecutor],一般有一个[ExecutorService]*来运行所有任务和子任务。
在这样的编程模型中,所有的子任务都是并发运行的,所以每个任务都可以独立地成功或失败。如果其中一个任务失败,API中没有支持取消所有相关的子任务。应用程序对子任务没有控制权,必须等待所有的子任务完成后再返回父任务的结果。这种等待是对资源的浪费,并降低了应用程序的性能。
例如,如果一个任务要获取一个账户的详细信息,并且需要从多个来源获取详细信息,如账户详细信息、链接账户、用户的人口数据等,那么并发请求处理的伪代码将是这样的。
Response fetch(Long id) throws ExecutionException, InterruptedException {
Future<AccountDetails> accountDetailsFuture = es.submit(() -> getAccountDetails(id));
Future<LinkedAccounts> linkedAccountsFuture = es.submit(() -> fetchLinkedAccounts(id));
Future<DemographicData> userDetailsFuture = es.submit(() -> fetchUserDetails(id));
AccountDetails accountDetails = accountDetailsFuture.get();
LinkedAccounts linkedAccounts = linkedAccountsFuture.get();
DemographicData userDetails = userDetailsFuture.get();
return new Response(accountDetails, linkedAccounts, userDetails);
}
在上面的例子中,所有三个线程都独立执行。
- 假设在获取关联账户时出现了错误,那么*fetch()*将返回一个错误响应。但另外两个线程将继续在后台运行。这就是一个线程泄漏的案例。
- 同样,如果用户取消了来自前端的请求,并且*fetch()*被中断,所有三个线程将继续在后台运行。
虽然[取消子任务]在程序上是可能的,但没有直接的方法,而且有出错的可能。
1.2.不相关的线程转储和诊断
在前面的例子中,如果fetch()的API出现错误,那么就很难[分析线程转储],因为这些线程是在3个不同的线程中运行。在3个线程的信息之间建立关系是非常困难的,因为在API层面上这些线程之间没有关系。
当调用堆栈定义了任务-子任务的层次结构时,例如在顺序方法执行中,我们得到了父子关系,这种关系流入了错误传播。
理想情况下,任务关系应该反映在API层面,以控制子线程的执行,必要时进行调试。这将允许子线程只向它的父线程--拥有所有子任务的唯一任务--报告结果或异常,然后,它可以隐含地取消其余子任务。
2.1.基本概念
在结构化的多线程代码中,如果一个任务分裂成并发的子任务,它们都会返回到同一个地方,即任务的代码块。这样,一个并发的子任务的生命周期就被限制在该语法块内。
在这种方法中,子任务代表一个等待其结果并监视其失败的任务工作。在运行时,结构化并发建立了一个树形的任务层次,同级别的子任务由同一个父任务拥有。这个树形结构可以被看作是一个具有多个方法调用的单线程的调用栈的并发对应物。
2.2.用StructuredTaskScope实现
StructuredTaskScope 是结构化并发的基本API,它支持一个任务分成几个并发的子任务的情况,在它们自己的线程中执行。
它强制要求子任务必须在主任务继续之前完成。它确保一个并发操作的生命周期被一个语法块所限制。
让我们用StructuredTaskScopeAPI重写前面的例子。注意,fork() 方法启动一个[虚拟线程]来执行任务,join() 方法等待所有线程完成,而close() 方法关闭任务范围。
StructuredTaskScope类实现了*[AutoCloseable]接口,所以如果我们使用[try-with-resources]*块,那么在父线程完成执行后,close() 将被自动调用。
try (var scope = new StructuredTaskScope.ShutdownOnFailure()()) {
Future<AccountDetails> accountDetailsFuture = scope.fork(() -> getAccountDetails(id));
Future<LinkedAccounts> linkedAccountsFuture = scope.fork(() -> fetchLinkedAccounts(id));
Future<DemographicData> userDetailsFuture = scope.fork(() -> fetchUserDetails(id));
scope.join(); // Join all subtasks
scope.throwIfFailed(e -> new WebApplicationException(e));
//The subtasks have completed by now so process the result
return new Response(accountDetailsFuture.resultNow(),
linkedAccountsFuture.resultNow(),
userDetailsFuture.resultNow());
}
这个解决方案解决了第一节中提到的非结构化并发的所有问题。
3.结构化并发和虚拟线程
虚拟线程是由JVM管理的轻量级线程,用于编写高吞吐量的并发应用程序。由于虚拟线程与传统的操作系统线程相比价格低廉,结构化并发利用它们来分叉所有新线程。
除了数量多之外,虚拟线程还足够便宜,可以代表任何并发的行为单元,甚至涉及I/O的行为。在幕后,任务与子任务的关系是通过将每个虚拟线程与它唯一的所有者联系起来来维护的,所以它知道自己的层次,类似于调用栈中的一个帧知道它唯一的调用者一样。
4.总结
当与虚拟线程相结合时,结构化并发有望为Java提供期待已久且急需的功能,这些功能在其他编程语言中已经存在(例如Go中的goroutines和Erlang中的进程)。它将有助于编写更复杂的并发应用程序,具有出色的可靠性和更少的线程泄漏。
这样的应用程序将更容易在发生错误时进行调试和剖析。
学习愉快!!