协程上下文与调度器:IO、Main、Default、Unconfined

协程上下文这玩意儿,说白了就是协程运行时的"环境配置"。我刚开始接触时也觉得抽象,直到有一次线上出现线程爆炸——嗯,就是调度器选错了。今天咱们把几个核心调度器掰开揉碎,再聊聊怎么把上下文组合起来用。

1. 调度器家族:四兄弟各有脾气

Kotlin 提供了四个内置调度器,每个都绑定特定的线程池或线程。我把它比作四个性格迥异的打工人——你派错活,项目就得延期。

调度器线程来源典型场景注意点
Dispatchers.Main主线程(Android UI线程)更新UI、LiveData赋值不能在非UI环境下用,会抛异常
Dispatchers.IO线程池(上限64个)网络请求、文件读写、数据库操作别用它做CPU密集计算
Dispatchers.Default线程池(等于核心数,至少2)列表排序、JSON解析、图片缩放阻塞操作会占满线程池
Dispatchers.Unconfined不切线程(继承调用者线程)极少用,调试或临时任务线程不可控,容易出bug

2. 实战:每个调度器该怎么用?

先看一段代码。我写了个模拟网络请求 + 数据处理 + 更新UI的流程:

// 错误示范:在IO里做CPU密集计算
viewModelScope.launch(Dispatchers.IO) {
    val json = fetchFromNetwork()        // 网络IO,OK
    val list = sortLargeList(json)       // 排序是CPU密集,占用了IO线程!
    withContext(Dispatchers.Main) {
        adapter.submitList(list)
    }
}

你想想看:IO线程池虽然能扩展到64个,但如果你在里面跑排序,其他IO任务就得排队。我在项目里见过一个同事这样写,结果列表加载慢得被吐槽……后来改成 Dispatchers.Default 单独做排序,流畅多了。

正确的做法:

viewModelScope.launch(Dispatchers.Main) {
    val json = withContext(Dispatchers.IO) { fetchFromNetwork() }
    val list = withContext(Dispatchers.Default) { sortLargeList(json) }
    adapter.submitList(list)             // 已经在Main线程
}

核心原则:

  • Main:只做UI操作,不阻塞
  • IO:处理阻塞式IO(网络、文件、数据库)
  • Default:处理CPU密集型计算
  • Unconfined:能不碰就别碰

3. 上下文组合:不止调度器

协程上下文其实是个 键值对集合,调度器只是其中一个元素。常用的还有:

  • Job:控制协程的生命周期
  • CoroutineName:调试时给协程起名字
  • CoroutineExceptionHandler:全局异常处理

组合方式用 + 运算符:

val customContext = Dispatchers.IO + 
                    CoroutineName("fetch-user") +
                    SupervisorJob() +
                    CoroutineExceptionHandler { _, e -> 
                        logError(e) 
                    }

coroutineScope.launch(customContext) {
    // 这个协程用IO调度器,名字叫fetch-user,父Job是SupervisorJob
    // 如果内部抛出未捕获异常,会走上面的handler
}

我的习惯:在复杂协程链里,一定给每个 async 加上 CoroutineName。有一次排查 "图片加载失败" 的问题,就是靠名字在日志里定位到是哪个协程被取消了。这招对调试混合泄漏特别管用。

4. 避坑指南:Unconfined 和线程切换陷阱

Dispatchers.Unconfined 是调度器里的"野马"。它不会切线程——启动时在哪个线程,代码就在哪个线程跑,直到遇到第一次挂起。挂起恢复后,又跑到那个挂起的线程上。

我曾经 在一个demo里用Unconfined测试,结果恢复后跑到了RxJava的调度线程上,直接把UI更新抛到了非主线程……从那以后我再也没在生产环境用过它。

// 不要这样写
launch(Dispatchers.Unconfined) {
    // 第一段代码在调用者线程执行(可能是Main)
    updateUI()                           // OK
    delay(100)                           // 挂起
    // 恢复后可能跑到别的线程,updateUI2()可能不在Main上!
    updateUI2()                          // 危险!
}

警告:Dispatchers.Unconfined 只在极少数场景(如实现某些作用域边界)有用,日常开发 禁止使用。如果你不确定自己是否需要用,那一定不需要。

5. 组合上下文的优先级规则

当多个上下文组合时,后加入的覆盖前者。比如:

val ctx = Dispatchers.Main + Dispatchers.IO   // 结果是IO,因为IO覆盖了Main
val ctx2 = Dispatchers.IO + CoroutineName("A") + Dispatchers.Default  // 结果是Default

这个特性容易踩坑。我见过有人这样写:

launch(Dispatchers.Main + Dispatchers.IO) {   // 你以为在Main?其实在IO!
    // ...
}

正确做法:用 withContext 显式切换。

协程上下文与调度器核心结构 协程上下文 (CoroutineContext) Dispatchers.IO 网络IO / 文件读写 Dispatchers.Main Android 主线程 Dispatchers.Default CPU 密集型计算 Dispatchers.Unconfined 不切线程 (慎用) Job 生命周期控制 CoroutineName 调试命名 异常处理器 全局异常处理 组合上下文: val ctx = Dispatchers.IO + CoroutineName("fetch") + SupervisorJob() 后加入的覆盖前者,建议用 withContext 显式切换调度器 ✅ 正确用法 IO → 耗时IO操作 Default → 排序/解析 Main → 更新UI ❌ 错误用法 IO里做排序(占用IO池) Main里做网络请求 随便用Unconfined 💡 泄漏排查提示 用CoroutineName标记协程 检查是否用了全局Scope 确保cancel能传播到子协程

6. 总结几个实操要点

  • 每个 launch 或 async 都可以单独指定上下文,但更推荐在最外层作用域统一设置公共参数(如异常处理器),内部用 withContext 临时切调度器。
  • 调试泄漏时,记得给协程加 CoroutineName。我一般在每个 viewModelScope.launch 里都加一句,配合log看生命周期,瞬间定位问题。
  • 小心 SupervisorJob 和普通 Job 的区别——前者子协程失败不会取消父级,后者会。如果你用 coroutineScope 做并行任务,子任务失败会导致兄弟任务被取消,这时候可以考虑 supervisorScope。

一句话口诀:IO做苦力,Default算数,Main画界面,Unconfined别碰。