承接的问题
第 13 章用 CompletableFuture 拼扇出,第 14 章把执行单元换成虚拟线程。两章合起来能跑得很快,但有一个问题一直没解决:谁保证子任务在你离开这段代码之前结束?
ExecutorService 的答案是「没人保证」。submit 返回一个 Future,任务从此和调用方脱钩:调用方抛异常、提前 return、被中断,任务照跑;反过来,任务抛异常,调用方也可能永远不知道。要补上这层关系,你得自己记账——保存所有 Future、在 finally 里取消、等 awaitTermination、把异常从 ExecutionException 里剥出来。代码越写越长,漏一处就是一个泄漏的线程或者一条丢失的错误日志。
StructuredTaskScope(JDK 25 preview,未来版本 API 可能变化)把这个关系做进语言结构:子任务的生命周期不超过 fork 它的那个代码块。
一个作用域长什么样
最小的一段代码只有四个动作:open 开作用域,fork 派生子任务,join 等全部结束,close 收尾。注意 open()、fork()、join()、close() 加上 Joiner 是 JDK 25 的写法;网上不少示例基于更早的 preview 版本,那些「一个类一种策略」的旧形态在 JDK 25 已经被 Joiner 统一取代,照抄编译不过。
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class ScopeBasics {
static String who(String tag) {
Thread t = Thread.currentThread();
return "%-6s virtual=%-5s name=%-5s id=%d".formatted(
tag, t.isVirtual(), t.getName(), t.threadId());
}
static String stock(String isbn) throws InterruptedException {
System.out.println(who("stock"));
Thread.sleep(Duration.ofMillis(300));
return "stock(" + isbn + ")=3";
}
static String quota(String memberId) throws InterruptedException {
System.out.println(who("quota"));
Thread.sleep(Duration.ofMillis(400));
return "quota(" + memberId + ")=5";
}
public static void main(String[] args) throws Exception {
long t0 = System.nanoTime();
try (var scope = StructuredTaskScope.<Object, Void>open(
StructuredTaskScope.Joiner.awaitAll(),
cfg -> cfg.withThreadFactory(Thread.ofVirtual().name("q-", 0).factory()))) {
Subtask<String> s = scope.fork(() -> stock("978-7-111-1"));
Subtask<String> q = scope.fork(() -> quota("M-1024"));
scope.join();
System.out.println("state " + s.state() + " / " + q.state());
System.out.println("merged " + s.get() + " " + q.get());
}
System.out.printf("elapsed %dms%n", (System.nanoTime() - t0) / 1_000_000);
}
}
编译和运行都必须带 --enable-preview,不带会直接失败(15.3 会贴完整报错):
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$JAVA_HOME/bin/javac --release 25 --enable-preview -d out ScopeBasics.java
$JAVA_HOME/bin/java --enable-preview -cp out ScopeBasics
本机输出(Apple M1 Pro,10 核,32 GB,Temurin 25.0.5+8;当时 load average 124,耗时偏大):
stock virtual=true name=q-0 id=26
quota virtual=true name=q-1 id=28
state SUCCESS / SUCCESS
merged stock(978-7-111-1)=3 quota(M-1024)=5
elapsed 435ms
三件事一眼可见:
- 子任务默认跑在虚拟线程上(
virtual=true),不需要你传ThreadFactory,也没有池的概念。 - 线程名来自
withThreadFactory,不是来自withName。本机实测只写withName("reserve")时子任务线程名是空串,withName不落到线程名上。要在jcmd Thread.print里认出是谁的子任务,就得自己给工厂起名字。 - 300ms 与 400ms 两路并行结果是 435ms,串行要 700ms 左右。并行本身不新鲜,新鲜的是这两路一定在 try 块结束前结束。
open 有三个重载,对应三种控制粒度:
| 写法 | 返回 | join() 返回 | 用途 |
|---|---|---|---|
open() | StructuredTaskScope<T, Void> | null | 只关心全部成功,失败就抛 |
open(joiner) | StructuredTaskScope<T, R> | R | 由 Joiner 决定何时结束、返回什么 |
open(joiner, cfg) | 同上 | 同上 | 再加线程工厂、作用域名、超时 |
本机实测 open() 与 open(Joiner.awaitAllSuccessfulOrThrow()) 行为一致:成功时 join() 都返回 null,有子任务失败时都抛 FailedException。差别只在后者能再挂一个 Configuration(线程工厂、超时)。
子任务的生命周期与 close 的契约
fork 返回的既不是 Future 也不是 Thread,而是一个 Subtask。它只有三个方法:state()、get()、exception()。状态只有三个值:
| 状态 | 含义 | get() 行为 |
|---|---|---|
SUCCESS | 正常返回 | 返回结果,可重复调用 |
FAILED | 抛了异常 | 抛 IllegalStateException,原始异常在 exception() |
UNAVAILABLE | 未完成或被取消 | 抛 IllegalStateException |
注意 FAILED 那一行的坑:get() 抛的是 IllegalStateException(消息 Result is unavailable or subtask did not complete successfully),不是子任务原来的异常。想拿到原始异常必须走 exception();而 SUCCESS 的 exception() 反过来会抛 IllegalStateException。这两个方法都只在特定状态下可用,15.2 会给出统一封装。
close() 的契约比想象中严格。JDK 25 要求先 join 再 close,不 join 直接关会抛异常,但取消照样发生:
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class ScopeLifecycle {
static Subtask<String> slowTask(StructuredTaskScope<String, ?> scope) throws Exception {
return scope.fork(() -> {
try {
Thread.sleep(Duration.ofSeconds(5));
return "done";
} catch (InterruptedException e) {
System.out.println(" subtask 被中断");
throw e;
}
});
}
public static void main(String[] args) throws Exception {
System.out.println("A. 不 join 直接 close");
var a = StructuredTaskScope.<String>open();
Subtask<String> sa = slowTask(a);
try {
a.close();
} catch (IllegalStateException e) {
System.out.println(" close threw " + e.getMessage());
}
Thread.sleep(Duration.ofMillis(30));
System.out.println(" state=" + sa.state() + " cancelled=" + a.isCancelled());
System.out.println("B. join 之后再 fork");
try (var b = StructuredTaskScope.<String>open()) {
b.fork(() -> "x");
b.join();
try {
b.fork(() -> "y");
} catch (IllegalStateException e) {
System.out.println(" fork threw " + e.getMessage());
}
}
System.out.println("C. 配置超时");
try (var c = StructuredTaskScope.<String, Void>open(
StructuredTaskScope.Joiner.awaitAll(),
cfg -> cfg.withTimeout(Duration.ofMillis(100)))) {
Subtask<String> sc = slowTask(c);
try {
c.join();
} catch (StructuredTaskScope.TimeoutException e) {
System.out.println(" join threw " + e.getClass().getSimpleName()
+ " cancelled=" + c.isCancelled() + " state=" + sc.state());
}
}
System.out.println("main end");
}
}
本机输出:
A. 不 join 直接 close
subtask 被中断
close threw Owner did not join after forking
state=UNAVAILABLE cancelled=true
B. join 之后再 fork
fork threw Already joined or scope is closed
C. 配置超时
subtask 被中断
join threw TimeoutException cancelled=true state=UNAVAILABLE
main end
三段输出合起来是三条硬规则:
close()会取消所有还活着的子任务,包括不 join 就关的那种情况——先中断,再抛Owner did not join after forking。这是它和Future的根本差别:不存在「作用域关了但线程还在跑」的窗口。被取消的子任务状态是UNAVAILABLE,不是FAILED。join()之后作用域就冻住了,再fork抛Already joined or scope is closed。也就是说子任务集合在 join 之前就确定了,没有「运行中再补一个任务」。- 超时属于配置,不是另一个类。
Configuration.withTimeout到点后join抛StructuredTaskScope$TimeoutException(一个RuntimeException),同时取消全部子任务。旧版 preview 里对应的机制已经不在了,超时统一收进Configuration,不再有单独的超时入口。
把这三条放在一起看,作用域其实定义了一个不可逃逸的区间:从 open 到 close,任何 fork 出去的任务都必须在这里面结束,要么正常、要么失败、要么被取消,没有第四种结局。
与 ExecutorService 的差别
最常见的误解是「虚拟线程池也能做到,换个 API 而已」。差别不在线程,在责任归属:
| 维度 | ExecutorService | StructuredTaskScope |
|---|---|---|
| 谁等任务结束 | 调用方自己 get / awaitTermination | join() 强制等 |
| 调用方提前退出 | 任务继续跑,或随 JVM 退出被静默杀掉 | 作用域关闭时取消 |
| 一失败取消其余 | 手工遍历 cancel(true) | Joiner 决定(15.2) |
| 任务的父子关系 | 无处表达 | fork 树即调用树 |
| 线程来源 | 池,复用 | 每次 fork 一个虚拟线程,无池 |
| 异常去向 | 塞在 Future 里,不 get 就消失 | join 抛出或 Subtask 里可查 |
「调用方提前退出会怎样」值得单独跑一次:
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorContrast {
public static void main(String[] args) throws Exception {
System.out.println("A. close() 会等所有任务");
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
long t0 = System.nanoTime();
pool.submit(() -> { Thread.sleep(Duration.ofMillis(600)); return null; });
pool.submit(() -> { throw new IllegalStateException("mail 503"); });
pool.close();
System.out.printf(" close 返回耗时 %dms%n", (System.nanoTime() - t0) / 1_000_000);
System.out.println("B. 调用方先结束,任务无人等待");
Thread worker = Thread.ofVirtual().start(() -> {
try {
Thread.sleep(Duration.ofMillis(300));
System.out.println(" worker: 我跑完了");
} catch (InterruptedException e) {
System.out.println(" worker: 被中断");
}
});
System.out.println(" main: 我不管了,直接退出");
System.out.println(" worker.isVirtual=" + worker.isVirtual() + " daemon=" + worker.isDaemon());
}
}
本机输出:
A. close() 会等所有任务
close 返回耗时 616ms
B. 调用方先结束,任务无人等待
main: 我不管了,直接退出
worker.isVirtual=true daemon=true
A 段里那个抛 IllegalStateException 的任务,异常一个字都没打出来——没人 get 它的 Future。close() 老老实实等到 600ms 那个任务跑完才返回,既不快也不取消。B 段更直接:虚拟线程是 daemon,主线程一退 JVM 就退出,worker 的最后一句话永远没机会打印。没有异常、没有日志、没有取消回调,只有一条「任务凭空消失」。
作用域把这两种情况都堵死:想提前走,close() 必须取消;想拿到结果,join() 必须成功。代价是它不允许「提交完就走」的用法——这正是下一节要讲的边界。
在 libraryd-conc 里落地
把 14 章的 VirtualLoanService 换成作用域版本。同一个 reserve 要查两处:图书库存和会员配额。两处都是 IO,正好并行。
@Override
public Reservation reserve(String isbn, String memberId) {
try (var scope = StructuredTaskScope.<Object, Void>open(
StructuredTaskScope.Joiner.awaitAll(),
cfg -> cfg.withName("reserve")
.withThreadFactory(Thread.ofVirtual().name("reserve-", 0).factory())
.withTimeout(Duration.ofSeconds(2)))) {
var bookTask = scope.fork(() -> books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn)));
var memberTask = scope.fork(() -> members.findById(memberId)
.orElseThrow(() -> new LoanException(ErrorCode.QUOTA_EXCEEDED, memberId)));
scope.join();
Book book = value(bookTask);
Member member = value(memberTask);
if (member.quota() <= 0) {
throw new LoanException(ErrorCode.QUOTA_EXCEEDED, memberId);
}
if (book.stock() <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
Reservation reservation = new Reservation(
UUID.randomUUID().toString(), isbn, memberId,
Instant.now().plusSeconds(86_400));
loans.save(new Loan(reservation.loanId(), isbn, memberId, reservation.dueAt()));
return reservation;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LoanException(ErrorCode.INTERRUPTED, "reserve 被中断");
} catch (StructuredTaskScope.TimeoutException e) {
throw new LoanException(ErrorCode.CONFLICT, "reserve 超过 2s,已取消全部子任务");
}
}
/** awaitAll 不会替你传播失败:FAILED 的 get() 只抛 ISE,原始异常在 exception() 里。 */
private static <T> T value(Subtask<T> task) {
if (task.state() == Subtask.State.FAILED) {
Throwable cause = task.exception();
if (cause instanceof LoanException loan) {
throw loan;
}
throw new LoanException(ErrorCode.CONFLICT, "子任务失败: " + cause);
}
return task.get();
}
类名沿用第 14 章的 VirtualLoanService 改名而来:StructuredLoanService implements LoanService,构造器注入 BookRepository、LoanRepository、MemberRepository,returnBook 与 14 章一致。用 Demo15 挂上内存版仓储跑:
mvn -q compile -Dmaven.compiler.enablePreview=true
java --enable-preview --source 25 -cp target/classes com.plume.conc.core.Demo15
本机输出(仓储里 findByIsbn 睡 300ms、findById 睡 400ms):
member + 16ms virtual=true name=reserve-1 id=29
catalog + 16ms virtual=true name=reserve-0 id=27
reserve + 462ms loanId=ea2c887b-596a-4b88-981d-57581aa0d573 stock_after=2
catalog + 0ms virtual=false name=main id=3
member + 301ms virtual=false name=main id=3
serial + 705ms 串行两次查询(对照)
catalog + 0ms virtual=true name=reserve-0 id=33
member + 0ms virtual=true name=reserve-1 id=34
missing + 410ms code=BOOK_NOT_FOUND msg=978-7-000-0
- 并行 462ms、串行 705ms,省下的是等待时间,不是 CPU 时间。这个接口的瓶颈是两次 IO 的往返,作用域只是让它们重叠。
- 查不到书时
missing花了 410ms 才报错——awaitAll不会因为一个子任务失败就取消另一个,它老老实实等会员查询跑完。这是 15.2 要修的语义。
什么时候不要用
作用域适合「一次请求内、结果必须全部拿到、生命周期明显短于调用方」的场景,比如查两处数据、并行校验、扇出通知。下面几种情况应该继续用别的模型:
- 任务要比调用方活得久:发一条消息、写一条审计日志、异步预热缓存。这类「发射后不管」的语义和「关闭前必须结束」直接冲突,硬套作用域只会把任务拖回请求线程里等。
- 执行单元要复用:作用域每次
open都是新的一批虚拟线程,没有队列、没有并发上限。要限流就得自己加Semaphore(14.3 的做法),要排队就得回到线程池。 - 需要定时或周期执行:作用域只运行一次,没有调度能力,用
ScheduledThreadPoolExecutor。 - 任务集合在运行中增长:
join之后不能再fork,动态补充任务只能靠别的结构。
常见错误与边界
一、忘了 join 直接 close。 编译器不拦,运行时抛 Owner did not join after forking。虽然子任务会被取消,但 try 块会带着异常退出,你原本的返回值丢了。正确写法永远是 fork → join → 关作用域。
二、把 Subtask 当成 Future 到处传。 Subtask 属于它所在的作用域,出了作用域再 get() 只能拿到已完成的结果;被取消的那种会抛 IllegalStateException。15.3 会实测这条边界。
三、用 get() 读失败结果。 前面踩过的坑:get() 在 FAILED 状态下抛的是 IllegalStateException,业务异常藏在 exception() 里。不处理就会把「库存不足」变成「不知道什么错了」。
四、以为 withName 会改线程名。 本机实测不会,子任务线程名是空串。要诊断就配 withThreadFactory,两者不是一回事。
五、不设超时。 下游卡住时 join 会一直等,作用域再结构化也救不了。凡是对外部系统发起的 fork,都应该在 Configuration 里给 withTimeout。
六、把 preview 当稳定 API 用。 JDK 25 上它仍然是 preview,编译和运行都要 --enable-preview,class 文件版本是 69.65535,换一个 JDK 就跑不起来。生产代码要等它转正,或者在构建脚本里把 preview 参数当成显式依赖管理起来。
小结
StructuredTaskScope在 JDK 25 是 preview API,编译和运行都要--enable-preview,未来版本 API 可能变化。- 四个动作:
open/fork/join/close;open的三个重载决定返回值和配置粒度,open()实测等价于open(Joiner.awaitAllSuccessfulOrThrow())。 - 子任务默认跑在虚拟线程上(实测
virtual=true),线程名只能由withThreadFactory给出,withName不设置线程名。 close()保证取消所有存活子任务;不join就close会抛Owner did not join after forking,join之后再fork抛Already joined or scope is closed。- 超时是
Configuration的一项,到点抛StructuredTaskScope$TimeoutException并取消全部子任务。 - 与
ExecutorService的根本差别是责任归属:执行器只接受提交,作用域对「任务什么时候必须结束」负责。
第 15.2 节接着处理上面那个 410ms 的问题:awaitAll 不传播失败,我们需要能让「一个下游挂了,其余立刻停」的 Joiner。
阅读导航:上一节 14.3 虚拟线程下的池、ThreadLocal 与限流 | 下一节 15.2 Joiner、失败传播与取消
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。