承接的问题
15.1 末尾留了一个数字:查不到书的那次请求花了 410ms 才报错,而真正失败发生在 0ms。原因很简单,awaitAll 的语义是「等所有子任务结束」,不是「一有结果就做决定」。它把失败存进 Subtask,然后继续等另一个 400ms 的查询。
业务上这是浪费:库存已经确定不存在,再等会员配额毫无意义。更糟的是扇出场景——三个下游发通知,push 挂了,mail 和 sms 还在往用户手机上发,等它们发完才报错,等于把一次失败放大成三次副作用。
这一节解决的就是「失败之后剩下的任务怎么办」。JDK 25 的答案是 Joiner:它不是回调,而是 join() 的决策器——决定什么时候可以返回、返回什么、以及要不要取消其余子任务。先说版本:StructuredTaskScope 在 JDK 25 仍是 preview,未来版本 API 可能变化,本节代码都要 --enable-preview。
Joiner 是 join 的决策器
open() 的行为等价于 open(Joiner.awaitAllSuccessfulOrThrow())(15.1 实测两者成功与失败路径一致)。五个内置 Joiner 覆盖了绝大多数扇出形状:
| Joiner | join() 返回 | 何时返回 | 失败/成功后的动作 |
|---|---|---|---|
awaitAll() | Void | 全部结束 | 不取消,失败留在 Subtask 里 |
awaitAllSuccessfulOrThrow() | Void | 首个失败,或全部成功 | 首个失败即取消其余 |
allSuccessfulOrThrow() | Stream<Subtask<T>> | 同上 | 同上,并返回全部子任务 |
anySuccessfulResultOrThrow() | T | 首个成功,或全部失败 | 首个成功即取消其余 |
allUntil(Predicate) | Stream<Subtask<T>> | 谓词返回 true | 触发后取消未完成者 |
两个类型细节必须记住,否则编译不过:
open(Joiner)需要写两个类型参数<T, R>,R是join()的返回类型。allSuccessfulOrThrow()的R是Stream<Subtask<T>>,不是Void;写成<String, Void>会报「找不到合适的方法」。open(joiner, cfg)也是两个类型参数,即使你只关心Void,也得写全StructuredTaskScope.<Object, Void>open(...)。
Joiner 只有一个抽象方法 result(),看起来可以写成 lambda,但不要自己实现:它还有两个 default 方法 onFork/onComplete 参与取消决策,写错一个就是泄漏。JDK 25 的内置实现覆盖了「全成功」「任一成功」「满足条件就停」三类,组合不出想要的语义时,先想想是不是该换问题拆法。
实测:失败传播与取消时机
下面这段把四个 Joiner 放在同一个场景里跑:mail、sms 各睡 300~800ms,push 在第 120ms 抛 503。每个子任务在 catch (InterruptedException) 里打印被打断的时刻,这样取消发生在哪一刻是可测的。
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
public class JoinerSemantics {
static long T0 = System.nanoTime();
static long ms() { return (System.nanoTime() - T0) / 1_000_000; }
/** push 在 120ms 抛 503,其余睡满 budgetMs。 */
static String send(String name, long budgetMs, boolean fail) throws Exception {
try {
Thread.sleep(Duration.ofMillis(fail ? 120 : budgetMs));
} catch (InterruptedException e) {
System.out.printf(" [%-4s] +%4dms INTERRUPTED%n", name, ms());
throw e;
}
if (fail) {
System.out.printf(" [%-4s] +%4dms 503%n", name, ms());
throw new IllegalStateException(name + " 下游 503");
}
System.out.printf(" [%-4s] +%4dms ok%n", name, ms());
return name + "-ok";
}
public static void main(String[] args) throws Exception {
System.out.println("A. awaitAll");
T0 = System.nanoTime();
try (var scope = StructuredTaskScope.<String, Void>open(
StructuredTaskScope.Joiner.awaitAll())) {
Subtask<String> mail = scope.fork(() -> send("mail", 300, false));
Subtask<String> sms = scope.fork(() -> send("sms", 300, false));
Subtask<String> push = scope.fork(() -> send("push", 0, true));
System.out.println(" join=" + scope.join() + " states="
+ mail.state() + "/" + sms.state() + "/" + push.state());
System.out.println(" push.exception()=" + push.exception());
try {
push.get();
} catch (IllegalStateException e) {
System.out.println(" push.get() -> ISE " + e.getMessage());
}
}
System.out.println("B. allSuccessfulOrThrow");
T0 = System.nanoTime();
try (var scope = StructuredTaskScope.<String, Stream<Subtask<String>>>open(
StructuredTaskScope.Joiner.allSuccessfulOrThrow())) {
scope.fork(() -> send("mail", 800, false));
scope.fork(() -> send("sms", 800, false));
scope.fork(() -> send("push", 0, true));
System.out.println(" 不应到这里 " + scope.join().toList());
} catch (StructuredTaskScope.FailedException e) {
System.out.printf(" join threw FailedException at +%4dms, cause=%s%n", ms(), e.getCause());
}
System.out.println("C. anySuccessfulResultOrThrow");
T0 = System.nanoTime();
try (var scope = StructuredTaskScope.<String, String>open(
StructuredTaskScope.Joiner.anySuccessfulResultOrThrow())) {
scope.fork(() -> send("mail", 600, false));
scope.fork(() -> send("sms", 150, false));
scope.fork(() -> send("push", 900, false));
String winner = scope.join();
System.out.printf(" winner=%s at +%4dms cancelled=%s%n", winner, ms(), scope.isCancelled());
}
System.out.println("D. allUntil:满两个成功就停");
T0 = System.nanoTime();
var ok = new AtomicInteger();
try (var scope = StructuredTaskScope.<String, Stream<Subtask<String>>>open(
StructuredTaskScope.Joiner.<String>allUntil(st -> {
if (st.state() == Subtask.State.SUCCESS && ok.incrementAndGet() == 2) {
System.out.printf(" [pred] +%4dms 第二个成功,触发停止%n", ms());
return true;
}
return false;
}))) {
scope.fork(() -> send("mail", 100, false));
scope.fork(() -> send("sms", 200, false));
scope.fork(() -> send("push", 800, false));
var done = scope.join().toList();
System.out.printf(" 收到 %d 个 subtask,states=%s cancelled=%s%n",
done.size(), done.stream().map(Subtask::state).toList(), scope.isCancelled());
}
}
}
本机输出(Temurin 25.0.5+8,--enable-preview,load average 124):
A. awaitAll
[push] + 140ms 503
[mail] + 314ms ok
[sms ] + 314ms ok
join=null states=SUCCESS/SUCCESS/FAILED
push.exception()=java.lang.IllegalStateException: push 下游 503
push.get() -> ISE Result is unavailable or subtask did not complete successfully
B. allSuccessfulOrThrow
[push] + 131ms 503
[mail] + 133ms INTERRUPTED
[sms ] + 133ms INTERRUPTED
join threw FailedException at + 134ms, cause=java.lang.IllegalStateException: push 下游 503
C. anySuccessfulResultOrThrow
[sms ] + 153ms ok
[mail] + 154ms INTERRUPTED
[push] + 154ms INTERRUPTED
winner=sms-ok at + 154ms cancelled=true
D. allUntil:满两个成功就停
[mail] + 109ms ok
[sms ] + 201ms ok
[pred] + 204ms 第二个成功,触发停止
[push] + 204ms INTERRUPTED
收到 3 个 subtask,states=[SUCCESS, SUCCESS, UNAVAILABLE] cancelled=true
逐段读:
- A:
push在 140ms 就失败了,但join等到 314ms 才返回,mail/sms全程没被打断。join返回null,失败只能通过push.state() == FAILED发现。同一个Subtask上,exception()给出原始IllegalStateException,get()却只给一句ISE。这就是 15.1 那个 410ms 的来源。 - B:
push在 131ms 失败,mail/sms在 133ms 被中断——取消延迟 2ms,join在 134ms 抛FailedException,getCause()就是原始异常,类型和信息都没丢。800ms 的两个任务实际只活了 133ms,总耗时从 800ms 降到 134ms。 - C:
sms153ms 先成功,join立刻返回它的结果,另外两个在 154ms 被打断,isCancelled()为true。这是「多副本查询取最快」的正确写法。 - D:谓词在第二个成功时返回
true,剩下的push立刻被中断。注意返回的Stream里有三个Subtask,状态是[SUCCESS, SUCCESS, UNAVAILABLE]——它返回的是「这次 join 涉及的全部子任务」,不是「成功的那几个」,过滤要自己做。
取消语义的三个细节
一、取消靠中断,不靠杀线程。 上面每次 INTERRUPTED 都是子任务自己 catch (InterruptedException) 打出来的。如果子任务干的是不可中断的活(紧循环不检查标志、阻塞在 synchronized 里等锁、IO 不响应中断),它不会停,join 会一直等到它结束。作用域只能保证「关闭返回时没有活线程」,代价是 join/close 会一直等下去;能不能及时停下,取决于子任务是否配合中断——这条前提和 6.2 的两阶段终止一样。
二、owner 被中断时,join 不取消子任务。 本机实测:主线程在 join() 里被 interrupt(),join 抛 InterruptedException,此刻 scope.isCancelled() 仍是 false;真正的中断发生在随后的 close()。所以正确写法是 catch (InterruptedException e) { Thread.currentThread().interrupt(); } 交给 try-with-resources 关作用域,而不是自己 fork 一堆取消逻辑。
三、FailedException 只带一个 cause。 三个子任务同时失败时,join 抛出的 FailedException 只保留第一个失败作为 cause,本机实测 getSuppressed().length == 0,另一个异常没有被挂上去。依赖「所有失败都能看到」的监控会漏报,需要完整失败列表时只能在每个子任务里自己记录。
Subtask 的状态语义也因此分成两路:FAILED 表示「跑过并且抛了异常」,UNAVAILABLE 表示「没跑完或被取消」。前者能用 exception() 取原始异常,后者不能。判断「是不是被取消」不要看 state(),看 scope.isCancelled() 更直接。
在 libraryd-conc 里落地
把通知扇出单独抽成 StructuredNotifier:三个下游 mail、sms、push,全部成功才算通知成功,任一失败立刻取消其余——避免用户收到一半通知、系统却报错。
package com.plume.conc.core;
import java.util.List;
import java.util.Map;
import java.util.concurrent.StructuredTaskScope;
import java.util.stream.Stream;
/** 通知扇出:mail / sms / push 三个下游,任一失败即取消其余。 */
public final class StructuredNotifier {
public interface Channel {
void send(String memberId, String text) throws Exception;
}
private static final List<String> CHANNELS = List.of("mail", "sms", "push");
private final Map<String, Channel> channels;
public StructuredNotifier(Map<String, Channel> channels) {
this.channels = channels;
}
/** 三个下游全部成功才返回;任何一个失败,join 立刻抛 FailedException 并取消其余。 */
public void fanout(String memberId, String text) {
try (var scope = StructuredTaskScope.<String, Stream<StructuredTaskScope.Subtask<String>>>open(
StructuredTaskScope.Joiner.allSuccessfulOrThrow(),
cfg -> cfg.withThreadFactory(Thread.ofVirtual().name("notify-", 0).factory()))) {
for (String name : CHANNELS) {
Channel channel = channels.get(name);
scope.fork(() -> {
channel.send(memberId, text);
return name;
});
}
scope.join().toList(); // 全部成功才会走到这里
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LoanException(ErrorCode.INTERRUPTED, "通知扇出被中断");
} catch (StructuredTaskScope.FailedException e) {
Throwable cause = e.getCause();
if (cause instanceof LoanException loan) {
throw loan;
}
throw new LoanException(ErrorCode.CONFLICT, "通知扇出失败: " + cause);
}
}
}
fanout 返回 void,因为调用方只关心「三个都发出去没有」。如果业务要区分「哪个渠道失败」,就得给 Channel 加返回值并在 catch 里读 exception()——但那样语义就变成 15.1 的 awaitAll 了,取舍见下一节。
mvn -q compile -Dmaven.compiler.enablePreview=true
java --enable-preview --source 25 -cp target/classes com.plume.conc.core.DemoNotifier
本机输出(三个渠道正常,然后 push 在第 120ms 抛 503):
[sms ] + 19ms start virtual=true name=notify-1
[mail] + 19ms start virtual=true name=notify-0
[push] + 19ms start virtual=true name=notify-2
[push] + 849ms ok
[mail] + 849ms ok
[sms ] + 849ms ok
all-ok + 851ms
[mail] + 0ms start virtual=true name=notify-0
[sms ] + 0ms start virtual=true name=notify-1
[push] + 0ms start virtual=true name=notify-2
[push] + 131ms 503
[mail] + 138ms INTERRUPTED
[sms ] + 138ms INTERRUPTED
fanout + 138ms code=CONFLICT msg=push 下游 503
注意最后一行:原始 LoanException 穿过 FailedException 回到了调用方,ErrorCode.CONFLICT 和消息都没变。前提是 catch 里显式解包 getCause();不写这段,抛出去的就是 FailedException,上层按 LoanException.code() 分流的逻辑全部失效。这是 15.3 要展开的迁移陷阱之一。
什么时候不要用
- 需要拿到部分成功结果:
allSuccessfulOrThrow一失败就全取消,已经发出去的mail无法回滚。要「尽力而为」就该用awaitAll收集每个渠道的状态,自己决定重试哪几个。 - 下游之间有先后依赖:
sms要用mail返回的模板 ID,这不是扇出而是流水线,作用域只能并行独立任务,串行依赖用thenCompose或直接顺序写。 - 失败必须全部上报:前面说过
FailedException只带一个 cause。要完整失败列表,anySuccessfulResultOrThrow/allSuccessfulOrThrow都不合适,用awaitAll+ 遍历Subtask.exception()。 - 取消代价高于等待:下游是幂等的短查询时,取消省下的 600ms 可能不值一次连接重建。取消本身有成本,别为了取消而取消。
常见错误与边界
一、用 get() 判断失败。 FAILED 的 get() 抛 IllegalStateException,把你精心设计的错误码全吞了。先看 state(),再决定走 get() 还是 exception()。
二、以为返回的 Stream 只有成功的子任务。 顺序是 fork 顺序(本机实测,与完成先后无关),但 allUntil 的流里还包含被取消的 UNAVAILABLE 子任务。直接 map(Subtask::get) 会在那一项上抛 IllegalStateException,必须先按状态过滤。
三、在 catch 里忘了重设中断标志。 join 抛 InterruptedException 时会清掉中断位,不 Thread.currentThread().interrupt() 恢复,上层就再也不知道自己被取消了。
四、以为取消是同步的。 join 返回/抛出时,被取消的子任务可能还在跑它的 finally。本机实测两个场景的取消延迟分别是 2ms 和 7ms,下游负载高时会更长。想确认「全部停下」只能等 close() 返回。
五、谓词里抛异常。 本机实测谓词不在 owner 线程上执行,而是在每个子任务完成时的回调里、由一个匿名虚拟线程执行;抛出的异常成为那个线程的未捕获异常,join 照常返回,问题被完全吞掉。谓词只读 state(),别在里面做可能失败的解析。
六、用 Joiner 做限流。 allUntil 的谓词是「够不够」,不是「最多几个并发」。要控制同时在跑的子任务数量,用 Semaphore(14.3)或分批 fork,Joiner 管不了这个。
小结
Joiner决定join()何时返回、返回什么、是否取消其余;五个内置实现覆盖全成功、任一成功、条件停止三类语义。awaitAll不取消、不传播失败:失败子任务留在FAILED状态,get()只抛ISE,原始异常在exception()(本机实测 314ms vs 140ms 的差距)。allSuccessfulOrThrow在首个失败后取消其余,本机实测取消延迟 2ms,总耗时 134ms,FailedException.getCause()保留原始异常。anySuccessfulResultOrThrow取最快成功者并取消其余(实测 154ms 全部结束);全失败时抛FailedException,且只保留一个 cause(实测suppressed=0)。allUntil由谓词触发停止,返回的Stream含全部子任务,被取消者状态为UNAVAILABLE。- 取消基于中断,子任务不响应中断就停不下来;owner 被中断时
join抛InterruptedException但此刻尚未取消,取消发生在close()。
到这里「怎么写」已经清楚,剩下一件事:已有的 ExecutorService 代码怎么迁过来,哪些能迁、哪些迁了会更糟。15.3 用同一段扇出逻辑做前后对照,并给出迁移的硬边界。
阅读导航:上一节 15.1 StructuredTaskScope 的作用域 | 下一节 15.3 从 ExecutorService 迁移的边界
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。