为什么要“线程上下文”
- 协程会在不同线程间切换:一次 suspend/resume 可能从 A 线程恢复到 B 线程。
- 传统 ThreadLocal 只跟线程走,不跟协程走 ⇒ 一旦线程切换就“丢上下文”(如日志 MDC、租户/会话、traceId)。
- ThreadContextElement 的目标:把“线程本地”的信息,与协程生命周期绑定,在每次挂起/恢复时注入/还原到当前线程。
线程上下文由谁组成?
协程上下文 CoroutineContext 是一堆 Element 的组合:
- 常见内建:Job、CoroutineDispatcher、CoroutineName、CoroutineExceptionHandler…
- ThreadContextElement :声明“当协程切换线程时要怎样处理某个 ThreadLocal/线程状态”。
快速使用:把ThreadLocal变成协程上下文
1) 最简:ThreadLocal.asContextElement(value)
val TL_USER = ThreadLocal<String>()
val ctx = TL_USER.asContextElement(value = "alice")
launch(ctx + Dispatchers.Default) {
// 无论切到哪个线程,这里 TL_USER.get() 都是 "alice"
println(TL_USER.get())
}
要点
-
value 在创建 element 的那一刻被捕获;如果后续想换值,用 withContext(TL.asContextElement(new)) { … } 包起来。
-
适合:MDC、租户ID、traceId、Locale 等“只读或阶段性只增不改”的上下文。
2) 日志 MDC(推荐库)
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:<version>")
withContext(MDCContext(mapOf("traceId" to traceId))) {
log.info("hello") // 任意线程都带 traceId
}
MDCContext 内部就是 ThreadContextElement,专为 SLF4J MDC 适配。
自定义实现:完全掌控注入/还原
class TxContext(
private val threadLocal: ThreadLocal<Tx>,
private val tx: Tx
) : ThreadContextElement<Tx?>, CoroutineContext.Element {
companion object Key : CoroutineContext.Key<TxContext>
override val key: CoroutineContext.Key<*> get() = Key
// 当协程切换到某线程执行之前调用:把需要的值“放进”该线程
override fun updateThreadContext(context: CoroutineContext): Tx? {
val old = threadLocal.get()
threadLocal.set(tx)
return old // 返回“旧值”,用于恢复
}
// 挂起/切出或完成后调用:把线程恢复到原值
override fun restoreThreadContext(context: CoroutineContext, oldState: Tx?) {
if (oldState == null) threadLocal.remove() else threadLocal.set(oldState)
}
}
使用:
val ctx = TxContext(TL_TX, currentTx)
withContext(ctx) { doInTx() }
与调度、withContext、flowOn的关系
-
CoroutineDispatcher(如 Dispatchers.IO/Default)只决定在哪个线程池执行;不携带你的 ThreadLocal。
-
withContext(ctx + dispatcher) :在这段代码的执行期间,ThreadContextElement 会在每次恢复时注入对应的 ThreadLocal。
-
flowOn(…) :会把 flowOn 之上的上游放到新的调度器+协程执行(相当于切了“子协程边界”)。
- 想让 ThreadLocal 在 上游/下游都生效:把 ThreadContextElement 放在外层 Scope或各自 withContext。
- 不同段(flowOn 上下游)是不同协程,但会继承父上下文(除非你显式覆盖)。
与ThreadLocal的差异与陷阱
不要直接依赖 ThreadLocal 在协程里“自动同步”:
TL.set("alice")
launch(Dispatchers.Default) { println(TL.get()) } // 可能是 null(切到别的线程)
正确:用 asContextElement 或自定义 ThreadContextElement。
常见陷阱:
-
动态变更未传播:asContextElement(value) 捕获的是那一刻的值;之后你 TL.set(new) 并不会“自动更新”协程上下文。
- 解决:用 withContext(TL.asContextElement(new)) { … } 包裹变化范围;或把“当前值”放进协程上下文中的自定义 Element,逻辑层读写它而不是直接写 ThreadLocal。
-
可变对象引用:把 Map 之类可变对象放进 ThreadLocal,多个线程复用同一引用可能产生竞态。
- 解决:用不可变快照(Map.copyOf()),或写时复制。
-
性能:每次挂起/恢复都会触发更新/还原。高频 suspend 热路径要精简上下文元素,避免把几十个 ThreadLocal 都挂进去。
-
阻塞桥接:withContext(Dispatchers.IO) 里调阻塞库 ok;但如果阻塞库内部再起线程,不会自动带上你的协程上下文(那是库的线程)。这时要靠库的上下文传播机制或你显式传参。
与StateFlow/SharedFlow/Channel的配合
- 这些是数据通道;上下文传播靠协程上下文,与“流类型”正交。
- 在 收集/发送 的协程上添加 ThreadContextElement,即可在回调/处理逻辑中获取 ThreadLocal。
- flowOn 切段会新建协程:记得在新的段继承/叠加同样的上下文(或放到父 Scope)。
实战范式
A) traceId 贯穿一条链(含flowOn)
val traceTL = ThreadLocal<String>()
val traceCtx = traceTL.asContextElement("req-123")
suspend fun pipeline() = withContext(traceCtx) {
sourceFlow()
.map { doStage1(it) } // traceTL 可用
.flowOn(Dispatchers.IO + traceCtx) // 上游在 IO,也带 trace
.map { doStage2(it) } // 下游段同样可读
.collect { sink(it) }
}
B) 每步换 traceId(子范围)
suspend fun handle(req: Req) {
val id = genTraceId(req)
withContext(traceTL.asContextElement(id)) {
process(req)
}
}
C) 与 SLF4J MDC
withContext(MDCContext(mapOf("reqId" to id))) {
log.info("start")
// 任何线程打印都带 reqId
}
最佳实践清单
- 优先用库:日志用 MDCContext,Reactor 用 ReactorContext(kotlinx-coroutines-reactor),不要重复造轮子。
- 局部化上下文:变化范围用 withContext(element) 包住,避免“全局 ThreadLocal”。
- 只放必要信息:减少挂起/恢复的上下文切换开销。
- 不可变快照:把 Map/集合做不可变副本放入上下文。
- 避免吞取消:写自定义 ThreadContextElement 时不涉及取消,但你的业务代码要让取消穿透(CancellationException 直接抛)。
- 与测试结合:单测里断言 ThreadLocal.get() 在协程切换后仍正确,防回归。
一句话总结
协程会在多线程间奔跑,ThreadLocal 天然不跟得上。把需要的“线程本地状态”做成 ThreadContextElement(或 asContextElement/库适配器),让它在每次挂起/恢复时注入/还原即可;变化范围用 withContext 局部化,跨 flowOn 段在新协程继续携带同一上下文。这样既拿到“像线程一样的上下文体验”,又保留协程的高并发与可控开销。