TransmittableThreadLocal 解读:从 CRR 机制到上下文所有权

TransmittableThreadLocal 解读:从 CRR 机制到上下文所有权

很多关于 TransmittableThreadLocal(TTL) 的介绍都会说"它能把 ThreadLocal 带过线程池"。这句话很容易让人形成一个错误印象:任务提交时,TTL 会把上下文完整复制一份,执行线程拿到一份互不影响的副本。

理解 TTL,最好不要从"它帮我传了什么值"开始,而要追问三个时间点:什么时候捕获,什么时候安装,什么时候恢复。这个过程就是 CRR:Capture、Replay、Restore。

一次完整的 CRR 流程

假设提交线程在 Capture 时得到状态 S,执行线程进入命令前已经有状态 E。一次任务执行可以画成这样:

text 复制代码
提交线程:   S = capture()
                         |
执行线程:   E -> replay(S) -> 执行 command -> restore(E) -> E

Replay 并不是简单地把 S 写进执行线程。TTL 需要先保存 E,否则命令结束后无法把线程还原。命令无论正常返回还是抛异常,都应该经过 Restore。

这说明 CRR 解决的是线程边界上的"临时绑定"问题:执行期间看到一份指定的上下文,执行结束后线程回到进入前的状态。它没有承诺 value 本身是隔离的,也没有承诺执行期间对共享对象的修改可以被回滚。

这两个承诺经常被混在一起。比如下面的代码:

java 复制代码
CopyOnWriteArrayList<String> value = new CopyOnWriteArrayList<>();
ttl.set(value); // 使用ttl的默认实现

executor.execute(() -> ttl.get().add("changed"));

如果 Capture 复用了 value 的引用,Restore 之后绑定虽然恢复了,value 仍然包含 changed。这里使用 CopyOnWriteArrayList 是为了让列表本身可以安全地被并发读写;它不会让列表元素自动获得复制隔离。线程状态恢复和对象状态回滚是两回事。

copy 到底复制了什么

可以把多个 TTL 看成一张表:

text 复制代码
ThreadLocal identity key -> value reference

Capture 可能会创建一张新的绑定表,但表里的 value 不一定会新建。只有配置了 copier,TTL 才知道应该怎样构造执行侧的 value:

java 复制代码
TransmittableThreadLocal<CopyOnWriteArrayList<String>> ttl =
        TransmittableThreadLocal.withInitialAndCopier(
                CopyOnWriteArrayList::new,
                source -> new CopyOnWriteArrayList<>(source));

这个 copier 新建了一个线程安全的列表,但只复制了列表容器。若列表元素是可变对象,父任务和子任务仍可能共享同一个元素;元素自己的状态仍需要并发协议。线程安全容器不能替代 value 级隔离。

因此,讨论"TTL 有没有 copy"时,至少要分清三个层次:

text 复制代码
绑定表是否重新建立
value 对象是否重新建立
value 内部的可变对象是否隔离

第一层是 CRR 的工作范围。后两层是 copier 的工作范围,且最后一层不会自动发生。

copier 应该是一个只读输入、返回执行侧 value 的函数。它不应该修改源对象,也不应该依赖另一个 ThreadLocal 的瞬时状态。对于请求上下文,比较稳妥的做法是让上下文本身不可变,或者提供一个明确的复制入口:

java 复制代码
TransmittableThreadLocal<RequestContext> context =
        TransmittableThreadLocal.withInitialAndCopier(
                RequestContext::empty,
                RequestContext::copyForChild);

copyForChild 的语义需要由业务代码定义清楚:是浅复制、深复制,还是写时复制。不要把默认行为或者 clone() 当作通用方案。连接、锁、线程和缓存这类对象,本身就不适合放进 TTL。

为什么我更倾向使用 registerThreadLocal

应用代码通常直接使用 TtlRunnable 或 TTL executor wrapper。很多项目往往有自己的上下文容器,这时不必继承 TransmittableThreadLocal,可以注册一个普通 ThreadLocal:

java 复制代码
ThreadLocal<Relay> relay = ThreadLocal.withInitial(Relay::new);
TransmittableThreadLocal.Transmitter.registerThreadLocal(
        relay,
        current -> new Relay(current.currentValues()));

注册动作只告诉 TTL 两件事:这个 ThreadLocal 要参加 CRR,以及 Capture 时如何生成执行侧的值。它本身不会让任务跨线程传播;真正的传播仍然依赖 TTL wrapper、agent 或显式的 Transmitter 边界。

这种方式的价值在于,可以把"父级上下文"和"当前任务上下文"分开存放。典型做法是让父任务的 curMap 经过 copier 变成子任务的 parentMap,子任务再把自己的值写进新的 curMap:

text 复制代码
父任务 curMap --copier--> 子任务 unmodifiableMap(parentMap)
子任务自己的写入 ----------------> 子任务 curMap

这样做比直接替换整个上下文对象更容易解释嵌套任务的行为。子任务可以读取父级值,也可以建立自己的当前值,不会把两个层次混成一张可变 Map。

真正影响语义的是 Capture 时机

TTL 的各种 API 看起来都在"传递上下文",但它们最大的差别其实是 Capture 发生在哪里。

使用 TtlRunnable 时,Capture 发生在包装动作:

java 复制代码
Runnable wrapped = TtlRunnable.get(command);
executor.execute(wrapped);

如果 wrapper 创建后才修改 TTL,修改不会进入这份快照:

java 复制代码
Runnable wrapped = TtlRunnable.get(command); // 此处已经 Capture
// 这里再设置租户或 traceId,wrapped 看不到
executor.execute(wrapped);

TTL executor wrapper 把这一步放到 execute 或 submit 的入口,适合给一个 executor 统一处理任务提交。它解决的是边界一致性,不会改变 copier 的深度,也不会让普通 ThreadLocal 自动获得传播能力。

把捕获时机做成显式 API:ContextExecutors

我正在开发的结构化并发库 parallel-in-scope 构建在 Guava ListenableFuture 和 TTL 之上:库自己管理的任务路径在每次任务前后自动完成捕获与恢复;应用侧 callback 则由 ContextExecutors 承接,它把"Capture 时机"做成了两个显式的工厂方法。

两个工厂方法的差别正是捕获时机。默认的 directContextExecutor() 在每次 execute 调用点 Capture:

java 复制代码
Executor executor = ContextExecutors.directContextExecutor();
text 复制代码
execute(command-1) -> capture(A) -> A
execute(command-2) -> capture(B) -> B

它适合把 executor 当作一个可复用的同步适配器,调用点的上下文才是权威来源。

如果 ListenableFuture listener 属于注册者逻辑,也就是说注册的回调需要使用的是注册前代码的上下文,可以显式使用创建时捕获的 executor:

java 复制代码
Executor executor = ContextExecutors.registrationContextExecutor();
text 复制代码
executor = capture(A)
execute(command-1) -> A
execute(command-2) -> A

这时 Future 即使在别的线程完成,listener 仍然使用注册时的上下文。不要理解成 lazy 形式的上下文更新或 eager 形式的上下文过时,这里实际上是两种不同的上下文所有权:directContextExecutor() 属于每次执行,registrationContextExecutor() 属于创建或注册动作。

directContextExecutor() 的实现是无状态的共享实例:捕获、回放和恢复所需的状态都局限在当前 execute 调用中,因此可以被不同请求复用。registrationContextExecutor() 持有创建时的快照,必须按注册上下文创建,不能作为持有请求状态的全局单例。

CRR 的边界是命令,不是线程切换

从上一节的分析可以看出:directContextExecutor() 的 Capture、Replay 和 Restore 都发生在调用线程上,没有任何线程切换。这说明 CRR 绑定的边界其实是命令,线程切换只是最常见的外围事件。

这里的"边界"指一段有进入/退出纪律的执行区间:进入时安装一份指定状态,退出时恢复原状。回看开头那张时序图,replay(S) 与 restore(E) 夹住的正是 command 的执行,协议里没有任何一步检查"是否发生了线程切换"。跨线程场景容易让人把切换当成主角;实际上,切换决定的是问题在哪里出现,命令边界决定的才是机制在哪里起作用。

没有线程切换,三步一步不少,CRR 反而变得最容易观察------Restore 的效果立刻可见:

java 复制代码
String before = ttl.get();
executor.execute(() -> {
    ttl.set("inside");
});
// 这里恢复为 before

由此有两个容易忽略的推论:

一是复制成本照付。对 directContextExecutor() 来说,每次 execute 都会 Capture,copier 的调用次数与命令次数相关,而不是与线程数相关;同线程执行并不省掉复制。

二是对象身份会变。假设 copier 为每次 Capture 返回新的 CopyOnWriteArrayList,命令内的 ttl.get() 与外部的 value 就不是同一个对象。此时如果用 assertSameIdentity 检查传播,会把正确的复制误判为错误;应该断言值是否符合预期,以及命令结束后调用者的绑定是否恢复。

嵌套是同一条规则的叠加:内层 Capture 看到的是外层 Replay 后的状态;内层 Restore 先回到外层状态,外层结束后再回到最外层状态。只要每层都保存并恢复自己的进入状态,嵌套就不需要额外的全局清理协议。

ListenableFuture listener 怎么选 executor

ListenableFuture 的 listener 是上下文所有权最直接的应用场景,规则可以浓缩成一句话:上下文属于注册动作,就用 registrationContextExecutor();属于执行调用点,就用 directContextExecutor()。

listener 属于注册者时,在注册处创建执行器并传入:

java 复制代码
future.addListener(listener, ContextExecutors.registrationContextExecutor());

租户、请求号、trace 这类在注册时就已确定的上下文走这条路,无论 Future 最终在哪个线程完成。

listener 属于执行调用点时,用每次调用点捕获的默认执行器:

java 复制代码
future.addListener(listener, ContextExecutors.directContextExecutor());

它以 listener 被调度时的当前线程上下文为准,适合通用同步回调适配,但回答不了"这个 listener 是谁注册的"。

最后区分一个容易混淆的近亲:Guava 的 MoreExecutors.directExecutor() 不参与 TTL。它只负责在当前线程直接调用命令,没有 Capture、Replay 或 Restore。如果 callback 在完成线程中运行,它看到的就是完成线程的上下文;如果 callback 恰好在调用线程中运行,它看到的就是调用线程当时的上下文。

TTL 里应该放什么

TTL 适合传播任务需要知道的身份和策略,例如:

text 复制代码
requestId / tenantId / traceId / deadline / cancellation token / immutable options

它不适合替代资源管理。数据库连接、Socket、文件句柄、锁、线程和线程池都有自己的生命周期和所有权,放进 TTL 后反而更难判断谁负责关闭、谁可以并发访问。

可变上下文不是绝对不能使用,但必须把 copier 和并发协议写清楚。如果一个请求上下文很大,或者里面有无限增长的集合,频繁 Capture 还会带来实际的复制成本。这时应优先缩小上下文,或把大对象放到显式的共享结构中,只在 TTL 里传播 ID。

总结

TTL 的核心问题不是"怎么把值传过去",而是"这份上下文属于谁,以及在哪个时间点确定"。

如果上下文属于任务提交动作,就在提交点 Capture;如果上下文属于 listener 注册动作,就用 registrationContextExecutor() 这样的创建时捕获;如果上下文属于每次执行调用,就用 directContextExecutor() 这样的每次调用点捕获。之后再决定 copier 是否需要创建新 value,以及需要复制到多深。

很多典型错误几乎都是把如下这几个判断做错了,对号入座即可诊断:

  • 把浅复制当成深复制。比如 new CopyOnWriteArrayList<>(source) 新建了 value 对象(第二层),却没有隔离列表元素(第三层)。
  • Capture 太早。先创建 TtlRunnable,后设置租户或 traceId,任务自然看不到后面的设置;时机判断错了,值再怎么传都不对。
  • Capture 太晚。在 Future 完成线程里才为 listener 创建 wrapper,捕获到的是完成线程的上下文,而不是 listener 注册者的上下文;这是把注册者的所有权让给了完成线程。
  • 把 TTL 当作隐式返回值。命令内部对 TTL 的修改通常只应该影响当前作用域;需要把结果传回调用者时,应显式返回结果,或使用有明确同步协议的共享对象。CRR 只恢复线程绑定,不回滚共享对象已经发生的修改。
  • 全局复用创建时捕获的 executor。它会持有创建时的快照,既可能串用请求上下文,也可能延长请求对象的生命周期;注册上下文的 executor 必须按注册创建,不能做成全局单例。

总之,Capture 决定拿哪一刻的状态,copier 决定执行侧拿到什么对象,Replay/Restore 决定线程在命令前后如何变化。

相关推荐
喵个咪34 分钟前
GoWind 拆解:全链 ID 类型体系——int64 定版与前端字符串化契约
前端·后端·go
xys_6781 小时前
JavaEE通关手册:初识Java
java·开发语言
文丑颜不良啊1 小时前
LeetCode2461~2480题目解析
java·leetcode
喵个咪1 小时前
GoWind CMS 拆解:发布审批——多级推进、逐级角色与全程留痕
前端·后端·go
喵个咪1 小时前
GoWind Flow 拆解:一个 Go 工作流引擎与它的双模式设计器
前端·后端·go
喵个咪1 小时前
GoWind CMS 拆解:搜索三板斧——高亮、联想、同义词
后端·搜索引擎·go
喵个咪1 小时前
GoWind 拆解:AI Agent 编排引擎——波次并行、子图递归与人工审批
人工智能·后端·go
喵个咪2 小时前
GoWind Admin 拆解:打印模板设计器——从画布到服务端 PDF 的完整打印链路
前端·后端·go
文丑颜不良啊2 小时前
LeetCode2441~2460题目解析
java·leetcode