4. 协程上下文:CoroutineContext组成、Dispatcher、Job、CoroutineName等
协程上下文,说白了就是协程运行时的「环境配置包」。我刚开始学协程时,总觉得这概念很虚,直到有一次线上事故——子协程抛了个异常,把整个父协程炸了,才发现我对Context的理解太浅了。今天就带你把它的每一个零件拆开看看。
4.1 CoroutineContext 是什么?
CoroutineContext 本质是一个 键值对集合,但它不是普通的 Map。它支持 + 运算符组合,且每个元素都是独一无二的——同一个键只能有一个值。
核心元素只有这么几个:
| 元素类型 | 键 | 作用 |
|---|---|---|
| CoroutineDispatcher | ContinuationInterceptor | 指定协程在哪个线程池执行 |
| Job | Job | 管理协程生命周期,父子关系 |
| CoroutineName | CoroutineName | 调试时用的名称(强烈推荐加上) |
| CoroutineExceptionHandler | CoroutineExceptionHandler | 捕获未处理的异常(重点) |
你想想看,日常写 launch 或 async 时,第一个参数就是 CoroutineContext。大多数时候我们传个 Dispatchers.IO 就完了,但其实背后还有一堆默认值在起作用。
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。它记录了协程的状态:
| 状态 | 说明 | isActive | isCompleted | isCancelled |
|---|---|---|---|---|
| New | 创建但未启动(默认是立即启动) | false | false | false |
| Active | 运行中 | true | false | false |
| Completing | 内部等待子协程完成 | true | false | false |
| Cancelling | 正在取消 | false | false | true |
| Cancelled | 已被取消 | false | true | true |
| Completed | 正常结束 | false | true | false |
注意看 Completing 和 Cancelling 这两个中间状态。我曾经写过一个定时任务,在 finally 块里做清理,结果发现协程被取消时 finally 也会执行——因为 Cancelling 状态下,协程还在「挣扎」。如果你在 finally 里调了挂起函数,可能直接抛 CancellationException。
val job = scope.launch {
try {
delay(1000)
} finally {
// 这里也会执行,但再调 delay 会抛出 CancellationException
withContext(NonCancellable) {
// 非要执行不可的清理,用 NonCancellable 包裹
releaseResource()
}
}
}
job.cancel()
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 没设置,反而活了下来——变成了「孤儿协程」。
4.6 实战避坑指南
- 不要用 Unconfined —— 它不保证线程,行为难预测。我只在单元测试里用它。
- 用 newSingleThreadContext 小心泄露 —— 记得 close(),否则线程永远不释放。
- ExceptionHandler 只在根协程有效 —— 子协程抛异常会优先被父协程的 handler 处理,但 supervisorScope 例外。
- Job 是唯一的,同一个 Job 不能重用 —— 如果重复 assign 同一个 Job 对象,后面会覆盖前面。
最后放一张我手绘的上下文结构图,你对着看就明白了:
这张图里,中心是 CoroutineContext,它聚合了 Dispatcher、Job、CoroutineName 和 ExceptionHandler。注意右侧虚线框里我特意标了 ExceptionHandler 只对根协程生效——这是生产环境最容易忽略的点。你想想看,一个 launch 嵌套三个 async,最里面的 ExceptionHandler 可能根本没被调用。解决方案是用 supervisorScope 或者每个子协程单独 try-catch。