Kotlin 协程的“调度与拦截”机制

62 阅读5分钟

核心总览

  • Continuation:每个 suspend 在编译后都会多一个 Continuation,恢复时通过 resumeWith 继续执行“状态机”。
  • ContinuationInterceptor:协程上下文里的一个元素,拦截并包装原始 Continuation,决定在哪个线程/队列恢复(即“调度”)。
  • DispatchedContinuation:kotlinx-coroutines 内部对 Continuation 的包装,持有 dispatcher + context,在 resumeWith 时选择直接执行还是投递到队列

1) ContinuationInterceptor:怎么“拦截”与“调度”

ContinuationInterceptor 是一个 CoroutineContext.Element(Key 唯一),协程启动/挂起点创建 continuation 时,会调用:

val intercepted: Continuation<T> = interceptor.interceptContinuation(original)

你拿到的是一个被拦截后的 continuation(通常是 DispatchedContinuation)。其关键职责:

  1. 选择线程/队列

    • 通过 dispatcher.isDispatchNeeded(context) 判断是否需要切换;
    • 若需要,dispatcher.dispatch(context, Runnable) 把恢复任务投递到目标线程/事件循环。
  2. 恢复语义

    • resumeWith 与 resumeCancellableWith(可取消恢复)统一走拦截器;

    • 控制 inline 恢复(undispatched)异步派发 的策略。

上下文里只能有一个 ContinuationInterceptor(它通常就是 Dispatchers.Default/IO/Main 等之一)。


2) DispatchedContinuation:恢复路径怎么走

内部要点(概念化):

override fun resumeWith(result: Result<T>) {
    if (dispatcher.isDispatchNeeded(context)) {
        // 需要切线程/排队
        saveState(result)
        dispatcher.dispatch(context, thisAsRunnable)
    } else {
        // 同线程可直接执行(undispatched/inline fast-path)
        runUndispatched(result) // 期间会处理 ThreadContextElement 的安装/还原
    }
}
  • fast-path:当前就在目标“事件循环/线程”,走同线程 inline 恢复(减少切换和排队)。
  • dispatch-path:不在目标线程,就打包成任务投递给 dispatcher(如主线程 Looper、Default 池)。
  • 事件循环协作:为避免深度递归/栈爆,内部有轻量 event loop 控制(yield/Unconfined 时尤其重要)。

3) 常见 Dispatcher 的调度语义

  • Dispatchers.Default:CPU 密集型线程池(与 IO 共享底池但有并行度限制);

  • Dispatchers.IO:面向阻塞 I/O,更高并行度的视图(避免 I/O 堵住 CPU 池);

  • Dispatchers.Main:Android 主线程(Looper);

  • Dispatchers.Main.immediate:若当前就在主线程且处于正确事件循环,isDispatchNeeded=false,直接 inline 恢复;否则投递到主线程队列。

  • Dispatchers.Unconfined:不强制切换到任何固定线程:

    • 首次恢复在当前线程 inline 执行;
    • 发生挂起后,下一次恢复在哪个线程由真正唤醒你的挂起点决定(可能是 I/O 回调线程、定时器线程……)。
    • 只适合非常底层、明白其时序者;UI/业务逻辑中少用

4)withContext的线程切换与恢复

suspend fun <T> withContext(ctx: CoroutineContext, block: suspend () -> T): T
  • 进入时:若 ctx 的拦截器不同,创建新的 DispatchedContinuation 并切换到目标调度器。

  • 执行 block;期间遇到挂起点,保存现场并返回 COROUTINE_SUSPENDED。

  • 恢复时:由目标调度器决定在哪个线程执行余下代码。

  • 退出 withContext 时:还原到调用者上下文(包括线程局部,见下)。

优化:当 withContext(Dispatchers.Main.immediate) 且正处于主线程时,通常不派发,直接 inline(避免多余切换)。


5) 线程局部(ThreadLocal)如何随协程一起“切”

协程不总是回到同一线程,ThreadLocal 会丢失。为此有:

  • ThreadContextElement 接口:定义 updateThreadContext / restoreThreadContext。

    • 常见实现:kotlinx-coroutines 的 ThreadLocal.asContextElement()、SLF4J MDC 适配、Transaction/Tracing 等。
  • 拦截器在恢复前安装这些上下文,恢复后再还原,保证“看起来像在一个连续的线程上下文里”。


6) Start 模式与调度:DEFAULT / LAZY / ATOMIC / UNDISPATCHED

  • CoroutineStart.DEFAULT:立即调度;若可 inline(如 Main.immediate)则直接执行。
  • LAZY:直到 start/join/await 才启动。
  • ATOMIC:启动不可取消直到第一次挂起点(避免“刚启动就被取消”)。
  • UNDISPATCHED:在当前线程直接运行到首个挂起点,然后再交给目标调度器继续(减少一次派发)。

7)yield()、公平性与事件循环

  • yield():把自己重新排队到 dispatcher,给同队列的其他任务一个执行机会(避免“独占”)。
  • 某些 dispatcher(如主线程)通过事件循环保证“可重入调用”不至于无限递归(Unconfined/immediate 情况尤需)。

8) 取消、异常与调度的协作

  • 取消是通过在下一次恢复时注入 CancellationException 完成的;
  • resumeCancellableWith 会在恢复前检查 Job 状态;已取消则不再执行用户代码,而是走异常路径;
  • try/finally 被编译进状态机,保证 finally 必达(无论是正常返回、异常还是取消)。

9) 调试与“栈追踪恢复”

  • 因为是 stackless,原生调用栈很浅;库通过 Stacktrace Recovery挂起点链路拼回堆栈,便于定位。
  • 调试探针(DebugProbes)与日志上下文常借助拦截/线程上下文机制插入信息(协程 id、线程名等)。

10) 常见坑与实践

  1. Unconfined 线程漂移:一次挂起后,恢复线程不可预测;不要触碰 UI/线程局部资源。

  2. Main.immediate 重入:在主线程内层层 withContext(Main.immediate) 可能形成同步重入,注意可重入性/锁。

  3. 在锁内挂起:持有 synchronized/互斥锁期间 await/delay 会导致长时间持锁或死锁;把挂起点挪到锁外。

  4. 误以为 withContext(IO) 就一定立刻切线程:isDispatchNeeded 可能为 false(如已在同一池且允许 inline),所以不要依赖“切线程”作为内存可见性屏障,用 withContext 的语义保证即可。

  5. ThreadLocal 泄漏/错位:忘了用 asContextElement、自定义 ThreadContextElement,会出现上下文丢失。

实践

  • UI:默认用 Main,IO/CPU 分别在 IO/Default;必要时用 Main.immediate 减少切换;
  • 底层库/桥接回调:可用 Unconfined/UNDISPATCHED 优化首段执行,但不要把它们“冒泡”到业务层
  • 需要限流:Dispatcher.limitedParallelism(n) 为局部并行度设上限;
  • 频繁切换热路径:合并 withContext,或通过“批处理 + 单次切换”降低派发成本;
  • 线程局部:ThreadLocal.asContextElement(value) 或实现 ThreadContextElement。

迷你速查表

  • 拦截谁? ContinuationInterceptor 拦截并包装 Continuation。

  • 谁调度? dispatcher.isDispatchNeeded 决定 inline 还是 dispatch。

  • 主线程立刻跑? Main.immediate 在主线程可 inline。

  • 线程不固定? Unconfined 首次当前线程、其后由唤醒源决定。

  • 跨线程携带 ThreadLocal? 用 ThreadContextElement(如 asContextElement)。

  • 如何少一次切换? UNDISPATCHED 或 Main.immediate。

  • 公平让路? yield()。