CoroutineScheduler 设计解析(上)------ 为什么 Dispatchers.IO 会创建更多线程?
阅读源码版本:kotlinx.coroutines 1.10.x
前言
最近在阅读 Kotlin CoroutineScheduler 源码时,我产生了一个疑问。
对于同样一段代码:
kotlin
withContext(Dispatchers.Default) {
doSomething()
}
和:
kotlin
withContext(Dispatchers.IO) {
doSomething()
}
在 Android App 中运行时,我发现 Dispatchers.IO 往往会创建比 Dispatchers.Default 更多的线程。
最开始,我认为原因只是两个 Dispatcher 使用了不同的线程池参数。
例如:
Dispatchers.IO 默认允许至少 64 个并发任务,而 Dispatchers.Default 的并行度通常与 CPU 核数有关。
继续阅读源码之后,我发现事情并没有这么简单。
真正决定线程创建策略的,并不是线程池大小,而是 CoroutineScheduler 对 BlockingTask 与 NonBlockingTask 的区别对待。
本文将带着下面这个问题,一步步阅读 CoroutineScheduler 的实现。
为什么 Dispatchers.IO 会创建更多线程?
从普通线程池开始思考
在分析 CoroutineScheduler 之前,我们先思考一个问题。
假设 Kotlin 没有实现 CoroutineScheduler,而是直接使用普通线程池,会发生什么?
例如:
kotlin
repeat(8) {
launch {
Thread.sleep(30_000)
}
}
假设当前设备是 8 核 CPU,线程池也只有 8 个工作线程。
那么很快就会出现下面这种情况:
bash
Worker-1 -> Thread.sleep()
Worker-2 -> Thread.sleep()
Worker-3 -> Thread.sleep()
...
Worker-8 -> Thread.sleep()
此时:
- 所有 Worker 都存在。
- 所有 Worker 都被阻塞。
- CPU 实际上几乎没有工作。
如果这时候再提交一个新的任务:
kotlin
launch {
println("Hello")
}
它只能等待前面的 Thread.sleep() 完成之后才能执行。
虽然 CPU 处于空闲状态,但线程池已经没有可用线程。
这就是普通固定线程池面对大量阻塞任务时的典型问题。
CoroutineScheduler 想解决什么问题?
很多人在第一次阅读源码时,会认为:
CoroutineScheduler 的目标是管理线程。
其实并不是。
我认为,它真正想解决的问题应该描述为:
如何保证 CPU 始终保持较高的利用率,而不会因为少量阻塞任务导致整个线程池失去执行能力。
换句话说,它真正关心的是:
- CPU 是否一直有计算任务可以执行?
- CPU 是否因为线程过多而产生严重竞争?
- IO 等待是否影响 CPU 计算?
因此,它并没有把所有任务都一视同仁,而是首先回答了一个问题:
这个任务到底属于什么类型?
CoroutineScheduler 的第一层设计:任务分类
CoroutineScheduler 将任务分成了两类。
NonBlockingTask
例如:
kotlin
withContext(Dispatchers.Default) {
sort(list)
}
或者:
kotlin
withContext(Dispatchers.Default) {
calculate()
}
这类任务通常具有几个特点:
- 持续占用 CPU。
- 很少主动阻塞线程。
- CPU 是主要瓶颈。
因此 CoroutineScheduler 认为:
执行这类任务的线程数量,不应该超过 CPU 真正能够并行执行的数量。
通常就是:
corePoolSize ≈ CPU 核数
如果 CPU 是 8 核,那么 Scheduler 更希望只有大约 8 个 Worker 同时执行这类任务。
原因也很容易理解。
假设同时创建 100 个执行 CPU 密集型计算的线程:
100 个线程
↓
竞争 8 个 CPU 核心
↓
大量线程切换
↓
CPU 时间浪费在线程调度上
线程越多,并不意味着计算越快。
很多时候反而更慢。
BlockingTask
另一类任务则完全不同。
例如:
kotlin
withContext(Dispatchers.IO) {
socket.read()
}
或者:
kotlin
withContext(Dispatchers.IO) {
file.readBytes()
}
这类任务真正消耗时间的,并不是 CPU。
而是在等待:
- 网络返回
- 磁盘 IO
- 数据库
- Binder 调用
- 文件系统
例如:
lua
Worker
↓
socket.read()
↓
等待服务器响应
↓
CPU 几乎没有参与计算
虽然线程仍然存在,但是 CPU 很可能已经去执行其他任务了。
因此 CoroutineScheduler 认为:
正在执行 BlockingTask 的 Worker,不应该继续占用 CPU 并行度。
这也是整个 CoroutineScheduler 最重要的设计思想之一。
BlockingTask 与 NonBlockingTask 是如何产生的?
阅读源码后可以发现。
协程恢复执行之后,最终都会调用:
kotlin
CoroutineScheduler.dispatch(...)
Dispatcher 会把 Runnable 包装成一个 Task。
但是,不同 Dispatcher 会传入不同的 TaskContext。
例如:
kotlin
Dispatchers.Default
对应:
kotlin
NonBlockingContext
而:
kotlin
Dispatchers.IO
对应:
kotlin
BlockingContext
随后,Scheduler 根据 TaskContext 创建不同类型的 Task。
可以简单表示为:
markdown
Dispatchers.Default
│
▼
NonBlockingContext
│
▼
NonBlockingTask
以及:
markdown
Dispatchers.IO
│
▼
BlockingContext
│
▼
BlockingTask
这也是为什么同样一个 Runnable,在不同 Dispatcher 下会拥有完全不同的调度策略。
一个重要的问题
看到这里,我产生了一个新的疑问。
既然 Scheduler 已经知道:
- 哪些任务属于 CPU 计算;
- 哪些任务属于 IO 阻塞;
那么它下一步会如何利用这些信息?
答案就是本文开头提出的问题。
为什么 Dispatchers.IO 会创建更多线程?
不过,在回答这个问题之前,还有一个重要的问题需要解决。
Task 创建出来以后,到底放在哪里?
CoroutineScheduler 为什么设计了 LocalQueue、GlobalCpuQueue 和 GlobalBlockingQueue?
下一篇文章,我们继续分析 CoroutineScheduler 的队列设计,以及 Worker 是如何寻找任务、如何实现 Work Stealing 的。
本文总结
本文主要介绍了 CoroutineScheduler 的整体设计目标。
几个比较重要的结论如下。
-
CoroutineScheduler 并不是简单地管理线程,而是希望最大化 CPU 的吞吐量。
-
Scheduler 将任务分成两类:
- NonBlockingTask:CPU 密集型任务。
- BlockingTask:IO 阻塞型任务。
-
Dispatchers.Default 和 Dispatchers.IO 的区别,并不仅仅是线程池参数不同,更重要的是它们会创建不同类型的 Task。
-
这种分类,为后续 CPU Permit、Worker 创建策略以及 Work Stealing 提供了基础。
下一篇,我们将继续分析 CoroutineScheduler 的任务队列设计,以及 Worker 是如何寻找 Task 的。