ThreadForge 源码解读三:从任务执行到并发编排,ScopeJoiner 是怎么工作的?
前两篇讲了 ThreadScope 的边界和 Task 的执行链路。这一篇继续深入,我们来看高阶并发编排。
当业务需要从多个并发请求中获取第一个成功结果 、根据多数请求的结果进行决策 ,或者通过发送备用请求来降低长尾延迟时,代码应该如何实现?
ThreadForge 给出的答案是 ScopeJoiner 和 JoinStrategy。

1. 为什么需要 JoinStrategy
普通并发就是:把多个任务跑起来,等它们结束。
但真实业务里,经常不需要等全部完成。
first success
比如下面这些场景:
- 缓存和多个下游副本同时请求,只要第一个成功:先查缓存,再查副本;
- 主服务和备服务并发;
- 多数据源抢首个可用结果。
有一个成功就要及时结束。
quorum
- 多个副本并发,成功数达到阈值即可;
- 5 个副本拿到 3 个成功就返回,多个节点投票达到法定票数就继续;
这类场景关心的不是全量,是数量足够后就结束。
hedged request
先发主请求,慢了再补发备用请求,目标是压长尾。
这类逻辑如果让业务自己写,常见结果是一堆 CompletableFuture.anyOf(...) ,再加上手动取消剩余任务、手动补延迟启动、手动处理已经不可能成功的分支。
ThreadForge 把这些模式统一收敛成了 JoinStrategy。
2. JoinStrategy:只描述结果目标
src/main/java/io/threadforge/JoinStrategy.java:
java
enum Mode {
FIRST_SUCCESS,
QUORUM,
HEDGED
}
三个工厂方法:
java
public static <T> JoinStrategy<T, T> firstSuccess()
public static <T> JoinStrategy<T, List<T>> quorum(int requiredSuccesses)
public static <T> JoinStrategy<T, T> hedged(Duration hedgeDelay)
firstSuccess()--- 拿第一个成功值quorum(n)--- 拿到 n 个成功值hedged(delay)--- 主请求先跑,延迟后放出备援
JoinStrategy 只定义任务的合并目标,具体的启动、结果汇总和任务取消由 ScopeJoiner 统一处理。
三层分工:JoinStrategy 定义模式,ScopeJoiner 对外入口,JoinState 内部推进状态。
3. ScopeJoiner.join:总流程
入口:
java
public <T, R> R join(JoinStrategy<T, R> strategy, Collection<? extends Callable<T>> callables)
简单来讲就是先校验参数并创建 JoinState,然后根据策略启动任务,最后等待并返回执行结果。
关键代码如下:
java
JoinState<T, R> state = new JoinState<T, R>(scope, strategy, taskList.size());
if (strategy.isHedged()) {
state.launch(taskList.get(0));
for (int i = 1; i < taskList.size(); i++) {
state.scheduleHedged(taskList.get(i), strategy.hedgeDelay());
}
} else {
for (Callable<T> callable : taskList) {
state.launch(callable);
}
}
return scope.awaitJoinFuture(state.result());
任务统一通过 scope.submit(...) 提交,并通过 scope.awaitJoinFuture(...) 等待执行结果。
这样一来,整个 join 过程始终由 ThreadScope 管理,任务取消和超时控制仍然遵循底层运行时的规则。
4. JoinState:内部状态机
JoinState 是 ScopeJoiner 的私有内部类。
核心字段如下:
java
private final CompletableFuture<R> result;
private final List<Task<T>> tasks;
private final List<ScheduledTask> launchers;
private final List<Throwable> failures;
private final List<T> successes;
private final Object monitor;
private int completed;
result--- 最终等待的 futuretasks--- 已启动的候选任务launchers--- hedged 模式下的延迟启动器failures/successes--- 结果收集monitor--- 保护共享状态completed--- 已完成计数
整个处理过程可以分为四步:先启动所有候选任务,再持续收集任务结果;当结果满足策略要求时,立即作出决策,并取消不再需要的剩余任务。
各种高级并发模式,本质上都是基于这套流程实现的。
5. firstSuccess:第一个成功就结束
当多个任务并发执行时,只要其中一个任务成功,就立即完成整个 join,并取消其他尚未结束的任务。
handleCompletion(...) 会先记录成功结果:
java
if (throwable == null) {
successes.add(value);
if (!result.isDone()) {
if (strategy.isFirstSuccess() || strategy.isHedged()) {
successToComplete = value;
}
}
}
离开同步块后,再尝试用该结果完成 result:
java
if (successToComplete != null) {
if (result.complete((R) successToComplete)) {
cancelPending(task);
}
return;
}
这里的核心逻辑是:任意任务成功后,都可以尝试完成最终结果;只有第一个成功完成 result 的任务会被保留,其余任务全部取消。
需要注意的是,这种策略比较的是谁先成功,而不是谁先结束。
某个任务即使最先返回,但结果是失败,也不会结束整个 join;只有最先返回成功结果的任务才算胜出。
6. quorum:成功数够了就结束
当成功任务的数量达到指定阈值后,立即返回前 N 个成功结果:
java
scope.joiner().quorum(3, callables); // 收集到 3 个成功结果后返回
对应的判断逻辑如下:
java
else if (strategy.isQuorum() && successes.size() >= strategy.requiredSuccesses()) {
successToComplete = Collections.unmodifiableList(
new ArrayList<T>(successes.subList(0, strategy.requiredSuccesses()))
);
}
系统还会提前判断当前任务是否已经不可能达到目标:
java
private boolean cannotSucceed() {
int target = strategy.isQuorum() ? strategy.requiredSuccesses() : 1;
int remainingPossible = totalCandidates - completed;
return successes.size() + remainingPossible < target;
}
系统会结合当前成功数量和剩余候选任务数量,判断是否还有可能达到目标阈值。
如果剩余任务全部成功后仍无法满足要求,就立即结束 join,不再继续等待。
失败时 JoinState.failure() 区分两种场景:有真实失败的返回 AggregateException,纯粹因取消达不到 quorum 的返回 CancelledException。
7. hedged request:通过备用请求降低长尾延迟
Hedged Request 的执行方式是:先发送主请求。如果主请求在 hedgeDelay 内没有返回,再启动备用请求,最终采用最先成功的结果。
java
if (strategy.isHedged()) {
state.launch(taskList.get(0));
for (int i = 1; i < taskList.size(); i++) {
state.scheduleHedged(taskList.get(i), strategy.hedgeDelay());
}
}
第一个任务会立即执行,后续任务则通过 scheduleHedged(...) 延迟启动:
java
ScheduledTask launcher = scope.schedule(delay, () -> {
if (result.isDone()) {
return;
}
scope.token().throwIfCancelled();
launch(callable);
});
延迟时间到达后,系统会先检查最终结果是否已经产生。
如果主请求已经成功,就不再启动备用请求;如果当前 ThreadScope 已被取消,备用请求也不会继续执行。
这种方式不会一开始就同时发送所有请求,而是在主请求响应较慢时再启动备用请求,从而在控制额外资源消耗的同时,降低长尾请求对整体响应时间的影响。
8. cancelPending:取消不再需要的任务
无论采用哪种 join 策略,只要最终结果已经确定,就应立即取消剩余任务。
JoinState.cancelPending(...) 的处理逻辑如下:
java
for (ScheduledTask launcher : launchersSnapshot) {
if (!launcher.isDone()) {
launcher.cancel();
}
}
for (Task<T> task : tasksSnapshot) {
if (task != winner && !task.isDone()) {
task.cancel();
}
}
这里需要处理两类对象:
- 尚未触发的 Hedged Request 延迟启动器;
- 已经启动,但最终结果不再依赖的任务。
对于获胜任务,代码会通过 task != winner 将其保留;其他仍在运行的任务则直接取消。
完成 result 只能让调用方提前返回,取消剩余任务才能避免无效计算继续占用线程、连接和其他运行时资源。
9. 所有编排都由 ThreadScope 统一管理
并发编排如果绕开底层任务运行时,通常会形成两套生命周期管理机制:普通任务由底层框架负责取消和超时,高级编排则需要单独维护自己的状态和控制逻辑。
这样不仅实现复杂,也容易出现行为不一致。
ScopeJoiner 没有额外建立一套任务运行机制,而是始终通过 ThreadScope 提供的 API 完成任务提交、延迟调度和结果等待:
java
scope.submit(callable); // 提交任务
scope.schedule(delay, runnable); // 延迟执行
scope.awaitJoinFuture(state.result()); // 等待最终结果
因此,ScopeJoiner 中的所有任务仍然处于当前 ThreadScope 的生命周期内,可以继续使用 scope 已有的能力,包括:
- deadline 和任务超时;
- cancellation token;
- 上下文传播;
- 重试策略;
- 任务调度;
- 执行钩子;
- 指标采集;
- scope 关闭时的统一清理。
ScopeJoiner 只负责组织任务之间的协作关系,任务本身仍由 ThreadScope 统一执行和管理。取消、超时、上下文传播和运行指标不会因为引入高级编排而失效,也不需要重复实现。
写在最后
ScopeJoiner 解决的是更高一层的并发编排问题:它将 firstSuccess、quorum、hedged 等常见并发模式封装成统一策略,同时继续复用 ThreadScope 已有的取消、超时、上下文传播和观测能力。
无论编排逻辑多复杂,任务最终仍然运行在 ThreadScope 的生命周期边界内。
至此,ThreadForge 的三篇源码解读正式结束。
从第一篇介绍 ThreadScope 如何管理任务边界,到第二篇拆解 Task 的完整执行链路,再到这一篇分析 ScopeJoiner 的并发编排机制,这三层共同组成了 ThreadForge 当前的并发运行时:
ThreadScope负责生命周期和运行边界;Task负责单个任务的执行过程;ScopeJoiner负责多个任务之间的协作与结果决策。
最近一段时间工作比较忙,很多精力都投入到了实际业务和团队交付中。
越来越多的业务也开始向 AI 靠拢,我自己也在不断积累和突破 AI 工程化落地方面的实践经验,并在现有业务中做了大量探索。
这些实践不只包括 Function Calling、RAG 和 Agent,也逐渐延伸到了研发流程、运维排障、自动化执行、灰度发布等环节。
过程中既有真正产生价值的方案,也踩过不少看起来厉害、但实际落地效果一般的坑。
后续会结合具体业务场景,逐步整理并分享这些实践,包括技术方案、架构取舍、落地成本,以及哪些场景适合使用 AI,哪些场景并不适合。
最后仍然奉上地址,感谢您的三连:
ThreadForge 项目地址:github.com/wuuJiawei/T...
欢迎提交 issue,也欢迎 star。