调度器与线程:Dispatchers.Main、IO、Default、Unconfined详解

协程跑在哪个线程上?这个问题,说白了就是调度器在管。

我刚开始学协程时,以为它跟线程池差不多。后来踩了几个坑才明白——调度器是协程的「交通指挥员」,它决定了你的代码在哪个车道跑。

这节课,咱们把 Kotlin 提供的四个核心调度器掰开揉碎讲清楚。

Dispatchers.Main:主线程调度器

这个调度器专门用于 UI 操作。在 Android 上,它对应主线程;在桌面或服务端,它对应事件调度线程。

核心特点:

  • 只能在有 Looper 的线程上运行
  • Android 中默认绑定主线程
  • 不适合做耗时操作
// 典型用法:更新 UI
lifecycleScope.launch(Dispatchers.Main) {
    val result = fetchData()  // 这里会报错!网络请求不能在主线程
    textView.text = result
}

嗯,这里要注意。上面这段代码如果直接跑,会 crash。因为 fetchData() 如果是网络请求,Android 不允许在主线程执行。

正确的做法是:

lifecycleScope.launch {
    val result = withContext(Dispatchers.IO) { fetchData() }
    textView.text = result  // 自动回到 Main
}

我个人习惯把 Dispatchers.Main 当作「终点站」——所有 UI 更新都放在这里,但绝不在这里做耗时操作。

Dispatchers.IO:IO 密集型任务调度器

这个调度器是为网络请求、文件读写、数据库操作准备的。它的线程池会根据需要动态扩展。

我的一点经验:Dispatchers.IO 的线程数默认上限是 64(或 CPU 核心数 * 2,取较大值)。但别以为它无限大,阻塞太多还是会耗尽的。

// 网络请求的正确姿势
suspend fun loadUserData(): User {
    return withContext(Dispatchers.IO) {
        // 这里做网络请求或数据库查询
        apiService.getUser()
    }
}

我曾经在一个项目里,把所有耗时操作都丢进 Dispatchers.IO,结果发现某些场景下响应变慢了。排查后发现——IO 调度器的线程虽然多,但如果你同时发起几百个阻塞式文件读写,照样会排队。

避坑指南:不要把 CPU 密集计算丢进 IO 调度器。IO 调度器的线程数多,但每个线程的优先级较低。CPU 密集任务放这里反而会慢。

Dispatchers.Default:CPU 密集型任务调度器

这个调度器适合做计算密集型工作——比如图片处理、数据解析、复杂算法。它的线程数等于 CPU 核心数(最少 2 个)。

// 图片压缩的典型场景
suspend fun compressImage(bitmap: Bitmap): ByteArray {
    return withContext(Dispatchers.Default) {
        // 这里做 CPU 密集的压缩计算
        compress(bitmap)
    }
}

你想想看,为什么要有专门的 CPU 调度器?因为线程切换是有代价的。如果线程数超过 CPU 核心数,就会频繁发生上下文切换,反而降低效率。

Dispatchers.Default 的线程数刚好等于核心数,就是为了避免这种「过度并行」的问题。

调度器 适用场景 线程数
Main UI 更新、事件处理 1(主线程)
IO 网络、文件、数据库 动态扩展,上限 64
Default 计算、解析、算法 CPU 核心数
Unconfined 测试、特殊场景 不固定

Dispatchers.Unconfined:不限制调度器

这个调度器比较特殊。它不会把协程限制在特定线程上。协程在哪个线程被启动,就在哪个线程继续执行——直到遇到第一个挂起点。

// Unconfined 的行为演示
runBlocking {
    launch(Dispatchers.Unconfined) {
        println("开始于:${Thread.currentThread().name}")
        delay(100)  // 挂起点
        println("恢复于:${Thread.currentThread().name}")
    }
}

输出结果可能是:

开始于:main
恢复于:kotlinx.coroutines.DefaultExecutor

为什么会这样?因为 delay 之后,协程被恢复时,跑在了定时器的线程上。

注意:生产环境几乎不用 Dispatchers.Unconfined。它太不可控了。我一般只在写单元测试时用它,或者做一些简单的协程行为验证。

withContext:线程切换的瑞士军刀

withContext 是协程中最常用的线程切换工具。它不会创建新的协程,而是在当前协程的上下文中切换调度器。

// 经典三段式:IO 获取数据 → Default 处理数据 → Main 更新 UI
lifecycleScope.launch(Dispatchers.Main) {
    val rawData = withContext(Dispatchers.IO) { fetchData() }
    val processed = withContext(Dispatchers.Default) { processData(rawData) }
    textView.text = processed
}

这段代码看起来像是串行的,但每个 withContext 都会切换到对应的线程池执行。执行完后自动回到 Dispatchers.Main

性能小贴士:withContext 的切换开销很小。我做过压测,百万次切换也就几十毫秒。所以放心用,别为了「省几次切换」把代码写成一坨。

实战中的选择策略

说了这么多,到底怎么选?我总结了一个简单的判断流程:

  1. 要更新 UI? → 用 Dispatchers.Main
  2. 要读写文件或网络? → 用 Dispatchers.IO
  3. 要做复杂计算? → 用 Dispatchers.Default
  4. 不确定? → 用 Dispatchers.Default(它是默认值)

我曾经接手过一个项目,里面大量使用了 Dispatchers.IO 做 JSON 解析。结果在低端手机上频繁卡顿。改成 Dispatchers.Default 后,流畅度明显提升。

原因很简单:JSON 解析是 CPU 密集操作,IO 调度器的线程优先级低,而且线程数多导致竞争激烈。换成 Default 后,线程数刚好等于核心数,每个线程都能充分利用 CPU。

我的习惯:在 ViewModel 中,我通常不显式指定调度器。而是让 Repository 层通过 withContext 切换。这样上层代码只关心业务逻辑,不关心线程细节。

好了,调度器这块就讲到这里。记住一句话:选对调度器,比优化代码更重要。线程用错了,再好的算法也白搭。