3. 协程上下文与调度器:Dispatchers.Main/IO/Default、自定义线程池

协程上下文和调度器,这两个概念可以说是协程的"任督二脉"。理解透了,协程泄漏问题基本就解决了一大半。我刚开始用协程时,就吃过调度器选错的亏。那会儿觉得反正都是异步,用哪个不一样?结果线上出现了ANR,排查了好久才发现是在 Dispatchers.Main 上做了耗时操作。

说白了,调度器决定了你的协程在哪个线程池里跑。选错了,轻则性能拉胯,重则直接泄漏或崩溃。这一章咱们就把调度器彻底掰开揉碎,看看每个调度器的脾气秉性,以及怎么自定义线程池来应对特殊场景。

3.1 协程上下文的核心概念

协程上下文,我个人习惯把它理解为一个"配置包"。协程运行时需要的所有环境信息,都装在这个包里。包括:

  • 调度器:决定协程在哪个线程池执行
  • Job:管理协程的生命周期
  • 异常处理器:处理未捕获的异常
  • 协程名称:调试时用来区分协程

上下文的组合方式很特别——用 + 运算符。比如:

val context = Dispatchers.IO + SupervisorJob() + CoroutineName("Worker")

这里有个关键点:上下文是不可变的。每次用 + 组合,都会生成一个新的上下文对象。为什么会这样设计?为了线程安全。你想想看,如果上下文可以随便改,多线程环境下就乱套了。

小技巧:调试协程问题时,别忘了给协程取名。我在项目中排查泄漏时,经常靠协程名称快速定位问题协程,比看那一串默认ID高效多了。

下面这张图展示了协程上下文与调度器的整体架构,以及调度器选择不当带来的风险路径:

协程上下文与调度器核心架构 协程上下文 (CoroutineContext) 包含调度器、Job、异常处理器等 协程调度器 (CoroutineDispatcher) 决定协程在哪个线程池执行 Dispatchers.Main 主线程 / UI 更新 Dispatchers.Default CPU 密集型任务 Dispatchers.IO IO 密集型任务 自定义线程池 特殊业务场景 ⚠ 调度器使用不当 → 协程泄漏、线程耗尽、ANR 选择正确的调度器是第一道防线

核心要点:协程上下文是整个协程运行环境的基石。调度器只是上下文中的一员,但却是最容易出问题的一个。理解上下文的组合规则,是避免泄漏的前提。

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("任务在自定义调度器上执行")
    // 你的业务逻辑
}

我建议你注意两点:

  1. 设置 isDaemon = true —— 否则线程池会阻止进程退出
  2. 记得关闭线程池 —— 不用的时候调用 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)
        }
    }
}
            ← 上一章
            📖 返回目录
            下一章 →