为什么 Default 只用核数个线程?为什么 IO 能弹性扩到 64 个?Main.immediate 凭什么更快?Unconfined 为什么是禁区?——从设计原理到源码依据,一次性讲透
核心规则:线程数 = CPU 核心数(最少 2 个)。例如 8 核手机就是 8 个线程。
源码依据:DefaultScheduler使用 SchedulerCoroutineDispatcher,内部线程池大小由系统属性 kotlinx.coroutines.scheduler.core.pool.size决定,默认值取自 Runtime.getRuntime().availableProcessors()。
要理解这个设计,先要想清楚 CPU 密集型任务的特征:
假设你有一台 8 核机器:
阿姆达尔定律的核心启示:并行加速比受限于程序中串行部分的比例。当线程数超过物理核心数时,额外的线程并不能增加并行度,只会带来上下文切换的额外开销。线程数 = 核心数,是 CPU 密集型场景下吞吐量最大化的经典结论。
如果你在 Dispatchers.Default里执行阻塞 I/O(如 InputStream.read()、同步网络请求),会发生什么?
灾难场景:假设 8 核机器,Default 线程池有 8 个线程。如果你开了 4 个协程各做一个 5 秒的阻塞 I/O:
// 错误示范:在 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 调度器有弹性线程池,不怕阻塞
}
为了直观感受为什么 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 必须严格分工。
核心规则:按需创建和回收线程,默认上限 64 个(可通过 kotlinx.coroutines.scheduler.max.pool.size配置)。
关键设计:IO 底层和 Default 共享线程池(共享 DefaultScheduler的 corePoolSize个线程),但 IO 允许在这些核心线程不够用时临时"借"或创建额外线程来处理阻塞场景,达到弹性效果。
I/O 任务的特征与 CPU 任务截然不同:
这就是 IO 调度器需要弹性线程池的根本原因:
4 个 I/O 协程阻塞了 4 个线程 → 只剩 4 个线程处理其他任务 → CPU 利用率暴跌
阻塞线程让出 CPU → 新创建的线程补上 → CPU 始终有活干
IO 调度器可以临时创建新线程去执行其他就绪的协程,从而保证 CPU 一直有活干。同时为了防止内存耗尽和过度竞争,线程数量设置了 64 的上限。
把这个设计想象成一家餐厅:Default 是"厨师"——有几个灶台(CPU 核心)就配几个厨师,刚好满负荷。而 IO 是"服务员"——服务员经常等在厨房门口(阻塞等菜),如果只有 3 个服务员,客人多了就忙不过来。所以餐厅会临时多叫几个服务员(弹性扩容),保证每个灶台始终有人对接,但也不可能无限叫(64 上限),否则餐厅挤满服务员反而乱了。
理解 IO 调度器的设计,必须区分两种完全不同的"等待"。先看一段对比代码,直观感受二者的差异:
// 挂起函数:底层是非阻塞 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 + 协程挂起,线程根本不需要阻塞——这才是协程世界的正确打开方式。
// 典型用法:挂起 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} 个用户"
}
}
在 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 线程
}
结合 lifecycleScope或 viewModelScope,协程自动绑定了 Main 调度器,界面相关代码默认就运行在主线程,简洁且安全。
这是一个非常容易被忽略的细节,但在性能敏感场景下很关键。先看一段对比代码:
// 假设当前已在主线程执行
fun onButtonClick() { // ← 在主线程
launch(Dispatchers.Main) {
println("A") // 不会立即执行!
}
println("B")
}
// 输出顺序:B → A
// 原因:Dispatchers.Main 无条件调用:
// handler.post { /* 整个协程代码块 */ }
// 相当于把"打印A"塞到消息队列末尾
// 必须先等当前消息(打印B)执行完
即使当前已在主线程,也会重新入队 Handler 消息队列,导致至少延迟一帧才执行,白白浪费一次协程挂起/恢复的开销。
// 假设当前已在主线程执行
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 的决策流程
重要:lifecycleScope和 viewModelScope默认使用的就是 Main.immediate。所以当你用这些作用域启动协程时,如果已在主线程就能获得最优性能——无需额外 Handler 入队,无需额外协程挂起。而如果你显式使用 Dispatchers.Main(不带 .immediate),反而会强制走 Handler 入队,即使你已经在主线程上。
一句话区分:Main 是"不管三七二十一,全扔 Handler";Main.immediate 是"在主线程就直接干,不在才扔 Handler"。immediate本身也是一个 MainCoroutineDispatcher,它和 Main 一样指定主线程执行——唯一的区别就是这个"先检查再决定"的优化。
Dispatchers.Unconfined不会把协程限定在任何特定线程上。协程在挂起点恢复后,会运行在刚好触发恢复的那个线程里,而不进行线程切换。
launch(Dispatchers.Unconfined) {
println("第一次打印:${Thread.currentThread().name}")
// ↑ 在 launch 调用的线程上执行(比如主线程)
delay(100)
// ↑ 挂起!协程离开所有线程
println("第二次打印:${Thread.currentThread().name}")
// ↑ 恢复时在 delay 定时器回调的线程上执行
// delay 的内部定时器回调在 kotlinx.coroutines.DefaultExecutor 的线程上
// 所以第二次打印可能输出完全不同的线程名!
}
Unconfined 的核心特征:协程在哪个线程被恢复,就在哪个线程继续执行。恢复线程取决于"是谁触发了挂起点的回调"——这完全不可预测。
恢复线程的几种常见来源:
| 挂起点 | 恢复触发者 | 恢复线程 |
|---|---|---|
delay(100) | DefaultExecutor 定时器 | kotlinx.coroutines.DefaultExecutor 线程 |
withContext(IO) { ... } | IO 线程池 | IO 线程池中的某个线程 |
| ktor-client 请求 | NIO Selector | ktor 的 I/O 工作线程 |
| 任意 callback-based API | 回调线程 | 完全不确定 |
官方明确提醒: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 → 读到过期值 |
Unconfined 仅在以下场景有存在价值(且这些场景都不是业务代码该碰的):
业务代码中应视为禁区。如果你在写 Activity / Fragment / ViewModel 的代码,永远不要使用 Unconfined。它带来的性能收益微乎其微(省一次线程切换),但引入的并发风险是灾难性的。
| 调度器 | 线程池大小 | 设计理念 | 适用场景 | 禁忌 |
|---|---|---|---|---|
| Default | CPU 核数(≥2) | 阿姆达尔定律:线程数=核心数时吞吐量最大 | 排序、加解密、JSON 解析、复杂计算 | 执行阻塞 I/O |
| IO | 弹性 64(与 Default 共享核心池) | "填补 CPU 空闲窗口":用额外线程补上被阻塞线程留下的 CPU 空隙 | 网络请求、文件读写、数据库操作 | 执行 CPU 密集任务(浪费弹性线程) |
| Main | 1 个(UI 线程) | Looper + Handler:安全的 UI 线程投递 | 更新 UI 组件、操作 View | 执行耗时操作(会卡 UI) |
| Unconfined | 无固定线程 | "抓到谁是谁":零线程切换开销,但线程不确定 | 底层库开发、自定义协程构建器 | 业务代码中使用(线程不安全) |
这就是协程调度器的精妙之处:同一个底层线程池,Default 克制地只用核数个线程追求 CPU 效率,IO 弹性地扩展线程数填补 I/O 空隙。两者各司其职,共享基础设施却行为迥异。
lifecycleScope/ viewModelScope默认使用 Main.immediate,已在主线程时跳过 Handler 入队,避免不必要的延迟。四种调度器各司其职:Default 用核数个线程跑 CPU 任务追求零浪费,IO 弹性扩到 64 个线程填补 I/O 阻塞留下的 CPU 空隙,Main 靠 Looper/Handler 安全回到 UI 线程(且 immediate 跳过无意义入队),Unconfined 不绑定线程但线程不确定性让它成为业务代码的禁区。核心心法只有一句——永远用对的调度器做对的事,框架已经替你算好了最优解。