《Java 并发编程·线程与内存模型》15.1 StructuredTaskScope 的作用域

StructuredTaskScope 把一次请求 fork 出的子任务收进词法作用域:关闭前必须 join,关闭后不留活线程。本节用两路查询聚合实测 open/fork/join/close 的契约、子任务三种状态与超时取消,并对照 ExecutorService 说明任务生命周期为什么要绑在代码块上。

承接的问题

第 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 而已」。差别不在线程,在责任归属:

维度ExecutorServiceStructuredTaskScope
谁等任务结束调用方自己 get / awaitTerminationjoin() 强制等
调用方提前退出任务继续跑,或随 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、失败传播与取消

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收