4. 协程上下文:CoroutineContext组成、Dispatcher、Job、CoroutineName等

协程上下文,说白了就是协程运行时的「环境配置包」。我刚开始学协程时,总觉得这概念很虚,直到有一次线上事故——子协程抛了个异常,把整个父协程炸了,才发现我对Context的理解太浅了。今天就带你把它的每一个零件拆开看看。

4.1 CoroutineContext 是什么?

CoroutineContext 本质是一个 键值对集合,但它不是普通的 Map。它支持 + 运算符组合,且每个元素都是独一无二的——同一个键只能有一个值。

核心元素只有这么几个:

元素类型键作用
CoroutineDispatcherContinuationInterceptor指定协程在哪个线程池执行
JobJob管理协程生命周期,父子关系
CoroutineNameCoroutineName调试时用的名称(强烈推荐加上)
CoroutineExceptionHandlerCoroutineExceptionHandler捕获未处理的异常(重点)

你想想看,日常写 launch 或 async 时,第一个参数就是 CoroutineContext。大多数时候我们传个 Dispatchers.IO 就完了,但其实背后还有一堆默认值在起作用。

💡 我的习惯: 生产环境里,我至少会显式设置 Dispatcher 和 CoroutineName。线上日志里搜 "CoroutineName" 定位问题,比翻堆栈快多了。

4.2 Dispatcher —— 线程池的指挥官

协程切换线程靠的是 Dispatcher。Kotlin 内置了四种:

  • Dispatchers.Default —— CPU 密集型任务用,线程数 = CPU 核心数(至少2)
  • Dispatchers.IO —— IO 密集型(网络、文件读写),线程数上限 64(JDK 里是 max(64, 核心数))
  • Dispatchers.Main —— Android 上的主线程 / JavaFX 的 UI 线程
  • Dispatchers.Unconfined —— 不指定,跟调用者线程(慎用!)

这里有个坑:Dispatchers.Default 和 Dispatchers.IO 共享线程池?嗯,确实共享底层池,但各自维护独立的队列。我遇到过同事在 Default 上跑网络请求,把 CPU 核占满了,导致 UI 线程的协程也卡顿——因为 Default 和 Main 不是同一个池,但 Default 的线程数如果不够,会影响全局调度。

经验法则:

  • 计算密集 → Default
  • 阻塞 IO → IO
  • UI 操作 → Main
  • 千万别在 Main 上做耗时操作,也别在 IO 上做大量计算

4.3 Job —— 协程的身份证和遥控器

每次 launch 返回的就是 Job。它记录了协程的状态:

状态说明isActiveisCompletedisCancelled
New创建但未启动(默认是立即启动)falsefalsefalse
Active运行中truefalsefalse
Completing内部等待子协程完成truefalsefalse
Cancelling正在取消falsefalsetrue
Cancelled已被取消falsetruetrue
Completed正常结束falsetruefalse

注意看 Completing 和 Cancelling 这两个中间状态。我曾经写过一个定时任务,在 finally 块里做清理,结果发现协程被取消时 finally 也会执行——因为 Cancelling 状态下,协程还在「挣扎」。如果你在 finally 里调了挂起函数,可能直接抛 CancellationException。

val job = scope.launch {
    try {
        delay(1000)
    } finally {
        // 这里也会执行,但再调 delay 会抛出 CancellationException
        withContext(NonCancellable) {
            // 非要执行不可的清理,用 NonCancellable 包裹
            releaseResource()
        }
    }
}
job.cancel()
⚠️ 我曾经踩过的坑: 用 cancel() 取消协程但忘了 join(),导致协程清理还没跑完,父协程就已经结束了。说白了两步走:先 cancel,再 join,或者直接用 cancelAndJoin()。

4.4 CoroutineName —— 调试利器

别看它小,关键时候能救命。线上日志几百兆,你搜 "coroutine" 能搜到一堆。加上名字后,直接搜你定义的名字就行:

scope.launch(CoroutineName("UserLoginTask") + Dispatchers.IO) {
    // 所有日志都会带上这个 name
}

如果你用的日志框架支持 MDC,还能把这个名字自动塞进日志上下文。我团队现在强制每个协程都必须带 CoroutineName。

4.5 组合与继承 —— + 运算符的魔法

两个 Context 用 + 组合,右边覆盖左边:

val ctx1 = Dispatchers.Default + CoroutineName("A")
val ctx2 = Dispatchers.IO + Job()
val combined = ctx1 + ctx2  // 结果: Dispatchers.IO + CoroutineName("A") + Job()

子协程会自动继承父协程的 Context,但可以覆盖。注意 Job 会创建新的父子关系——子 Job 的 parent 指向父 Job。这也是为什么取消父协程会递归取消所有子协程。

我当年犯过一个错:在父协程里用 Job() 创建了一个新 Job 传给子协程,心想「独立控制」。结果父协程取消了,子协程因为 parent 没设置,反而活了下来——变成了「孤儿协程」。

💡 正确做法: 如果不想要父子关系,用 SupervisorJob()。它仍然是父 Job,但子协程的异常不会向上传播。这也是 supervisorScope 的原理。

4.6 实战避坑指南

  1. 不要用 Unconfined —— 它不保证线程,行为难预测。我只在单元测试里用它。
  2. 用 newSingleThreadContext 小心泄露 —— 记得 close(),否则线程永远不释放。
  3. ExceptionHandler 只在根协程有效 —— 子协程抛异常会优先被父协程的 handler 处理,但 supervisorScope 例外。
  4. Job 是唯一的,同一个 Job 不能重用 —— 如果重复 assign 同一个 Job 对象,后面会覆盖前面。

最后放一张我手绘的上下文结构图,你对着看就明白了:

CoroutineContext 组成结构 CoroutineContext 键值对集合 + 组合 CoroutineDispatcher Default / IO / Main Unconfined Job 生命周期 | 父子关系 SupervisorJob CoroutineName 调试 & 日志 线上定位 ExceptionHandler 捕获未处理异常 根协程才生效 💡 组合规则 用 + 合并,右边覆盖左边 子协程继承父 Context,Job 生成父子链 实际使用中,最少组合:Dispatcher + CoroutineName + Job (自动生成)

这张图里,中心是 CoroutineContext,它聚合了 Dispatcher、Job、CoroutineName 和 ExceptionHandler。注意右侧虚线框里我特意标了 ExceptionHandler 只对根协程生效——这是生产环境最容易忽略的点。你想想看,一个 launch 嵌套三个 async,最里面的 ExceptionHandler 可能根本没被调用。解决方案是用 supervisorScope 或者每个子协程单独 try-catch。