协程上下文与调度器:掌控协程的执行环境
协程上下文,说白了就是协程运行时的“环境配置包”。它包含了你关心的所有调度器、任务名、异常处理器、Job 等组件。我在项目中见过不少同事把上下文和调度器混为一谈,其实调度器只是上下文里的一个元素而已。
CoroutineContext.Element 接口。
1. 上下文的结构与组合
上下文通过 + 运算符组合。比如 Dispatchers.Default + CoroutineName("fetch") 会产生一个包含两个元素的上下文。这里的“+”其实是一个重载操作符,它会将右边的元素合并到左边的上下文中,如果有同类型元素,右边的会覆盖左边的。
我记得有一次排查线上问题,发现某个协程一直卡在 IO 线程池里。日志里明明指定了 Dispatchers.Main,但实际执行线程却是 IO Worker。后来发现是父协程的上下文里埋了一个 Dispatchers.IO 的覆盖,子协程的 withContext 没完全生效。嗯,从这里就能看出上下文继承的优先级规则有多重要:子协程默认继承父上下文,但用 + 添加的元素会覆盖父级中同类的元素。
// 组合示例
fun main() {
val context = Dispatchers.Default + CoroutineName("example")
println(context[CoroutineName]) // 输出: CoroutineName(example)
println(context[ContinuationInterceptor]) // 输出: DefaultDispatcher-worker-1
}
2. Dispatchers 调度器一览
调度器决定了协程跑在哪个线程池里。我个人的习惯是:CPU 密集用 Default,IO 密集用 IO,界面更新用 Main。Unconfined 我在生产代码中几乎不用,因为它不保证线程,容易出灵异问题。
| 调度器 | 线程池 | 典型场景 | 并发数/备注 |
|---|---|---|---|
Dispatchers.Default | CPU 核心数线程(至少2个) | 计算、排序、解析JSON | 最大并发 = 核心数(最小2) |
Dispatchers.IO | 动态扩容线程池(上限64) | 网络请求、文件读写、数据库操作 | 默认上限64,可用 limitedParallelism 调整 |
Dispatchers.Main | Android 主线程 / Swing EventQueue | UI 更新、页面跳转 | 需要引入对应依赖(如 kotlinx-coroutines-android) |
Dispatchers.Unconfined | 不固定 | 极少使用,仅用于测试或内部实现 | 协程可能在任意线程上恢复 |
withContext(Dispatchers.Default) 和 withContext(Dispatchers.IO)。但注意不要嵌套多层,否则线程窃取反而会降低吞吐。
3. withContext 切换调度器
我以前写过一段烂代码,在 IO 操作里直接调了 withContext(Dispatchers.Default) 做计算,结果两个操作都跑在 Default 池里,白白浪费了 IO 线程的并发能力。正确的做法应该是:让 IO 操作留在 IO 池,只把真正需要 CPU 的部分切换过去。
// 不推荐的写法
suspend fun fetchAndProcess(): Result {
val data = withContext(Dispatchers.Default) { // 这里把网络请求也拖进来了
api.fetch() // 本应跑在 IO 上
}
return process(data)
}
// 推荐写法
suspend fun fetchAndProcess(): Result {
val data = withContext(Dispatchers.IO) { // 明确指定 IO
api.fetch()
}
return withContext(Dispatchers.Default) { process(data) } // 计算部分单独切换
}
为什么要这么做?因为 Dispatchers.Default 和 Dispatchers.IO 底层共享同一个线程池,但默认的并行数不同。如果 IO 操作占据了 Default 的线程,计算任务就要排队等,整体性能反而下降。
4. 协程作用域:哪来的上下文
作用域其实就是协程的“生命周期容器”。全局的 GlobalScope 我用得很少,因为它会导致协程泄漏。项目里我更倾向自定义作用域(比如 CoroutineScope(Dispatchers.IO + SupervisorJob()))来管理一组协程的生命周期。
// 自定义作用域
class MyRepository {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob() + CoroutineName("RepoScope"))
fun loadData() {
scope.launch {
// 这里自动继承 Dispatchers.IO 和 SupervisorJob
}
}
fun clear() {
scope.cancel() // 取消所有协程
}
}
这里有个细节:SupervisorJob 能让子协程的失败不波及父协程。我平时写后台任务时几乎必用 SupervisorJob,除非我明确需要失败传播(比如一连串依赖操作)。
5. 结构化并发下的调度陷阱
我曾遇到过一个问题:在 coroutineScope 块里用了 launch(Dispatchers.IO) { ... },但块内的异常居然没有被外层捕获。为什么?因为 coroutineScope 会创建一个新的作用域,里面的子协程虽然继承了父上下文,但异常处理机制是独立的。如果你想让异常往上抛,记得用 withContext 而不是 launch。
withContext(Dispatchers.Main) 里 不要 直接启动协程(如 launch { }),因为外部协程的 Job 可能和内部协程的 Job 产生冲突。正确的做法是:在作用域层面就定义好调度器,或者用 withContext 嵌套。
好了,现在我们把上下文、调度器、作用域这几个概念串起来,画一张图你就清楚了。
从图上可以看出,上下文容器里装了几个核心元素:调度器(拦截器)、Job、名称、异常处理器、线程上下文元素等。我们平时写 launch(start = ...) { } 时,第二个参数 context 就是往这个容器里添东西。默认会从父协程继承一份完整上下文。
好了,到这一步你应该对协程上下文和调度器有了整体的感知。实际编码时,记住几条简单原则:
- 调度器选型:IO 密集型用
Dispatchers.IO,CPU 密集型用Dispatchers.Default,UI 操作用主调度器。 - 作用域管理:永远不要用
GlobalScope启动长期协程,为每个模块创建自定义作用域。 - 异常隔离:用
SupervisorJob避免子协程失败波及兄弟任务。 - 上下文继承:子协程默认继承父上下文,但
+运算符可以覆盖特定元素。