协程上下文与调度器:掌控协程的执行环境

协程上下文,说白了就是协程运行时的“环境配置包”。它包含了你关心的所有调度器、任务名、异常处理器、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.DefaultCPU 核心数线程(至少2个)计算、排序、解析JSON最大并发 = 核心数(最小2)
Dispatchers.IO动态扩容线程池(上限64)网络请求、文件读写、数据库操作默认上限64,可用 limitedParallelism 调整
Dispatchers.MainAndroid 主线程 / Swing EventQueueUI 更新、页面跳转需要引入对应依赖(如 kotlinx-coroutines-android)
Dispatchers.Unconfined不固定极少使用,仅用于测试或内部实现协程可能在任意线程上恢复
小技巧: 如果你的项目同时进行大量 IO 和少量计算,可以混用 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 嵌套。

好了,现在我们把上下文、调度器、作用域这几个概念串起来,画一张图你就清楚了。

协程上下文与调度器结构图 协程上下文 Composition CoroutineContext (不可变集合) ContinuationInterceptor Dispatchers.Default Dispatchers.IO 等 Job SupervisorJob / Job 结构化并发节点 CoroutineName 调试/日志 CoroutineExceptionHandler 未捕获异常处理 ThreadContextElement MDC / 自定义数据 用 + 运算符组合:contextA + Dispatchers.IO + CoroutineName(“fetch”) 同类型元素遵循“后出现的覆盖先出现的”规则

从图上可以看出,上下文容器里装了几个核心元素:调度器(拦截器)、Job、名称、异常处理器、线程上下文元素等。我们平时写 launch(start = ...) { } 时,第二个参数 context 就是往这个容器里添东西。默认会从父协程继承一份完整上下文。

好了,到这一步你应该对协程上下文和调度器有了整体的感知。实际编码时,记住几条简单原则:

  • 调度器选型:IO 密集型用 Dispatchers.IO,CPU 密集型用 Dispatchers.Default,UI 操作用主调度器。
  • 作用域管理:永远不要用 GlobalScope 启动长期协程,为每个模块创建自定义作用域。
  • 异常隔离:用 SupervisorJob 避免子协程失败波及兄弟任务。
  • 上下文继承:子协程默认继承父上下文,但 + 运算符可以覆盖特定元素。