线程上下文与 ThreadContextElement

53 阅读4分钟

为什么要“线程上下文”

  • 协程会在不同线程间切换:一次 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。

常见陷阱:

  1. 动态变更未传播:asContextElement(value) 捕获的是那一刻的值;之后你 TL.set(new) 并不会“自动更新”协程上下文。

    • 解决:用 withContext(TL.asContextElement(new)) { … } 包裹变化范围;或把“当前值”放进协程上下文中的自定义 Element,逻辑层读写它而不是直接写 ThreadLocal。
  2. 可变对象引用:把 Map 之类可变对象放进 ThreadLocal,多个线程复用同一引用可能产生竞态

    • 解决:用不可变快照(Map.copyOf()),或写时复制。
  3. 性能:每次挂起/恢复都会触发更新/还原。高频 suspend 热路径要精简上下文元素,避免把几十个 ThreadLocal 都挂进去。

  4. 阻塞桥接: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 段在新协程继续携带同一上下文。这样既拿到“像线程一样的上下文体验”,又保留协程的高并发与可控开销。