ThreadForge 源码解读一:ThreadScope 如何把并发任务放进清晰边界?

前言

最近有读者对 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 要不要取消
  • 整体超时了,谁负责发中断
  • 任务里带着 traceIdrequestId,切线程后怎么传
  • 某个任务卡住了,整个并发阶段怎么退出
  • 多个任务一起失败,异常怎么汇总
  • 业务结束后,任务和调度资源会不会漏掉

这些问题放在一起看,往往就会发现:

并发代码里,完整的生命周期管理太难处理。

这就是 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. 整体结构

classDiagram class ThreadScope { -Queue~Task~ tasks -Queue~ScheduledTask~ scheduledTasks -Deque~Runnable~ deferred -DefaultCancellationToken token -DelayScheduler delayScheduler -Scheduler scheduler -FailurePolicy failurePolicy -RetryPolicy retryPolicy -Duration deadline -ThreadHook hook -ScopeMetrics metrics +open() ThreadScope +submit(...) Task +await(...) Outcome +awaitAll(...) List +schedule(...) ScheduledTask +defer(Runnable) +joiner() ScopeJoiner +close() } class Task { -CompletableFuture future -AtomicReference state -AtomicReference runnerThread +await() +cancel() +state() } class Scheduler { +detect() +commonPool() +fixed(int) +priority(int) +virtualThreads() } class CancellationToken { +isCancelled() +throwIfCancelled() } class ScopeMetrics { +recordStart() +recordTerminal() +snapshot() } ThreadScope --> Task ThreadScope --> Scheduler ThreadScope --> CancellationToken ThreadScope --> ScopeMetrics

这张图建议先看关系,不着急抠每个字段。

我们可以先抓住这三点:

  • ThreadScope 在中间,说明它是总入口
  • TaskSchedulerCancellationTokenScopeMetrics 都围着它转
  • 每个能力都和 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(...),调用方恐怕都讲不清预期了。

而且维护的人看到这种代码,也会立刻皱眉头罢! 🤣

作用域配置先确定好后,再开始执行任务。

stateDiagram-v2 [*] --> Configurable: ThreadScope.open() Configurable --> Configurable: withScheduler / withDeadline / withFailurePolicy Configurable --> Locked: submit / schedule Locked --> Running: task running Running --> Awaiting: await Awaiting --> Closing: close Running --> Closing: close Closing --> Closed

再结合这张状态图,其实就是"先定规则再执行"的可视化版本:

  • open() 之后,scope 先处于可配置阶段
  • 一旦开始 submitschedule,配置就锁住
  • 后面进入运行、等待和关闭阶段

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(...) 给单个任务挂上 timeout
  • tasks.add(task) 把任务正式登记进 scope

后面才交给调度器:

java 复制代码
scheduler.executor().execute(...)

简单描述就是:

先登记,再执行。

只要任务进入了 scope,后续的执行、失败、取消和清理,都会由 scope 统一承接。

flowchart TD A[调用 scope.submit] --> B[校验 scope 未关闭] B --> C[锁定配置 configLocked] C --> D[尝试获取并发信号量] D --> E[创建 CompletableFuture] E --> F[创建 Task] F --> G[创建 TaskInfo] G --> H[捕获 ExecutionContextCarrier] H --> I[注册任务级 timeout] I --> J[加入 tasks 队列] J --> K[交给 Scheduler 执行] K --> L[runTask]

结合流程图来看,这里把 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:其他任务继续跑,失败放进 Outcome
  • IGNORE_ALL:失败直接忽略

最终结果通过 Outcome 返回:

java 复制代码
new Outcome(taskList.size(), succeeded, cancelled, failures)

它对应的就是整个并发阶段的完成情况,可以结合着看下面这个流程图:

flowchart TD A[await tasks] --> B[逐个等待 Task] B --> C{Task 成功?} C -->|是| D[成功数 +1] C -->|取消| E[取消数 +1] C -->|失败| F{FailurePolicy} F -->|FAIL_FAST| G[取消其他任务] G --> H[立即抛出异常] F -->|CANCEL_OTHERS| I[取消其他任务] I --> J[记录失败] F -->|COLLECT_ALL| K[记录失败并继续等待] F -->|SUPERVISOR| L[记录失败但不取消其他任务] F -->|IGNORE_ALL| M[忽略失败] D --> N[继续等待] J --> N K --> N L --> N M --> N N --> O{全部结束?} O -->|否| B O -->|是| P[返回 Outcome 或抛 AggregateException]
  • 左边在等任务
  • 中间按结果分流
  • 右边按失败策略处理

一直读到这里后,应该就能明显地感觉到 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,所以无论谁如果想无限等下去,都会在这里被拦下来。

sequenceDiagram participant User participant Scope as ThreadScope participant Delay as DelayScheduler participant Token as CancellationToken participant Task User->>Scope: open().withDeadline(3s) Scope->>Delay: schedule(deadlineTrigger) User->>Scope: submit(task) Scope->>Task: execute Delay-->>Scope: deadline reached Scope->>Scope: triggerDeadline() Scope->>Token: cancel() Token-->>Task: throwIfCancelled / interrupt User->>Scope: await() Scope-->>User: ScopeTimeoutException

再结合这张时序图一起来看:

  1. 用户创建了一个带 deadline 的 scope
  2. scope 顺手挂上 deadline 触发器
  3. 任务开始执行
  4. 时间一到,DelayScheduler 回调 triggerDeadline()
  5. token 被取消,任务开始感知取消
  6. 用户再去 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 个动作:

  1. 先取消作用域令牌
  2. 再取消所有定时任务
  3. 再取消所有未完成普通任务
  4. 执行 defer(...) 注册的清理动作
  5. 取消 deadline 监视任务
  6. 处理调度器关闭

两个设计点尤其好用:

  • defer(...) 采用 LIFO,后注册先执行
  • 外部传入的执行器不会被随手关掉,只有 owned scheduler 才会在这里关闭
flowchart TD A[scope.close] --> B[CAS 标记 closed] B --> C[token.cancel] C --> D[取消 scheduledTasks] D --> E[取消未完成普通 Task] E --> F[执行 deferred 清理动作] F --> G[取消 deadline trigger] G --> H[关闭 owned scheduler] H --> I[关闭 owned delayScheduler] I --> J{是否有异常?} J -->|有| K[抛出 primary exception] J -->|无| L[正常结束]

这张图看着很像清场流程,也确实就是清场流程:

  • 先发取消信号
  • 再停定时任务
  • 再停普通任务
  • 再跑清理逻辑
  • 最后关调度器

先发令,再清场,最后关门。

最后

通篇看下来,应该就能理解了, ThreadScope 最终建立的是一个完整的并发生命周期边界:

  • 任务在这里提交
  • 失败在这里汇合
  • 取消在这里传播
  • 超时在这里触发
  • 清理在这里收口
  • 观测也在这里统一处理

那么这篇就结束了。

下一篇继续深入探究:从 submit(...)runTask(...),一个 Task 会经历什么有趣的流程。

最后的最后。

如果你正在写 Java 并发代码,或者对结构化并发、任务生命周期管理感兴趣,不妨看看这个项目:

GitHub 仓库:github.com/wuuJiawei/T...

欢迎 Star、试用,也欢迎提 Issue 一起交流改进。

相关推荐
洛_尘1 小时前
Python 5:使用库
java·前端·python
程序员小假2 小时前
HTTP3 性能更好,为什么内网微服务依然多用 HTTP2?HTTP2 内网优势是什么?
java·后端
Mr数据杨2 小时前
【Codex】用教案主体模块沉淀标准化教学设计内容
java·开发语言·django·codex·项目开发
苍煜2 小时前
RocketMQ系列第三篇:Java原生基础使用实操,手把手写生产者消费者Demo
java·rocketmq·java-rocketmq
Andya_net3 小时前
Java | Java内存模型JMM
java·开发语言
182******20833 小时前
2026年java后端还有机会吗?还能找到工作吗?
java·开发语言
XS0301063 小时前
Java基础 map集合
java·哈希算法·散列表
凤山老林4 小时前
从0到1搭建企业级权限管理系统:Spring Boot + JWT + RBAC实战指南
java·spring boot·后端·权限管理·rbac
小程故事多_804 小时前
[大模型面试系列] 深度解析ReAct框架,大模型Agent的“思考+行动”底层逻辑
人工智能·react.js·面试·职场和发展·智能体