四种协程调度器

为什么 Default 只用核数个线程?为什么 IO 能弹性扩到 64 个?Main.immediate 凭什么更快?Unconfined 为什么是禁区?——从设计原理到源码依据,一次性讲透

Dispatchers.Default Dispatchers.IO Dispatchers.Main Dispatchers.Unconfined 阿姆达尔定律

目录导航(点击跳转)

一、Dispatchers.Default —— CPU 密集型任务的最优解

1线程池配置:线程数 = CPU 核心数

核心规则:线程数 = CPU 核心数(最少 2 个)。例如 8 核手机就是 8 个线程。

源码依据:DefaultScheduler使用 SchedulerCoroutineDispatcher,内部线程池大小由系统属性 kotlinx.coroutines.scheduler.core.pool.size决定,默认值取自 Runtime.getRuntime().availableProcessors()

2为什么线程数 = 核心数?—— 阿姆达尔定律的经典结论

要理解这个设计,先要想清楚 CPU 密集型任务的特征:

  • 一个任务持续占用 CPU 直到完成,几乎不会主动让出 CPU
  • 任务之间没有"等待 I/O"的空隙可钻

假设你有一台 8 核机器:

线程数 = 8(等于核心数)

  • 每个核心分配一个线程,8 个核心 100% 运转
  • 零上下文切换损耗(没有多余线程抢 CPU)
  • 吞吐量达到理论上限

线程数 = 16(大于核心数)

  • 16 个线程争抢 8 个核心 → 每个线程只能分到 50% CPU 时间
  • 操作系统频繁切换线程 → 上下文切换开销吃掉大量 CPU
  • 吞吐量反而下降(实测可能下降 20%~40%)

阿姆达尔定律的核心启示:并行加速比受限于程序中串行部分的比例。当线程数超过物理核心数时,额外的线程并不能增加并行度,只会带来上下文切换的额外开销。线程数 = 核心数,是 CPU 密集型场景下吞吐量最大化的经典结论。

3致命陷阱:在 Default 上执行阻塞 I/O

如果你在 Dispatchers.Default里执行阻塞 I/O(如 InputStream.read()、同步网络请求),会发生什么?

灾难场景:假设 8 核机器,Default 线程池有 8 个线程。如果你开了 4 个协程各做一个 5 秒的阻塞 I/O:

  • 4 个线程被 I/O 阻塞住,只剩下 4 个线程处理其他计算任务
  • CPU 核心利用率从 100% 跌到 50%
  • 如果有 8 个协程都在做阻塞 I/O → 8 个线程全被占住 → CPU 闲置 → 吞吐量归零
//  错误示范:在 Default 上做阻塞 I/O
launch(Dispatchers.Default) {
    val socket = Socket("example.com", 80)
    val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
    val line = reader.readLine()  // ← 阻塞!这个 Default 线程被占住,其他计算任务没线程可用
}

//  正确做法:I/O 操作交给 Dispatchers.IO
launch(Dispatchers.IO) {
    val socket = Socket("example.com", 80)
    val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
    val line = reader.readLine()  // IO 调度器有弹性线程池,不怕阻塞
}
4场景推演:Default 遇到阻塞 I/O 的渐进式灾难

为了直观感受为什么 Default 上不能做阻塞 I/O,我们来做一个具体的场景推演。假设 8 核机器,Default 线程池有 8 个线程,同时启动 20 个协程:

批次协程数任务类型线程状态CPU 利用率
第 1 批 8 个 4 个 CPU 计算 + 4 个阻塞 I/O 4 个线程被 readLine() 阻塞 50%(4/8 核心闲置)
第 2 批 再进来 8 个 全部是 CPU 计算 只剩 4 个线程可用 → 4 个计算任务排队等 50%(4 个任务等待中,4 个核心空转)
极端情况 20 个全阻塞 I/O 全部 Socket.connect() 8 个线程全被阻塞 ~0%(所有任务卡死,CPU 完全闲置)

问题的本质:CPU 密集型调度器的设计前提是"任务不阻塞、线程一直跑"。一旦有线程阻塞,那它对应的那个 CPU 核心就没人喂任务了——8 核机器变成 4 核甚至 0 核。这就是为什么 Default 和 IO 必须严格分工。

5Default 调度器图解
协程任务队列:排序大数组 → 解析 JSON → 加密数据 → 图像处理 → ...
Dispatchers.Default 线程池(8核机器 = 8线程)
Core-1 线程 Core-2 线程 Core-3 线程 Core-4 线程 Core-5 线程 Core-6 线程 Core-7 线程 Core-8 线程
物理 CPU:8 个核心 → 8 个线程正好一对一 → 100% 利用率,零上下文切换浪费

二、Dispatchers.IO —— 弹性线程池应对阻塞 I/O

1线程池配置:弹性扩容,默认最大 64 个线程

核心规则:按需创建和回收线程,默认上限 64 个(可通过 kotlinx.coroutines.scheduler.max.pool.size配置)。

关键设计:IO 底层和 Default 共享线程池(共享 DefaultSchedulercorePoolSize个线程),但 IO 允许在这些核心线程不够用时临时"借"或创建额外线程来处理阻塞场景,达到弹性效果。

2为什么 IO 调度器需要更多线程?—— "填补 CPU 空闲窗口"

I/O 任务的特征与 CPU 任务截然不同:

  • I/O 任务经常阻塞:等网络响应、等磁盘寻道、等数据库返回
  • 当一个线程被 I/O 阻塞时,它让出 CPU,CPU 核心就闲着了
  • 如果只有核数个线程,一旦有几个线程阻塞在 I/O 上,CPU 就开始摸鱼

这就是 IO 调度器需要弹性线程池的根本原因:

只有核数个线程(如 Default)
线程1
阻塞在 read()
线程2
阻塞在 connect()
线程3
阻塞在 query()

4 个 I/O 协程阻塞了 4 个线程 → 只剩 4 个线程处理其他任务 → CPU 利用率暴跌

IO 弹性线程池(按需扩到 64 个)
T1
阻塞中
T2
阻塞中
T9
新创建
T10
新创建
T11
新创建

阻塞线程让出 CPU → 新创建的线程补上 → CPU 始终有活干

IO 调度器可以临时创建新线程去执行其他就绪的协程,从而保证 CPU 一直有活干。同时为了防止内存耗尽和过度竞争,线程数量设置了 64 的上限。

3Default 和 IO 的线程池共享关系
底层共享:DefaultScheduler
核心线程池(corePoolSize = CPU 核数)
Default 调度器只使用这部分线程,不越界
弹性扩展区(maxPoolSize = 64)
IO 调度器在核心线程全忙时,可临时借用或创建额外线程

把这个设计想象成一家餐厅:Default 是"厨师"——有几个灶台(CPU 核心)就配几个厨师,刚好满负荷。而 IO 是"服务员"——服务员经常等在厨房门口(阻塞等菜),如果只有 3 个服务员,客人多了就忙不过来。所以餐厅会临时多叫几个服务员(弹性扩容),保证每个灶台始终有人对接,但也不可能无限叫(64 上限),否则餐厅挤满服务员反而乱了。

4关键区分:挂起函数 vs 阻塞 I/O —— 一线之隔,天壤之别

理解 IO 调度器的设计,必须区分两种完全不同的"等待"。先看一段对比代码,直观感受二者的差异:

挂起函数(Ktor 异步请求) 阻塞 I/O(Java Socket)
// 挂起函数:底层是非阻塞 NIO + 协程挂起
suspend fun fetchWithKtor(): String {
    val client = HttpClient(CIO) {
        engine { requestTimeout = 5_000 }
    }
    return client.get("https://api.example.com/data").body()
}

// 执行过程:
// 1. 协程在线程 A 发起 HTTP 请求
// 2. 网络 I/O 注册到 NIO Selector → 协程挂起(不阻塞线程!)
// 3. 线程 A 立刻被释放,去执行另一个协程
// 4. 网络数据到达 → Selector 通知 → 线程 B 恢复协程
// 5. 全程没有任何线程被阻塞在原地等待
// 阻塞 I/O:Java 原生 Socket,线程直接卡死
fun fetchWithBlockingSocket(): String {
    val socket = Socket("api.example.com", 80)
    val writer = OutputStreamWriter(socket.outputStream)
    val reader = BufferedReader(InputStreamReader(socket.inputStream))

    writer.write("GET /data HTTP/1.1\r\nHost: api.example.com\r\n\r\n")
    writer.flush()

    val line = reader.readLine()  // ← 线程在此阻塞!啥也干不了
    //  操作系统直接把当前线程挂起 (BLOCKED 状态)
    //  直到网络数据到达,线程才被唤醒
    return line
}

// 如果这个函数跑在 Dispatchers.Default 上 → 灾难
// 如果跑在 Dispatchers.IO 上 → 没问题,IO 有弹性线程补位
对比维度挂起函数(非阻塞异步 I/O)传统阻塞 I/O
典型 API delay()、ktor-client 请求、withContext、Kotlin 协程 I/O InputStream.read()OutputStream.write()、JDBC 查询、Socket.connect()
线程状态 线程不等待:协程挂起时线程立即被释放,去执行其他协程 操作系统挂起整个线程(进入 BLOCKED 状态),直到数据就绪,在此期间线程完全不可用
CPU 利用 线程始终有活干,CPU 利用率高 线程空等,对应 CPU 核心闲置
线程需求 少量线程即可(Dispatchers.Default 数量 + Default 调度器足够) 需要额外线程来"填空",避免 CPU 摸鱼 → Dispatchers.IO 的核心价值
进程视角 线程 RUNNABLE,协程 SUSPENDED 线程 BLOCKED,协程也动不了

核心结论:Dispatchers.IO 的弹性线程池本质上是为"不小心"或"不得已"使用的阻塞 I/O提供缓冲——通过增加线程数来弥补被阻塞的线程,保证 CPU 继续运转。但最佳实践始终是尽量使用协程友好的挂起 API来规避线程阻塞。当你用 ktor-client 发网络请求时,底层用的是 NIO + 协程挂起,线程根本不需要阻塞——这才是协程世界的正确打开方式。

5正确使用 IO 调度器的姿势
//  典型用法:挂起 API + 必要时才用阻塞操作
launch(Dispatchers.IO) {
    //  挂起函数:底层是非阻塞异步 I/O,线程不等待
    val response = httpClient.get("https://api.example.com/data")

    //  文件 I/O:Kotlin 的 writeText 内部依然是阻塞的,放在 IO 线程
    val file = File(context.cacheDir, "cache.json")
    file.writeText(response.body)

    // Room 数据库:内部用挂起函数包装,实际仍是阻塞 SQLite 操作
    val dao = database.userDao()
    val users = dao.getAllUsers()

    // 切回主线程更新 UI
    withContext(Dispatchers.Main) {
        textView.text = "加载完成:${users.size} 个用户"
    }
}

三、Dispatchers.Main —— UI 线程的专属调度器

1核心设计:Looper + Handler 投递任务

在 Android 上,Dispatchers.Main依赖 Looper.getMainLooper()Handler来投递任务。这和 View.post()思路一致——都是往主线程的消息队列里塞任务——但 Dispatchers.Main 是立即执行的优先级。

// 如果你在子线程中调用 withContext(Dispatchers.Main)
launch(Dispatchers.IO) {
    val data = fetchDataFromNetwork()     // 在 IO 线程

    withContext(Dispatchers.Main) {       // 协程挂起 →
        // 框架通过 Handler 将恢复执行的部分切回主线程
        textView.text = data              // 安全更新 UI
    }  // ← 执行完自动回到 IO 线程
}

结合 lifecycleScopeviewModelScope,协程自动绑定了 Main 调度器,界面相关代码默认就运行在主线程,简洁且安全。

2Main vs Main.immediate —— 一字之差,天壤之别

这是一个非常容易被忽略的细节,但在性能敏感场景下很关键。先看一段对比代码:

Dispatchers.Main(默认)—— 总是走 Handler 入队

// 假设当前已在主线程执行
fun onButtonClick() {  // ← 在主线程
    launch(Dispatchers.Main) {
        println("A")  // 不会立即执行!
    }
    println("B")
}
// 输出顺序:B → A

// 原因:Dispatchers.Main 无条件调用:
// handler.post { /* 整个协程代码块 */ }
// 相当于把"打印A"塞到消息队列末尾
// 必须先等当前消息(打印B)执行完

即使当前已在主线程,也会重新入队 Handler 消息队列,导致至少延迟一帧才执行,白白浪费一次协程挂起/恢复的开销。

Dispatchers.Main.immediate —— 先检查,再决定

// 假设当前已在主线程执行
fun onButtonClick() {  // ← 在主线程
    launch(Dispatchers.Main.immediate) {
        println("A")  // 立即同步执行!
    }
    println("B")
}
// 输出顺序:A → B

// 原因:Main.immediate 的执行逻辑:
// if (Looper.myLooper() == Looper.getMainLooper()) {
//     直接执行代码块  // ← 当前就在主线程,跳过 Handler
// } else {
//     handler.post { /* 代码块 */ }  // ← 不在主线程才投递
// }

框架先检查当前是否已在主线程:如果是,直接同步执行,跳过 Handler 投递,零延迟、零挂起开销。

Main.immediate 的决策流程

launch(Dispatchers.Main.immediate)
Looper.myLooper() ==
Looper.getMainLooper() ?
YES(当前在主线程)
直接同步执行
无需 Handler 投递
NO(当前在子线程)
Handler.post()
投递到主线程队列

重要:lifecycleScopeviewModelScope默认使用的就是 Main.immediate。所以当你用这些作用域启动协程时,如果已在主线程就能获得最优性能——无需额外 Handler 入队,无需额外协程挂起。而如果你显式使用 Dispatchers.Main(不带 .immediate),反而会强制走 Handler 入队,即使你已经在主线程上。

一句话区分:Main 是"不管三七二十一,全扔 Handler";Main.immediate 是"在主线程就直接干,不在才扔 Handler"。immediate本身也是一个 MainCoroutineDispatcher,它和 Main 一样指定主线程执行——唯一的区别就是这个"先检查再决定"的优化。

3Main 调度器线程切换时序图
IO 线程 主线程 (Main)
1协程在 IO 线程执行 → 遇到 withContext(Main)
协程挂起 → Handler 将续体投递到主线程 Looper 的消息队列
2IO 线程被释放,去执行其他协程
3主线程 Looper 取出消息 → 恢复协程 → 执行 UI 更新
UI 更新完成 → 协程再次挂起 → 切回 IO 线程池
4协程在 IO 线程恢复 → 继续后续逻辑

四、Dispatchers.Unconfined —— 不绑定线程的特殊调度器

1核心特征:线程不确定

Dispatchers.Unconfined不会把协程限定在任何特定线程上。协程在挂起点恢复后,会运行在刚好触发恢复的那个线程里,而不进行线程切换。

launch(Dispatchers.Unconfined) {
    println("第一次打印:${Thread.currentThread().name}")
    // ↑ 在 launch 调用的线程上执行(比如主线程)

    delay(100)
    // ↑ 挂起!协程离开所有线程

    println("第二次打印:${Thread.currentThread().name}")
    // ↑ 恢复时在 delay 定时器回调的线程上执行
    //   delay 的内部定时器回调在 kotlinx.coroutines.DefaultExecutor 的线程上
    //   所以第二次打印可能输出完全不同的线程名!
}
2Unconfined 的线程跳转示意图 —— 为什么会"飘"?

Unconfined 的核心特征:协程在哪个线程被恢复,就在哪个线程继续执行。恢复线程取决于"是谁触发了挂起点的回调"——这完全不可预测。

Main Thread DefaultExecutor 任意线程
1launch 调用 → 立即在调用者线程(如 Main)执行第一段代码
delay(100) → 协程挂起 → 续体注册到 DefaultExecutor 的定时器
2100ms 后定时器触发 → DefaultExecutor 的线程恢复协程
如果挂起点是网络回调,恢复线程可能是 OkHttp 的线程池线程
3后续代码在触发恢复的那个线程上继续执行(无法预知)

恢复线程的几种常见来源:

挂起点恢复触发者恢复线程
delay(100)DefaultExecutor 定时器kotlinx.coroutines.DefaultExecutor 线程
withContext(IO) { ... }IO 线程池IO 线程池中的某个线程
ktor-client 请求NIO Selectorktor 的 I/O 工作线程
任意 callback-based API回调线程完全不确定
3为什么 Unconfined 是"禁区"?—— 一个真实的灾难场景

官方明确提醒:Unconfined 是高级调度器,通常不应在普通代码中使用。

假设你在 Activity 中写了这样一段代码——这在业务开发中看起来"好像也对":

//  危险代码:Unconfined + UI 操作 = 随机崩溃
class MyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch(Dispatchers.Unconfined) {
            // 第一次执行:在 Main 线程(因为 launch 调用者在 Main)
            val data = fetchData()  // suspend 函数

            // ⚠️ 你以为还在主线程?不一定!
            // fetchData() 内部如果用了 withContext(IO),
            // 恢复时可能飘到 IO 线程池的某个线程上!
            textView.text = data  // → CalledFromWrongThreadException !!!
        }
    }
}

这个崩溃不是每次必现,它取决于 fetchData()内部实现、网络延迟、线程池状态——可能开发阶段从不 crash,上线后用户量大时随机崩溃。这就是 Unconfined 最阴险的地方。

Unconfined 会带来三类严重问题:

问题类型具体风险示例场景
线程不确定性 你不知道代码跑在哪个线程上,不小心就在子线程更新了 UI delay(100)textView.text = "xxx" → CalledFromWrongThreadException
破坏结构化并发 不遵循调度器的层级约束,异常传播路径混乱 父协程在 Main,子协程 Unconfined 飘到 DefaultExecutor → 异常时找不到正确的 handler
不可预测的执行顺序 每次挂起-恢复后可能在完全不同的线程,调试极其困难 同一段代码跑 10 次,可能有 10 种不同的线程轨迹
线程安全问题 多个协程可能意外地并发访问可变状态,且没有内存可见性保证 Unconfined 协程在 Thread-1 修改变量 X,在 Thread-2 读取变量 X → 读到过期值
4Unconfined 的唯一正确使用场景

Unconfined 仅在以下场景有存在价值(且这些场景都不是业务代码该碰的):

  • 编写自定义协程构建器:需要实现"继承调用者上下文"的行为时
  • 底层库开发:某些极轻量的非阻塞操作,希望避免线程切换的开销
  • 测试代码:有时为了精确控制执行顺序

业务代码中应视为禁区。如果你在写 Activity / Fragment / ViewModel 的代码,永远不要使用 Unconfined。它带来的性能收益微乎其微(省一次线程切换),但引入的并发风险是灾难性的。

五、四种调度器全景对比 & 核心思想总结

1四种调度器全景对比表
调度器线程池大小设计理念适用场景禁忌
Default CPU 核数(≥2) 阿姆达尔定律:线程数=核心数时吞吐量最大 排序、加解密、JSON 解析、复杂计算 执行阻塞 I/O
IO 弹性 64(与 Default 共享核心池) "填补 CPU 空闲窗口":用额外线程补上被阻塞线程留下的 CPU 空隙 网络请求、文件读写、数据库操作 执行 CPU 密集任务(浪费弹性线程)
Main 1 个(UI 线程) Looper + Handler:安全的 UI 线程投递 更新 UI 组件、操作 View 执行耗时操作(会卡 UI)
Unconfined 无固定线程 "抓到谁是谁":零线程切换开销,但线程不确定 底层库开发、自定义协程构建器 业务代码中使用(线程不安全)
2Default 与 IO 的线程池协作全景图
Default
调度器
核心线程池
(核数个线程)
IO
调度器
Default 调度器的行为:只用核心线程池 → 不越界 → CPU 密集型任务高效运转
IO 调度器的行为:先用核心线程 → 核心线程全被阻塞 → 创建额外线程(最多 64 个)→ 阻塞结束后回收

这就是协程调度器的精妙之处:同一个底层线程池,Default 克制地只用核数个线程追求 CPU 效率,IO 弹性地扩展线程数填补 I/O 空隙。两者各司其职,共享基础设施却行为迥异。

核心思想总结

一句话总结

四种调度器各司其职:Default 用核数个线程跑 CPU 任务追求零浪费,IO 弹性扩到 64 个线程填补 I/O 阻塞留下的 CPU 空隙,Main 靠 Looper/Handler 安全回到 UI 线程(且 immediate 跳过无意义入队),Unconfined 不绑定线程但线程不确定性让它成为业务代码的禁区。核心心法只有一句——永远用对的调度器做对的事,框架已经替你算好了最优解。