3. 协程上下文与调度器:Dispatchers.Main/IO/Default、自定义线程池
协程上下文和调度器,这两个概念可以说是协程的"任督二脉"。理解透了,协程泄漏问题基本就解决了一大半。我刚开始用协程时,就吃过调度器选错的亏。那会儿觉得反正都是异步,用哪个不一样?结果线上出现了ANR,排查了好久才发现是在 Dispatchers.Main 上做了耗时操作。
说白了,调度器决定了你的协程在哪个线程池里跑。选错了,轻则性能拉胯,重则直接泄漏或崩溃。这一章咱们就把调度器彻底掰开揉碎,看看每个调度器的脾气秉性,以及怎么自定义线程池来应对特殊场景。
3.1 协程上下文的核心概念
协程上下文,我个人习惯把它理解为一个"配置包"。协程运行时需要的所有环境信息,都装在这个包里。包括:
- 调度器:决定协程在哪个线程池执行
- Job:管理协程的生命周期
- 异常处理器:处理未捕获的异常
- 协程名称:调试时用来区分协程
上下文的组合方式很特别——用 + 运算符。比如:
val context = Dispatchers.IO + SupervisorJob() + CoroutineName("Worker")
这里有个关键点:上下文是不可变的。每次用 + 组合,都会生成一个新的上下文对象。为什么会这样设计?为了线程安全。你想想看,如果上下文可以随便改,多线程环境下就乱套了。
小技巧:调试协程问题时,别忘了给协程取名。我在项目中排查泄漏时,经常靠协程名称快速定位问题协程,比看那一串默认ID高效多了。
下面这张图展示了协程上下文与调度器的整体架构,以及调度器选择不当带来的风险路径:
核心要点:协程上下文是整个协程运行环境的基石。调度器只是上下文中的一员,但却是最容易出问题的一个。理解上下文的组合规则,是避免泄漏的前提。
3.2 三大内置调度器详解
Kotlin 提供了三个内置调度器,分别对应三种典型场景。我按使用频率给你捋一遍:
| 调度器 | 用途 | 底层线程 | 常见风险 |
|---|---|---|---|
| Dispatchers.Main | UI 操作、界面更新 | 主线程(单线程) | ANR、界面卡死 |
| Dispatchers.Default | CPU 密集型计算 | 线程池(核心数相关) | 资源竞争、线程饥饿 |
| Dispatchers.IO | 网络请求、文件读写 | 线程池(可扩展) | 线程数暴增、OOM |
Dispatchers.Main —— 说白了就是 Android 主线程。只有 UI 操作才应该用这个。我在项目中见过有人直接在 Dispatchers.Main 里做数据库查询,结果列表数据一多,直接 ANR。
Dispatchers.Default —— 线程数等于 CPU 核心数(至少 2 个)。适合做大量计算,比如图片处理、数据转换。注意,如果在这里面做 IO 操作,会白白占据计算线程,导致其他任务排队等着。
Dispatchers.IO —— 默认最大线程数是 64(实际是 max(64, 核心数 * 2))。适合网络请求、文件读写这种会阻塞线程的操作。但有个坑:如果你同时发起几百个 IO 请求,线程池会被撑爆。
警告:千万不要在 Dispatchers.Main 上执行任何耗时操作!我曾经排查过一个泄漏案例,原因是开发者在主线程上做了 3 秒的数据库写入,导致主线程卡死,协程任务队列无限堆积,最后 OOM。记住:Main 调度器只做 UI 操作,其他一律交给 IO 或 Default。
3.3 自定义线程池:什么时候需要?怎么用?
内置调度器虽然方便,但有些场景它们搞不定。比如:
- 你需要限制并发数,避免 IO 调度器把线程池撑爆
- 你需要给线程起特定名字,方便排查问题
- 你需要在协程和传统线程池之间共享资源
这时候就得自己动手了。Kotlin 提供了 asCoroutineDispatcher() 扩展方法,可以把任何 ExecutorService 转成调度器:
// 自定义:固定 4 个线程,带名字
val customDispatcher = Executors.newFixedThreadPool(4) { runnable ->
Thread(runnable, "CustomWorker-${nextId()}").apply { isDaemon = true }
}.asCoroutineDispatcher()
// 使用方式跟内置调度器一样
scope.launch(customDispatcher) {
log("任务在自定义调度器上执行")
// 你的业务逻辑
}
我建议你注意两点:
- 设置 isDaemon = true —— 否则线程池会阻止进程退出
- 记得关闭线程池 —— 不用的时候调用
close(),否则线程一直活着,造成泄漏
避坑指南:我曾经在项目里自定义了一个线程池,但忘记设置 isDaemon,结果测试环境一直有残留线程。后来用 Android Studio 的 Thread Dump 才发现。嗯,从那以后我每次自定义线程池都会加这句:"记得设守护线程"。
3.4 实战案例:调度器选择不当引发的泄漏
来看一个真实的泄漏场景。假设你在 Activity 里加载用户数据,需要从数据库和网络获取:
class ProfileActivity : AppCompatActivity() {
fun loadProfile() {
viewModelScope.launch(Dispatchers.Main) { // ❌ 问题出在这里
val user = database.queryUser() // 耗时 2 秒,阻塞主线程
val posts = api.fetchPosts() // 网络请求,又卡主线程
showProfile(user, posts)
}
}
}