Administrator
发布于 2024-08-06 / 11177 阅读
126

Structured Concurrency 第五次预览后的变化

做聚合服务时,要并发调三个下游再合并结果。最早我用 ExecutorService + Future,取消一个、异常处理、超时控制写得一团乱,线程泄漏还出了两次线上问题。JDK 23 的结构化并发(第五次预览,JEP 480)把这套"多任务协同"重新规范了一遍,值得认真看。

旧写法的乱

经典 Future 写法有三处别扭:一个任务失败,其余还在跑,得手动 future.cancel;超时了其他任务不会自动停;异常散落各处。代码里到处是 try/finally 收尾,新人根本不敢改。

StructuredTaskScope:把任务当整体

结构化并发的核心思想:一组并发任务有共同的生命周期,像代码块一样"进入就全部启动、退出就全部结束"。JDK 23 预览 API:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User>  u = scope.fork(() -> userService.get(id));
    Subtask<Order> o = scope.fork(() -> orderService.list(id));
    Subtask<Score> s = scope.fork(() -> scoreService.get(id));

    scope.join();           // 等所有任务
    scope.throwIfFailed();  // 任一失败则抛,其余已取消

    return new Profile(u.get(), o.get(), s.get());
}

ShutdownOnFailure 的语义很干净:一个失败,其余立刻取消,主线程拿到第一个异常。不用再手写取消逻辑,作用域结束资源自动回收,线程泄漏无从发生

API 的调整:从 fork 到 Subtask

相比早期预览,JEP 480 把 fork 的返回值从 Future 改成 Subtask,区分了"成功/失败/取消"状态;join() 不再抛受检异常,改由 throwIfFailed() 显式处理。这个调整让"成功拿结果、失败拿异常"的意图更清楚,编译期就逼你想清楚错误处理。

与虚拟线程的配合

StructuredTaskScope 默认在虚拟线程上跑 fork 的任务。配合 JDK 21 虚拟线程(JEP 444),上千个并发子任务也只是上千个轻量虚拟线程,载体平台线程就那么几个。我们在聚合服务上把线程池换成默认虚拟线程后,同样并发下内存占用降了 60%,因为不再为每个任务预分配平台线程栈。

// 显式用虚拟线程工厂(默认即是)
StructuredTaskScope<?> scope =
    new StructuredTaskScope.ShutdownOnFailure(
        Executors.newVirtualThreadPerTaskExecutor());

取消与超时处理

取消基于虚拟线程的中断:子任务里若阻塞在可中断的 IO(如 InterruptibleChannel),收到中断会抛出,任务干净退出。关键是子任务代码要"对中断友好"——别吞 InterruptedException,别在不可中断的同步块里死赖。

超时则用 joinUntil

scope.joinUntil(Instant.now().plusMillis(800));
if (scope.status() != StructuredTaskScope.Status.SUCCESS) {
    // 超时:未完成的子任务已被取消,走降级
    return fallbackProfile(id);
}

这里取消是自动传播的——joinUntil 返回后,超时未完成的任务已被中断取消,不会悬在后台空耗资源。

踩坑

  • 预览限制:JDK 23 上必须 --enable-preview,我们 CI 加了对应编译选项,生产镜像单独构建;
  • 可中断性:一度有个子任务卡在 synchronized 里阻塞,取消不生效(pin 问题,见前文),改成 ReentrantLock 后取消才灵敏;
  • 嵌套作用域:作用域里再开作用域完全合法,取消会顺着层级向上传播,这块比手写线程池清晰太多。

写在后面

现在回头看,《Structured Concurrency 第五次预览后的变化》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考