前言
最近有读者对 ThreadForge 的实现原理比较感兴趣,所以我准备做一个小系列,系统拆解这个库背后的设计思路。
目前这个系列暂定三篇文章,会从整体设计、任务执行流程、取消与超时机制等角度逐步展开,尽量用清晰、直接的方式来讲清楚。
如果你正在写 Java 并发代码,或者对结构化并发、任务生命周期管理感兴趣,也可以顺手看看这个项目:
GitHub 仓库:github.com/wuuJiawei/T...
欢迎 Star、试用,也欢迎提 Issue 一起交流改进。

0. 先从 ThreadScope 看懂 ThreadForge
ThreadForge 的主角,就是 ThreadScope。
它负责把一批并发任务收进同一个生命周期里。
任务从这里提交,在这里等待,在这里失败,在这里取消,在这里超时,也在这里清理。
这样处理,确保可以形成一个清晰的边界,而且代码读起来也能顺畅很多,排查问题也会轻松很多。
先看一个最常见的用法:
java
try (ThreadScope scope = ThreadScope.open()) {
Task<String> a = scope.submit("a", this::callA);
Task<String> b = scope.submit("b", this::callB);
scope.await(a, b);
}
短小精悍的一段代码,其实已经能看出来并发阶段的入口、等待点和收口了。
你说邦邦不邦邦。
后面读源码时,我们会始终贯穿这个思路。
1. 传统 Java 并发代码的缺陷
传统的 Java 并发代码长这样:
java
ExecutorService executor = Executors.newFixedThreadPool(8);
Future<String> a = executor.submit(this::callA);
Future<String> b = executor.submit(this::callB);
String r1 = a.get();
String r2 = b.get();
代码肯定是能跑的,但也有些问题:
a失败了,b要不要取消- 整体超时了,谁负责发中断
- 任务里带着
traceId、requestId,切线程后怎么传 - 某个任务卡住了,整个并发阶段怎么退出
- 多个任务一起失败,异常怎么汇总
- 业务结束后,任务和调度资源会不会漏掉
这些问题放在一起看,往往就会发现:
并发代码里,完整的生命周期管理太难处理。
这就是 ThreadForge 的诞生来源,或者说这就是它的切入点。
2. ThreadScope 的定位:一个结构化并发作用域
当前实现里,ThreadScope 是一个 final class,并且实现了 AutoCloseable:
java
public final class ThreadScope implements AutoCloseable
正是因为这样的设计,导致它天然适合和 try-with-resources 一起用。
scope 开始时进入一个并发阶段,scope 结束时统一收尾,语义非常完整。
再看几个核心字段:
java
private final Queue<Task<?>> tasks;
private final Queue<ScheduledTask> scheduledTasks;
private final Deque<Runnable> deferred;
private final DefaultCancellationToken token;
private final DelayScheduler delayScheduler;
private final ScopeMetrics metrics;
这一组字段很能说明设计意图:
tasks记录当前 scope 里的普通任务scheduledTasks记录定时任务和延迟启动任务deferred记录关闭阶段要执行的清理动作token是整个作用域共享的取消信号delayScheduler负责延时和周期调度metrics负责记录作用域内的统计数据
这样是不是就能逐步理解了,ThreadScope 真正管理的是一整段并发过程。
3. 整体结构
这张图建议先看关系,不着急抠每个字段。
我们可以先抓住这三点:
ThreadScope在中间,说明它是总入口Task、Scheduler、CancellationToken、ScopeMetrics都围着它转- 每个能力都和 scope 直接相连,说明生命周期是统一管理的
读源码时,如果开始觉得方法太多、调用太散,可以回来再看看这张图,做个对照。
4. 通过默认值来看设计取向
ThreadScope 的私有构造函数里,默认值一目了然:
java
this.scheduler = Scheduler.detect();
this.failurePolicy = FailurePolicy.FAIL_FAST;
this.retryPolicy = RetryPolicy.noRetry();
this.defaultTaskPriority = TaskPriority.NORMAL;
this.deadline = DEFAULT_DEADLINE;
this.hook = NOOP_HOOK;
this.delayScheduler = DelayScheduler.shared();
this.metrics = new ScopeMetrics();
this.token = new DefaultCancellationToken(...);
rescheduleDeadlineMonitor();
这些默认值很值得单独讲,因为它们直接体现了我们这个框架的态度 🆒。
所以多花些篇幅在这段。
4.1 默认调度器:Scheduler.detect()
看源码:
java
public static Scheduler detect() {
if (isVirtualThreadSupported()) {
return virtualThreads();
}
return commonPool();
}
应该是非常直观的:
- JDK 如果支持虚拟线程,就优先用虚拟线程
- 环境不支持,就回退到
commonPool()
框架先看运行环境,再挑执行模型,提供默认行为,不要求用户一上来就手动配线程池,减少心智负担。
4.2 默认失败策略:FAIL_FAST
默认值是:
java
this.failurePolicy = FailurePolicy.FAIL_FAST;
这就是我们对失败的态度。
尽快暴露问题,也尽快停掉无意义的后续工作。
4.3 默认 deadline:30 秒
当前实现里有:
java
private static final Duration DEFAULT_DEADLINE = Duration.ofSeconds(30);
这其实也是多年经验的累积总结。
很多人写的并发代码默认不会去做整体预算,任务一旦卡住,现场就会一直拖着。
所以我们直接给每个 scope 带一个默认的 deadline,整个并发阶段天然有了时间边界,避免任务执行时出现失控的风险。
5. 配置锁定:为什么 submit 之后不能再改配置
ThreadScope 里有个字段:
java
private final AtomicBoolean configLocked;
所有 with* 方法都会先走:
java
ensureConfigurable();
而 submit(...) 和 schedule(...) 会调用:
java
lockConfiguration();
效果就是这样:
java
ThreadScope scope = ThreadScope.open()
.withFailurePolicy(FailurePolicy.COLLECT_ALL)
.withDeadline(Duration.ofSeconds(3));
scope.submit(this::callA);
scope.withDeadline(Duration.ofSeconds(10)); // IllegalStateException
这个限制想必是很好理解的。
就是确保一个 scope 内的语义必须统一。
如果前半批任务按 FAIL_FAST 跑,后半批任务按 COLLECT_ALL 跑,后面再走到 await(...),调用方恐怕都讲不清预期了。
而且维护的人看到这种代码,也会立刻皱眉头罢! 🤣
作用域配置先确定好后,再开始执行任务。
再结合这张状态图,其实就是"先定规则再执行"的可视化版本:
open()之后,scope 先处于可配置阶段- 一旦开始
submit或schedule,配置就锁住 - 后面进入运行、等待和关闭阶段
6. submit:任务是怎么被纳入 scope 管理的
submit(...) 的核心逻辑在私有重载方法里,主线代码如下:
java
final CompletableFuture<T> future = new CompletableFuture<T>();
final Task<T> task = new Task<T>(id, name, future);
final TaskInfo info = new TaskInfo(scopeId, id, name, Instant.now(), scheduler.name());
final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture();
final ScheduledTask timeoutTask = scheduleTaskTimeout(task, info, taskTimeout);
tasks.add(task);
这一段建议大家慢慢看。
这里每一行都在给任务补充更多的语义:
CompletableFuture负责底层完成机制Task是对外暴露的任务句柄TaskInfo给 hook 和 metrics 用ExecutionContextCarrier负责上下文传播scheduleTaskTimeout(...)给单个任务挂上 timeouttasks.add(task)把任务正式登记进 scope
后面才交给调度器:
java
scheduler.executor().execute(...)
简单描述就是:
先登记,再执行。
只要任务进入了 scope,后续的执行、失败、取消和清理,都会由 scope 统一承接。
结合流程图来看,这里把 submit(...) 拆成两段:
- 前半段是建档和准备
- 后半段是交给调度器执行
很多并发 bug 都容易出在前半段。
比如 timeout 已经挂上了,任务却没登记;或者 scope 已经关闭了,任务还在往执行器里塞。
还有一个点很实用:如果 scope 配了并发上限,提交前会先过信号量:
java
final boolean permitAcquired = acquireSubmissionPermit(semaphore);
这样一来,ThreadScope 还能承担一点背压职责。
任务太多时,先在提交阶段做一层控制,避免所有任务一下子都打到执行器上。
7. await:失败策略真正落地的地方
很多人一开始会觉得 await(...) 很简单,感觉循环调一遍 task.await() 就结束了。
从源码上来看,才会发现这里其实是整套失败策略的汇合点:
java
if (failurePolicy == FailurePolicy.FAIL_FAST) {
cancelOthers(taskList, task);
throw failure;
}
if (failurePolicy == FailurePolicy.CANCEL_OTHERS) {
cancelOthers(taskList, task);
failures.add(failure);
} else if (failurePolicy == FailurePolicy.COLLECT_ALL || failurePolicy == FailurePolicy.SUPERVISOR) {
failures.add(failure);
}
这里可以直接按策略理解:
FAIL_FAST:一个失败就立刻取消其他任务并抛出CANCEL_OTHERS:先取消其他任务,再把失败记下来COLLECT_ALL:把所有失败收集起来,最后统一处理SUPERVISOR:其他任务继续跑,失败放进OutcomeIGNORE_ALL:失败直接忽略
最终结果通过 Outcome 返回:
java
new Outcome(taskList.size(), succeeded, cancelled, failures)
它对应的就是整个并发阶段的完成情况,可以结合着看下面这个流程图:
- 左边在等任务
- 中间按结果分流
- 右边按失败策略处理
一直读到这里后,应该就能明显地感觉到 ThreadForge 的整体思想了。
8. deadline:scope 超时后,整个作用域会一起收紧
ThreadScope 构造时会调用:
java
rescheduleDeadlineMonitor();
里面会先计算绝对截止时间:
java
this.deadlineAtNanos = System.nanoTime() + deadline.toNanos();
然后注册一个延迟任务:
java
deadlineTriggerTask = delayScheduler.schedule(deadline, new Runnable() {
@Override
public void run() {
triggerDeadline();
}
});
triggerDeadline() 的实现也没什么复杂的,非常直接:
java
if (!deadlineTriggered) {
deadlineTriggered = true;
token.cancel();
}
这里的重点在于:deadline 到了以后,scope 会立刻进入取消流程。
后面 remainingDeadline() 和 awaitJoinFuture(...) 也都会持续检查 deadline 和 token,所以无论谁如果想无限等下去,都会在这里被拦下来。
再结合这张时序图一起来看:
- 用户创建了一个带 deadline 的 scope
- scope 顺手挂上 deadline 触发器
- 任务开始执行
- 时间一到,
DelayScheduler回调triggerDeadline() - token 被取消,任务开始感知取消
- 用户再去
await(),拿到ScopeTimeoutException
到点就收,不拖泥带水。
9. close:生命周期最终收口的地方
ThreadScope.close() 的顺序如下:
java
token.cancel();
for (ScheduledTask scheduledTask : scheduledTasks) {
scheduledTask.cancel();
}
for (Task<?> task : tasks) {
if (!task.isDone()) {
task.cancel();
}
}
for (Runnable cleanup : deferred) {
cleanup.run();
}
if (deadlineTriggerTask != null) {
deadlineTriggerTask.cancel();
}
scheduler.shutdownIfOwned();
delayScheduler.shutdownIfOwned();
我们可以把它理解成 6 个动作:
- 先取消作用域令牌
- 再取消所有定时任务
- 再取消所有未完成普通任务
- 执行
defer(...)注册的清理动作 - 取消 deadline 监视任务
- 处理调度器关闭
两个设计点尤其好用:
defer(...)采用 LIFO,后注册先执行- 外部传入的执行器不会被随手关掉,只有 owned scheduler 才会在这里关闭
这张图看着很像清场流程,也确实就是清场流程:
- 先发取消信号
- 再停定时任务
- 再停普通任务
- 再跑清理逻辑
- 最后关调度器
先发令,再清场,最后关门。
最后
通篇看下来,应该就能理解了, ThreadScope 最终建立的是一个完整的并发生命周期边界:
- 任务在这里提交
- 失败在这里汇合
- 取消在这里传播
- 超时在这里触发
- 清理在这里收口
- 观测也在这里统一处理
那么这篇就结束了。
下一篇继续深入探究:从 submit(...) 到 runTask(...),一个 Task 会经历什么有趣的流程。
最后的最后。
如果你正在写 Java 并发代码,或者对结构化并发、任务生命周期管理感兴趣,不妨看看这个项目:
GitHub 仓库:github.com/wuuJiawei/T...
欢迎 Star、试用,也欢迎提 Issue 一起交流改进。