登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  java教程

Java 结构化并发怎样统一取消一组子任务

来源:17golang原创

时间:2026-10-09 01:15:54 370浏览 收藏

我在改造一个聚合接口时,最先盯着的是“并发后能快多少”,后来真正棘手的却是失败后的收尾:资料、订单和推荐三个请求同时发出,订单先报错,另外两个请求还在占用连接和线程。调用方已经拿到失败结果,后台工作却没有一起停。

Java 25 的 StructuredTaskScope 正好把这类相关子任务装进同一个生命周期。默认策略下,只要一个子任务失败,作用域就会取消,并用中断通知还没完成的兄弟任务;调用方中断、整体超时或提前离开作用域,也都通过同一个关闭边界完成收尾。

要点速览
  • 统一取消的关键不是保存更多 Future,而是给相关子任务一个共同所有者。
  • StructuredTaskScope.open() 的默认策略适合“全部成功才有意义”的聚合请求。
  • 取消依赖中断协作;子任务吞掉 InterruptedException,scope 仍可能迟迟无法关闭。
  • Java 25 中该 API 仍是预览功能,编译和运行都要开启 preview。

先看见分散 Future 留下的取消缺口

原来的写法通常不难理解:把三个 Callable 提交给执行器,再按顺序调用 get()。问题出在异常路径。第一个 get() 抛错后,后面的 Future 是否取消、何时取消、哪个异常应该返回,都要靠业务代码逐个补齐。

这类故障的影响不一定立刻表现为线程耗尽。更常见的是下游连接继续被占用、日志在请求结束后才出现、超时任务仍访问已经无用的数据。触发条件也很普通:一组结果必须一起使用,但其中一个子任务比其他任务更早失败。

我后来把根因归纳成一句话:这些任务在业务上属于一个请求,代码里却没有一个对象拥有它们的完整生命周期。只要所有权是分散的,取消逻辑就会分散。

用一个任务作用域收拢失败传播

Java 25 的默认 StructuredTaskScope.open() 使用“全部成功,否则失败”的策略。每个 fork 返回一个 Subtask,owner 线程只需要在同一作用域内调用一次 join()。任一子任务失败后,默认 Joiner 会取消整个作用域,中断仍未完成的子任务,并让 join() 抛出 StructuredTaskScope.FailedException。

import java.util.concurrent.StructuredTaskScope;

record Dashboard(Profile profile, Orders orders, Recommendations recommendations) {}

Dashboard loadDashboard(String userId) throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        var profile = scope.fork(() -> profileClient.load(userId));
        var orders = scope.fork(() -> orderClient.load(userId));
        var recommendations = scope.fork(() -> recommendClient.load(userId));

        // 三个子任务作为一个单元等待;任一失败会取消未完成的兄弟任务
        scope.join();

        return new Dashboard(
                profile.get(),
                orders.get(),
                recommendations.get());
    }
}

这个结构里,取消动作不再散落在多个 catch 中。正常路径是全部成功后读取结果;失败路径由 Joiner 决定作用域结果;try-with-resources 则保证离开代码块时关闭 scope。对于“缺一项就无法组装响应”的请求,这比手工维护多个 Future 的状态更符合业务语义。

Owner 线程、StructuredTaskScope、默认 Joiner 与三个子任务的静态关系图
图1:结构说明图,展示 Owner 线程、StructuredTaskScope、默认 Joiner 与三个相关子任务的共同生命周期边界;它不是运行截图或执行证据。

把超时和调用方中断纳入同一边界

子任务失败只是一个取消来源。聚合请求还需要整体超时,否则三个下游各自设置一秒超时,并不等于整个聚合过程只等待一秒。Java 25 可以在打开 scope 时给配置增加 withTimeout(Duration):

import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;

try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
        config -> config
                .withName("dashboard-load")
                .withTimeout(Duration.ofMillis(800)))) {

    var profile = scope.fork(() -> profileClient.load(userId));
    var orders = scope.fork(() -> orderClient.load(userId));

    scope.join(); // 整体超时会取消 scope,并抛出 TimeoutException
    return combine(profile.get(), orders.get());
}

如果 owner 线程在 join() 中被中断,join() 会抛出 InterruptedException;随后离开 try 块时,close() 取消未完成子任务并等待它们终止。这样,失败、超时和调用方取消最终都落到同一个作用域边界,而不是各写一套清理分支。

还有一个容易误解的点:close() 会等待 scope 启动的线程结束。它保证子任务不会逃逸到代码块外,但这也意味着子任务如果不响应中断,关闭动作可能被拖住。

让子任务真正配合取消

StructuredTaskScope 发出的取消通知本质上是中断。它能建立一致的控制边界,却不能强迫任意阻塞操作瞬间结束。子任务需要遵守中断约定:调用可中断 API,捕获 InterruptedException 后恢复中断标记或继续抛出,并在 finally 中释放资源。

Report loadReport(String userId) throws InterruptedException {
    try {
        return reportGateway.fetch(userId); // 应使用支持超时或中断的调用
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();  // 不吞掉取消信号
        throw e;
    } finally {
        temporaryBuffer.clear();
    }
}

如果底层库使用不可中断的阻塞调用,或捕获异常后继续长时间计算,scope 已经是“取消中”,线程却仍未结束。此时应先给 I/O 设置明确超时,必要时替换阻塞 API;CPU 密集循环则周期性检查中断状态,并把退出路径设计成正常清理的一部分。

withTimeout、scope close、中断信号与可中断和不可中断阻塞的静态关系图
图2:静态说明图,区分 scope 的取消控制与子任务对中断的协作责任;它不表示真实执行时序。

哪些策略不应该混在一起

业务语义适合的 Joiner取消表现
全部结果都必须成功默认 open() 或 awaitAllSuccessfulOrThrow()任一失败就取消其余未完成任务
任取一个成功结果anySuccessfulResultOrThrow()第一个成功结果出现后取消其余任务
无论成功失败都等待awaitAll()子任务失败不会自动取消 scope
满足自定义完成条件allUntil(predicate) 或自定义 Joiner谓词或 Joiner 返回取消决定

我不建议为了“统一”而把所有任务都塞进默认策略。比如批量探测多个地址并保留每个成功或失败结果,使用 awaitAll() 更符合目的;如果只需要最快的一个成功响应,继续等待其他结果反而浪费资源。先定业务结果,再选 Joiner,取消行为才不会让人意外。

用复查清单防止取消语义再次分叉

  • 这些子任务是否属于同一次业务操作,并且必须在方法返回前结束?如果不是,不要硬套结构化并发。
  • 全部成功、任一成功还是全部收集,哪一种结果策略与业务一致?
  • 整体超时是否写在 scope 配置上,而不是只依赖各个下游自己的超时?
  • 每个阻塞点是否支持中断或独立超时?
  • 是否有代码吞掉 InterruptedException,或在取消后继续长时间计算?
  • FailedException、TimeoutException 和调用方中断如何映射成接口错误?
  • 编译和运行环境是否都开启 Java 25 preview:javac --enable-preview --release 25 与 java --enable-preview?

结构化并发真正带来的变化,不是把线程换成虚拟线程,而是把“谁创建任务、谁等待任务、谁负责取消”重新放回同一段代码。只要子任务愿意响应中断,一组相关工作就能像一次普通方法调用一样,在成功、失败和超时后都有清晰的结束点。

常见问题

调用 scope.close() 就等于所有子任务立即停止吗?

不等于。关闭会取消作用域并中断未完成子任务,但仍会等待线程终止;不可中断阻塞或吞掉中断的代码会拖延关闭。

默认 open() 为什么适合聚合接口?

它采用全部成功策略。任何一个必需结果失败时,其余结果已经失去业务价值,取消未完成任务可以减少无效工作。

已经有 CompletableFuture,还必须迁移吗?

不必须。若现有代码已经清楚管理生命周期、异常和取消,可以继续使用。StructuredTaskScope 更适合方法内创建、等待并结束的一组相关任务。

Java 25 可以直接在生产环境使用 StructuredTaskScope 吗?

它在 Java 25 中仍是预览 API,需要显式开启 preview,并接受后续版本 API 可能变化的迁移成本。采用前应把 JDK 版本、构建参数和回归测试纳入发布计划。

官方资料

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>