1.1 结构化编程的起源和理论基础
结构化编程的起源是 Dijkstra 写了一封信给 CACM 的编辑 Go To Statement Considered Harmful。
Dijkstra 是 1972 年图灵奖的获得者,提出了著名的最短路径算法(Dijkstra 算法)和银行家算法(避免死锁问题),并解决了【哲学家就餐问题】。
编程是一项难度很大的活动。一段程序无论复杂与否,都包含了很多的细节信息。如果没有工具的帮助,这些细节的信息是远远超过一个程序员的认知能力范围的。而在一段程序中,哪怕仅仅是一个小细节的错误, 也会造成整个程序出错。
Dijkstra 提出的解决方案是采用数学推导方法。即:程序员可以用代码将一些己证明可用的结构串联起来,只要自行证明这些额外代码是正确的,就可以推导出整个程序的正确性。
Dijkstra 在研究过程中发现了一个问题: goto 语句的某些用法会导致某个模块无法被递归拆分成更小的、可证明的单元,这会导致无法采用分解法来将大型问题进一步拆分成更小的、可证明的部分。
goto 语句的其他用法虽然不会导致这种问题,但是 Dijkstra 意识到它们的实际效果其实和更简单的分支结构 if-then-else 以及循环结构 do-while 是一致的 。 如果代码中只采用了这两类控制结构,则一定可以将程序分解成更小的、可证明的单元。
1.2 结构化编程(Structured Concurrency)简析
结构化编程简单来说就是我们的程序是有结构的,伪代码如下:
// statement1
doSomethingPre();
if (case1) {
doSomethingCase1();
} else if (case2) {
doSomethingCase2();
} else {
doSomethingCase3();
}
// statement2
doSomethingAfter();
从上面的伪代码中我们可以清晰的看出, doSomethingPre()
和 doSomethingAfter()
是一定会执行的(假设没有抛出异常),至于中间的条件控制块,当我们不关注其中的具体细节时,可以将其视作一个整体(我们甚至可以将其重构为一个函数/方法 — doSomethingByCases()),我们看到这段代码的第一印象就是:【哦,这是由三条语句顺序组成的代码块】。
然而在非结构化编程中某些函数的调用就不会具备这个特性,示例伪代码如下:
print("OPPO is ")
func(true)
print("not")
label HERE
print("the best cellphone")
def func(arg):
if arg :
goto HERE
当我们不知道 func 的具体内容前,这段代码运行的结果是:【OPPO is not the best cellphone】,然而实际的运行的结果却是:【OPPO is the best cellphone】。这就是非结构化编程,无法清晰的看出这段代码的结构,需要每行详细阅读,才能了解具体的逻辑。
一张更经典的图(摘自 Notes on structured concurrency, or: Go statement considered harmful)
Fig. 1-1 goto 实际的执行流
2 结构化并发简介
并发编程对于程序员来说是一个无法绕过的话题,本节将简单介绍结构化并发的起源和思想。
2.1 非结构化并发
这里我们使用 jdk19 官方文档的一个例程来描述非结构化并发带来的问题。
private ExecutorService esvc = Executors.newCachedThreadPool();
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);
}
Fig. 2-1 例程逻辑示意图
例程中定义了一个 handle() 方法,执行子任务 findUser() 和 fetchOrder(),并通过阻塞调用 Future.get() 方法获取两个子任务的结果(等待子任务执行完成并返回结果)。
惯性思维里,我们一般认为任何一个子任务失败都会导致整个任务失败。然而在上面的例程中,由于两个子任务是并发执行的,因此每个子任务是否成功执行是相互独立的,而且父任务的失败也不会影响子任务的执行(这里的成功执行可以简单理解为不抛出异常)。我们可以简单分析一下上述例程在什么场景下发生异常,以及异常产生的问题:
- findUser() 方法抛出异常,在执行到 user.get() 方法时会抛出异常,进而导致 handle() 抛出异常;而 fetchByOrder() 任务将继续执行导致线程泄漏。
- handle() 在提交任务后失败,并不会中断不能子任务的执行,两个子任务将继续执行,导致线程泄漏。
- 当 findUser() 执行的时间很长,而 fetchOrder() 这时候已经失败了,handle() 方法不会立即抛出异常结束整个任务,直到 user.get() 执行完成后,执行 order.get() 时才拿到子任务的异常,进而结束整个任务。
线程泄漏轻则浪费计算资源,重则影响其他任务的执行(例如长时间占用数据库连接)。
我们的程序看起来是具有父子任务的逻辑关系,然而运行时并不存在。同时,这些问题排查起来很复杂,thread dump 之类的工具在调用堆栈上并不会显示 handle()、findUser() 和 fetchOrder() 之间的父子逻辑关系。
为了避免上面这种情况,我们执行 handle() 方法时,一般会用 try-catch-finally 的模式包围 handle() 方法,在 catch 中调用 Future.cancel() 方法结束未完成的子任务。除此之外,我们需要改造子任务方法,通过共享标记位、CountDownLatch 等线程同步策略避免 bug,这导致我们的代码看起来非常的复杂,可读性、可维护性大大降低。
2.2 结构化并发
维基百科对于结构化并发的定义如下:
Structured concurrency is a programming paradigm aimed at improving the clarity, quality, and development time of a computer program by using a structured approach to concurrent programming.
结构化并发是一种编程范式,旨在通过使用一种结构化的并发编程方式,提高程序的清晰度、质量,并降低开发时间。
2016年,ZeroMQ 的作者 Martin Sústrik 在 Structured Concurrency 中第一次形提出结构化并发这个概念。2018 年 Nathaniel J. Smith (njs) 在 Python 中实现了这一范式,并在 Notes on structured concurrency, or: Go statement considered harmful一文中进一步阐述了 Structured Concurrency。同时期,Roman Elizarov 也提出了 Structured concurrency,并在 Kotlin 中实现了大家熟知的kotlinx.coroutine。2019年,OpenJDK loom project 也开始引入 structured concurrency,作为其轻量级线程和协程的一部分。
njs 这篇经典文章中,将 go func() (go 语言中启动协程的语法)类比 goto,讲述了 非结构化并发的问题,一张经典的图:
Fig. 2-2 go 是另外一种形式的 goto
结构化并发主要有以下两个核心点:
- 并发代码块有明确的入口和出口
- 并发代码块执行过程中产生的子任务在出口之前结束(无论成功/失败/超时)
还是以 Fig 2.1 为例,handle() 创建了两个子任务:findUser() 和 fetchOrder()。handle() 就是一个控制流结构,入口是 handle() 被调用,出口是 handle() 调用结束,子任务 findUser() 和 fetchOrder() 需要在 handle() 结束前完成;当 handle() 完成时,其涉及的资源都要被释放掉,外部调用者无需关心其内部的执行逻辑,也无需担心程序是否失控,只需了解该方法会返回什么样的结果(包括成功、失败或是超时)。这就是所谓的结构化。
3 Java 中的结构化并发
jdk 19 中的结构化并发是一个孵化器 API,主要通过类 jdk.incubator.concurrent.StructuredTaskScope 实现。StructuredTaskScope 构造的对象到底是什么呢?顾名思义,StructuredTaskScope 定义了一个结构化并发作用域(一个具有明确入口和出口的并发代码块),支持将任务分割成几个并发子任务,在各自的线程中执行,子任务将在主任务继续执行前完成。
jdk.incubator.concurrent.StructuredTaskScope 包中的注释原文:
A basic API for structured concurrency. StructuredTaskScope supports cases where a task splits into several concurrent subtasks, to be executed in their own threads, and where the subtasks must complete before the main task continues. A StructuredTaskScope can be used to ensure that the lifetime of a concurrent operation is confined by a syntax block, just like that of a sequential operation in structured programming.
3.1 调试环境
由于 StructuredTaskScope 是一个孵化器 API,因此我们需要添加一些 jvm 参数(本文依旧使用 vscode + java 插件作为 IDE)。
pom 文件
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<compilerArgs>
<arg>--enable-preview</arg>
<arg>--add-modules jdk.incubator.concurrent</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
launch.json
{
"type": "java",
"name": "Launch ${your-main-class-name}",
"request": "launch",
"mainClass": "${your-main-class-name}",
"vmArgs": "--add-modules jdk.incubator.concurrent",
"projectName": "loom-benchmark"
}
3.2 简单使用
第二节中的例程用 StructuredTaskScope 重写一下,并以此为例介绍一下 StructuredTaskScope 使用方法:
Response handle() throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 1. 创建并提交子任务
Future<String> user = scope.fork(() -> findUser());
Future<Integer> order = scope.fork(() -> fetchOrder());
// 2. 等待子任务结束
scope.join();
// 3. 处理结果或异常
scope.throwIfFailed();
return new Response(user.resultNow(), order.resultNow());
} // 4. 关闭 StructuredTaskScope 对象
}
Future.resultNow() 是 jdk 19 新增的 default 方法,立即返回异步结果
default V resultNow() {
if (!isDone())
throw new IllegalStateException("Task has not completed");
boolean interrupted = false;
try {
while (true) {
try {
return get();
} catch (InterruptedException e) {
interrupted = true;
} catch (ExecutionException e) {
throw new IllegalStateException("Task completed with exception");
} catch (CancellationException e) {
throw new IllegalStateException("Task was cancelled");
}
}
} finally {
if (interrupted) Thread.currentThread().interrupt();
}
}
可以看到使用 StructuredTaskScope 进行并发编程,主要分为四步:
- 创建并提交子任务,fork(Callable task) 方法将创建一个虚拟线程并执行任务,任务返回一个 Future 对象。
- 等待子任务结束,join() 方法是一个阻塞调用,将等待所有子任务执行完毕;当然,StructuredTaskScope 也提供有时长限制的方法 joinUtil(Instant deadline)。
- 处理结果/异常,根据不同需求来处理结果或是异常。
- 结束 StructuredTaskScope 对象,这意味着我们将结束整个任务的调用;由于我们这里使用了 try-with-resource 这种编程模式,所以不需要手动关闭 StructuredTaskScope 对象;当然我们也可以手动结束,调用 shutdown() 方法,或者 close() 方法(AutoCloseable 接口的抽象方法)。
这个编程范式足够结构化,且高度抽象,基本能满足大部分需求。下面我们挑其中几个关键点详细介绍一下:
shutdown 与 close
同样是结束 scope 对象的生命周期,shutdown 与 close 实际上是不一样的,shutdown 可以翻译为中止(使 XX 中途停止),close 翻译为关闭,详细对比如下
- shutdown() 方法将会中止 scope 中所有的子任务,同时 scope 将不会 start 那些还未执行的任务;shutdown() 只能被主线程或是子任务调用;shutdown() 方法适用于当某个子任务执行完成后,其他任务无需再继续执行的场景。
- close() 方法首先将结束 scope(通过调用 shutdown 的具体实现 implShutdown()),然后等待所有的子任务都执行完毕;close() 方法只能被主线程调用。
boolean implShutdown() 将中止没有中止的 scope;中止成功返回 true,已经中止的 scope 将返回 false。
scope 的生命周期为:OPEN -> SHUTDOWN -> CLOSED
StructuredTaskScope 提供了两种默认的结果/异常处理策略,其 API 为 StructuredTaskScope.ShutdownOnFailure/StructuredTaskScope.ShutdownOnSuccess。
ShutdownOnFailure 捕获到第一个异常时将中止整个 scope,否则将等待所有的子任务执行完成(类似 ExecutorSevice.invokeAll 方法,当且仅当子任务都成功的情况)。提供方法 throwIfFailed(),当任意子任务失败时抛出 ExecutionException;当没有任务执行失败,但是有些任务已经被取消,则抛出 CancellationException,前者可以认为是任务真的执行失败的情况(join),后者则可以认为是某个任务超时的情况(joinUtil)。也提供异常包装的方法 throwIfFailed(Function<Throwable, ? extends X> esf)。
public void throwIfFailed() throws ExecutionException {
Future<Object> f = future;
if (f != null) {
if (f.state() == Future.State.FAILED) {
throw new ExecutionException(f.exceptionNow());
} else {
throw new CancellationException();
}
}
}
ShutdownOnSuccess 捕获到第一个成功执行的子任务时中止整个 scope(类似 ExecutorSevice.invokeAny 方法)。提供方法 result() 和异常包装方法 result(Function<Throwable, ? extends X> esf)。
public T result() throws ExecutionException {
Future<T> f = future;
if (f == null) {
throw new IllegalStateException("No completed subtasks");
}
return switch (f.state()) {
case SUCCESS -> f.resultNow();
case FAILED -> throw new ExecutionException(f.exceptionNow());
case CANCELLED -> throw new CancellationException();
default -> throw new InternalError("Unexpected state: " + f);
};
}
调用 result 方法就不用定义变量接收每个子任务 scope.fork 的结果,在 scope 中甚至没有一个技术对象,可以然我们更专注于业务逻辑。
String handle() throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess()) {
scope.fork(() -> findUserFrom(src1));
scope.fork(() -> findUserFrom(src2));
scope.fork(() -> findUserFrom(src3));
scope.join();
return scope.result();
}
}
3.3 进阶使用
本节将介绍如何创建自定义的结构化并发域。
假设我们有一个商品比价的需求,需要到各个购物网站上抓取商品价格,并返回一个最便宜的商品详情。
public static void main(String[] args) throws InterruptedException {
try {
var cheapestGoods = getCheapestGoods();
System.out.println(cheapestGoods);
} catch (RuntimeException e) {
e.printStackTrace();
}
}
public static Goods getGoodsFromWeb(String applier) {
Goods goods = null;
switch (applier) {
case "taobao" -> goods = new Goods("taobao.com", 100);
case "jd" -> goods = new Goods("jd.com", 101);
case "pdd" -> goods = new Goods("pdd.com", 99);
default -> throw new RuntimeException("no such applier");
}
return goods;
}
public static Goods getCheapestGoods() throws InterruptedException {
try (var scope = new GoodsScope()) {
scope.fork(() -> getGoodsFromWeb("taobao"));
scope.fork(() -> getGoodsFromWeb("jd"));
scope.fork(() -> getGoodsFromWeb("pdd"));
scope.fork(() -> getGoodsFromWeb("xxx"));
scope.join();
return scope.cheapestGoods();
}
}
public static class GoodsScope extends StructuredTaskScope<Goods> {
private final BlockingQueue<Goods> goodsList = new ArrayBlockingQueue<>(10);
private final BlockingQueue<Throwable> exceptions = new ArrayBlockingQueue<>(10);
@Override
protected void handleComplete(Future<Goods> future) {
switch (future.state()) {
case RUNNING -> throw new IllegalArgumentException("Task is not completed");
case SUCCESS -> goodsList.add(future.resultNow());
case FAILED -> exceptions.add(future.exceptionNow());
case CANCELLED -> { }
}
}
Goods cheapestGoods() {
return goodsList.stream().min(Comparator.comparing(Goods::getPrice)).orElseThrow(() -> new RuntimeException("no goods"));
}
}
public static class Goods {
private String url;
private Integer price;
// custructor and getter/setter
}