《Java 高级编程》6.2 结构化并发:StructuredTaskScope 与作用域取消

三个查询扇出,一个 50ms 就失败,另一个却跑满 400ms——invokeAll 不会取消兄弟任务。本节用 JDK 25 预览版的 StructuredTaskScope 把 DashboardService 的三路聚合改成「全成功或全取消」,并实测失败 44ms 返回、超时 106ms 中止。

本节目标:让「一个请求内的多个并发查询」拥有明确的父子关系、失败传播和超时边界。
适用版本:JDK 25(Temurin 25.0.5);StructuredTaskScope 仍是预览 API,编译与运行都需要 --enable-preview。

承接的问题

6.1 的最后留了一个尾巴。用 ExecutorService 扇出三个查询,成功路径很好,失败路径是这样:

failed after 408ms: IllegalStateException, siblingCancelled=false

一个任务 50ms 就抛了异常,另一个任务要跑 400ms,invokeAll 仍然等到 400ms 才返回,慢的那个没有被取消,siblingCancelled 是 false。放到 libraryd 的会员面板上,这意味着:

  • 会员不存在时,页面要等 400ms 才报错,而不是 50ms。
  • 请求线程被占住 400ms,虚拟线程再便宜也是浪费。
  • 如果慢任务里有数据库查询,连接也被多占 350ms。

要修,得自己写一套「一个失败就取消其他」的逻辑:保存 Future 列表、捕获第一个异常、遍历 cancel(true)、再处理 CancellationException。这套逻辑容易写漏,尤其是嵌套扇出的时候——孙子任务谁来取消?

结构化并发把这件事变成语言结构:任务必须在 try-with-resources 的作用域里 fork,作用域结束前所有子任务必须结束;一个子任务失败,作用域可以取消其余子任务。

API 形态与编译开关

JDK 25 的 StructuredTaskScope 是 JEP 505,第五次预览。形态和 JDK 21 预览期完全不同:没有 ShutdownOnFailure 子类,改成「打开作用域时传一个 Joiner」。

try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow())) {

    var a = scope.fork(() -> loadActiveLoans(memberId));
    var b = scope.fork(() -> loadOverdueCount(memberId));
    var c = scope.fork(() -> loadFineCents(memberId));

    scope.join();
    return combine(a.get(), b.get(), c.get());
}

三个关键对象:

  • StructuredTaskScope.open(Joiner) 创建作用域。open() 不是构造器,作用域是 AutoCloseable 的。
  • fork(Callable) 返回 Subtask<T>,任务立刻开始执行。Subtask 实现了 Supplier<T>,get() 取结果。
  • join() 等待所有子任务结束(或 joiner 决定提前结束),返回 joiner 的结果。

不写 --enable-preview 直接编译,会得到:

错误: StructuredTaskScope 是预览 API,默认情况下处于禁用状态。
  (请使用 --enable-preview 以启用预览 API)

编译和运行都要加开关,而且 --release 25 和 --enable-preview 必须同时出现:

javac --release 25 --enable-preview -d out DashboardService.java
java --enable-preview -cp out com.plume.library.app.Main

这个约束会传导到 CI、Docker 镜像、IDE 配置。如果团队不能接受「运行参数里必须带一个预览开关」,就不该用这套 API——卷二的 9.2 因此选了 CompletableFuture。本卷把两种都写出来,是为了让取舍有依据。

失败传播与取消

Joiner.allSuccessfulOrThrow() 的语义是:所有子任务都成功才返回,任何一个失败就取消其余子任务,并抛 FailedException。

用一个会失败的任务和一个 300ms 的慢任务验证:

try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow(),
        cfg -> cfg.withName("dashboard-" + memberId))) {

    var loans = scope.fork(() -> List.of(new Loan(1, "978000000001", ...)));
    var overdue = scope.fork(() -> {
        if (failOverdue) {
            Thread.sleep(50);
            throw new IllegalStateException("overdue query failed");
        }
        return 2L;
    });
    var fines = scope.fork(() -> {
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            siblingCancelled.set(true);
            throw e;
        }
        return 200L;
    });

    scope.join();
    return new MemberDashboard(memberId, loans.get(), overdue.get(), fines.get());
}

本机输出:

-- happy path
MemberDashboard[memberId=M-1, activeLoans=[Loan[id=1, ...]], overdueCount=2, totalFineCents=200]
-- one fork fails
join threw java.util.concurrent.StructuredTaskScope$FailedException: java.lang.IllegalStateException: overdue query failed
siblingCancelled=true
  • 失败被包装成 FailedException,原始异常在 getCause() 里。
  • 慢任务收到中断,siblingCancelled=true。整个 load 在 44ms 左右返回(失败任务睡 50ms 后抛),而不是等 300ms。
  • 取消是通过 InterruptException 送达的,所以任务里的阻塞调用必须响应中断。如果任务在 synchronized 里做不可中断的等待,取消会被延迟——这也是为什么 libraryd 的查询都带 statement_timeout。

FailedException 和 TimeoutException 都是 StructuredTaskScope 的嵌套类,都是 RuntimeException,可以直接 catch。

超时:作用域级别的 deadline

比「一个任务失败」更常见的是「一个任务太慢」。open 的第二个参数是 Configuration,可以设名字、线程工厂和超时:

try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow(),
        cfg -> cfg.withName("dashboard-timeout").withTimeout(Duration.ofMillis(100)))) {

    var slow = scope.fork(() -> {
        Thread.sleep(500);
        return "slow";
    });
    scope.join();
    System.out.println("result=" + slow.get());
}

本机输出:

-- timeout 100ms, slow fork 500ms
timed out after 101ms: java.util.concurrent.StructuredTaskScope$TimeoutException: null

101ms 就抛了 TimeoutException,慢任务被取消。注意超时是整个作用域的,不是每个任务各自 100ms:三个任务并发跑,总预算 100ms。

超时值和下游的超时要对齐。libraryd 的 statement_timeout 设 2s,HTTP 层设 3s,那 dashboard 的作用域超时应该设在 2.5s 左右:比 SQL 短,比 HTTP 长没有意义;太短会把本来能成功的查询砍掉。

Joiner 的几种语义

Joiner 决定了「什么时候算结束」和「结果怎么给」:

Joiner结束条件join() 返回
allSuccessfulOrThrow()全部成功,或任一失败Stream<Subtask<T>>
anySuccessfulResultOrThrow()任一成功那个成功的结果 T
awaitAllSuccessfulOrThrow()全部结束,任一失败则抛Void
awaitAll()全部结束,忽略失败Void
allUntil(predicate)谓词满足Stream<Subtask<T>>

libraryd 的面板用第一种:三个数字必须来自同一时刻的成功查询,缺一个就没法展示。anySuccessfulResultOrThrow() 适合「多副本取最快」——比如同一个 ISBN 的馆藏同时查主库和只读副本,谁先回用谁。awaitAll() 适合「发通知,失败无所谓」的旁路任务。

allUntil 用得少但有意思:可以传入「已经拿到 2 个成功结果就停」的谓词,用来做「至少 N 个可用」的降级查询。

在 libraryd 里落地

DashboardService 把三路查询放进一个作用域,并把异常翻译成领域错误:

package com.plume.library.web;

import com.plume.library.domain.ErrorCode;
import com.plume.library.domain.LibraryException;
import com.plume.library.domain.LoanRepository;
import com.plume.library.domain.MemberDashboard;
import java.time.Duration;
import java.time.LocalDate;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.FailedException;
import java.util.concurrent.StructuredTaskScope.TimeoutException;

public final class DashboardService {

    private final LoanRepository loans;
    private final Duration timeout;

    public DashboardService(LoanRepository loans, Duration timeout) {
        this.loans = loans;
        this.timeout = timeout;
    }

    public MemberDashboard load(String memberId, LocalDate today) {
        try (var scope = StructuredTaskScope.open(
                StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow(),
                cfg -> cfg.withName("dashboard-" + memberId).withTimeout(timeout))) {

            var active = scope.fork(() -> loans.findActiveByMember(memberId));
            var overdue = scope.fork(() -> loans.countOverdue(memberId, today));
            var fines = scope.fork(() -> loans.sumFineCents(memberId, today));

            scope.join();
            return new MemberDashboard(memberId, active.get(), overdue.get(), fines.get());
        } catch (TimeoutException e) {
            throw new LibraryException(ErrorCode.DB_UNAVAILABLE,
                    "dashboard 聚合超过 " + timeout.toMillis() + "ms", e);
        } catch (FailedException e) {
            Throwable cause = e.getCause();
            if (cause instanceof LibraryException library) {
                throw library;
            }
            throw new LibraryException(ErrorCode.INTERNAL, "dashboard 聚合失败", cause);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new LibraryException(ErrorCode.INTERNAL, "dashboard 聚合被中断", e);
        }
    }
}

四个设计点:

  • 泛型用 Joiner.<Object>。三个子任务返回 List<Loan>、Long、Long,共同上界是 Object。如果强行让它们同类型,就得包一层没有意义的容器。
  • 超时放在 Configuration,而不是在任务里各自 Thread.sleep 计时。作用域超时会取消所有子任务,不需要手写看门狗。
  • FailedException 的 cause 如果是 LibraryException,原样抛出。MEMBER_NOT_FOUND 这类领域错误不应该被包装成 INTERNAL,否则 Web 层会返回 500 而不是 404。
  • InterruptedException 单独处理并恢复中断标志。作用域被外部中断时,不能吞掉中断状态。

MemberDashboard 是不可变 record,构造时 List.copyOf:

package com.plume.library.domain;

import java.util.List;

public record MemberDashboard(String memberId, List<Loan> activeLoans,
                              long overdueCount, long totalFineCents) {

    public MemberDashboard {
        activeLoans = List.copyOf(activeLoans);
        if (overdueCount < 0 || totalFineCents < 0) {
            throw new IllegalArgumentException("overdueCount 与 totalFineCents 不能为负");
        }
    }
}

用假仓储跑三条路径,本机输出:

-- happy path
elapsedMs=64 MemberDashboard[memberId=M-1, activeLoans=[...], overdueCount=1, totalFineCents=300]
-- overdue query fails
elapsedMs=44 code=INTERNAL message=dashboard 聚合失败 cause=IllegalStateException
-- slow overdue query, timeout 100ms
elapsedMs=106 code=DB_UNAVAILABLE message=dashboard 聚合超过 100ms
  • 成功路径 64ms:三个查询分别 20ms、40ms、30ms,并发后取最慢的 40ms 加启动开销。
  • 失败路径 44ms:失败任务 40ms 抛异常,慢任务被取消,没有等满 300ms。
  • 超时路径 106ms:作用域 100ms 到期,抛 TimeoutException 并翻译成 DB_UNAVAILABLE。

编译与运行:

javac --release 25 --enable-preview -d out -cp out \
  com/plume/library/web/DashboardService.java
java --enable-preview -cp out DashboardDemo

常见错误与边界

错误一:照抄 JDK 21 的 new StructuredTaskScope.ShutdownOnFailure()。

// 反例:这个类在 JDK 25 不存在
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { ... }

编译会直接报「找不到符号」。JDK 25 的形态是 open(Joiner),joiner 是参数不是子类。升级 JDK 时旧博客的示例基本都要改。

错误二:在 fork 里 catch 掉异常。

// 反例:异常被吞掉,作用域认为任务成功
var task = scope.fork(() -> {
    try {
        return repository.load(id);
    } catch (Exception e) {
        return null;                 // 作用域看不到失败
    }
});

allSuccessfulOrThrow 依赖子任务抛出异常来触发取消。吞掉异常会让 join() 正常返回,后续 get() 拿到 null,错误被推迟到更难排查的地方。要记录日志可以,但记录完必须重新抛出。

错误三:在 fork 里写共享可变状态。 三个子任务同时往一个 HashMap 里放结果,既没有顺序保证也不线程安全。子任务应该只返回自己的结果,由 join() 之后的父线程合并。

错误四:把作用域当线程池。 StructuredTaskScope 是「一次请求内的扇出」,作用域关闭后不能复用。需要常驻并发就用 ExecutorService,两者不要混。

边界:预览 API 的部署成本。 运行参数必须带 --enable-preview,且 class 文件版本与 JDK 严格匹配。用 java --enable-preview 跑一个用 JDK 25 编译的 class 没问题,但用 JDK 26 跑就会因为预览版本不匹配而拒绝启动。容器基础镜像升级时这是常见的翻车点。

边界:取消不等于立刻停止。 取消是发中断,任务要自己响应。如果任务在不可中断的本地调用里,作用域关闭会被拖住。所有阻塞调用都应该有超时。

小结

  • ExecutorService.invokeAll 不会 fail-fast:一个任务 50ms 失败,另一个 400ms 的任务仍会跑完,本机总耗时 408ms。
  • JDK 25 的 StructuredTaskScope 是预览 API,形态为 open(Joiner),编译运行都要 --enable-preview。
  • allSuccessfulOrThrow 在任一子任务失败时取消其余子任务,本机 siblingCancelled=true,整体 44ms 返回。
  • 作用域级别的 withTimeout 是总预算,不是每个任务的预算;100ms 超时在 101ms 触发。
  • Joiner 有五种语义,libraryd 的面板用「全成功」,多副本取最快用「任一成功」。
  • FailedException 的 cause 要按领域错误翻译,不要把 MEMBER_NOT_FOUND 变成 500。

机制讲完了,接下来是落地时最容易踩的三件事:连接池、ThreadLocal、限流。虚拟线程让等待变便宜,但不会让下游容量变大。

阅读导航:上一节:6.1 虚拟线程原理:载体线程、挂起与 pinning · 下一节:6.3 虚拟线程落地:连接池、ThreadLocal 与限流 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发