协程上下文与调度器: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 显式切换。
6. 总结几个实操要点
- 每个
launch或async都可以单独指定上下文,但更推荐在最外层作用域统一设置公共参数(如异常处理器),内部用withContext临时切调度器。 - 调试泄漏时,记得给协程加
CoroutineName。我一般在每个viewModelScope.launch里都加一句,配合log看生命周期,瞬间定位问题。 - 小心
SupervisorJob和普通Job的区别——前者子协程失败不会取消父级,后者会。如果你用coroutineScope做并行任务,子任务失败会导致兄弟任务被取消,这时候可以考虑supervisorScope。
一句话口诀:IO做苦力,Default算数,Main画界面,Unconfined别碰。