CoroutineScheduler 设计解析(上)—— 为什么 Dispatchers.IO 会创建更多线程?

文章来源声明: 原文作者:Android打工仔; 来源站点:掘金; 原文链接:https://juejin.cn/post/7693351700207747072; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

从任务分类切入解释线程创建差异,抓住 CoroutineScheduler 的核心设计,适合 Kotlin/Android 开发者深入理解 Dispatchers.IO 与 Default 的调度行为。

CoroutineScheduler 设计解析(上)—— 为什么 Dispatchers.IO 会创建更多线程? --------------------------------------------------------

阅读源码版本:kotlinx.coroutines 1.10.x

前言

最近在阅读 Kotlin CoroutineScheduler 源码时,我产生了一个疑问。

对于同样一段代码:

withContext(Dispatchers.Default) {
    doSomething()
}

和:

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,而是直接使用普通线程池,会发生什么?

例如:

repeat(8) {
    launch {
        Thread.sleep(30_000)
    }
}

假设当前设备是 8 核 CPU,线程池也只有 8 个工作线程。

那么很快就会出现下面这种情况:

Worker-1 -> Thread.sleep()

Worker-2 -> Thread.sleep()

Worker-3 -> Thread.sleep()

...

Worker-8 -> Thread.sleep()

此时:

  • 所有 Worker 都存在。
  • 所有 Worker 都被阻塞。
  • CPU 实际上几乎没有工作。

如果这时候再提交一个新的任务:

launch {
    println("Hello")
}

它只能等待前面的 Thread.sleep() 完成之后才能执行。

虽然 CPU 处于空闲状态,但线程池已经没有可用线程。

这就是普通固定线程池面对大量阻塞任务时的典型问题。


CoroutineScheduler 想解决什么问题?

很多人在第一次阅读源码时,会认为:

CoroutineScheduler 的目标是管理线程。

其实并不是。

我认为,它真正想解决的问题应该描述为:

如何保证 CPU 始终保持较高的利用率,而不会因为少量阻塞任务导致整个线程池失去执行能力。

换句话说,它真正关心的是:

  • CPU 是否一直有计算任务可以执行?
  • CPU 是否因为线程过多而产生严重竞争?
  • IO 等待是否影响 CPU 计算?

因此,它并没有把所有任务都一视同仁,而是首先回答了一个问题:

这个任务到底属于什么类型?


CoroutineScheduler 的第一层设计:任务分类

CoroutineScheduler 将任务分成了两类。

NonBlockingTask

例如:

withContext(Dispatchers.Default) {
    sort(list)
}

或者:

withContext(Dispatchers.Default) {
    calculate()
}

这类任务通常具有几个特点:

  • 持续占用 CPU。
  • 很少主动阻塞线程。
  • CPU 是主要瓶颈。

因此 CoroutineScheduler 认为:

执行这类任务的线程数量,不应该超过 CPU 真正能够并行执行的数量。

通常就是:

corePoolSize ≈ CPU 核数

如果 CPU 是 8 核,那么 Scheduler 更希望只有大约 8 个 Worker 同时执行这类任务。

原因也很容易理解。

假设同时创建 100 个执行 CPU 密集型计算的线程:

100 个线程

↓

竞争 8 个 CPU 核心

↓

大量线程切换

↓

CPU 时间浪费在线程调度上

线程越多,并不意味着计算越快。

很多时候反而更慢。


BlockingTask

另一类任务则完全不同。

例如:

withContext(Dispatchers.IO) {
    socket.read()
}

或者:

withContext(Dispatchers.IO) {
    file.readBytes()
}

这类任务真正消耗时间的,并不是 CPU。

而是在等待:

  • 网络返回
  • 磁盘 IO
  • 数据库
  • Binder 调用
  • 文件系统

例如:

Worker

↓

socket.read()

↓

等待服务器响应

↓

CPU 几乎没有参与计算

虽然线程仍然存在,但是 CPU 很可能已经去执行其他任务了。

因此 CoroutineScheduler 认为:

正在执行 BlockingTask 的 Worker,不应该继续占用 CPU 并行度。

这也是整个 CoroutineScheduler 最重要的设计思想之一。


BlockingTask 与 NonBlockingTask 是如何产生的?

阅读源码后可以发现。

协程恢复执行之后,最终都会调用:

CoroutineScheduler.dispatch(...)

Dispatcher 会把 Runnable 包装成一个 Task。

但是,不同 Dispatcher 会传入不同的 TaskContext。

例如:

Dispatchers.Default

对应:

NonBlockingContext

而:

Dispatchers.IO

对应:

BlockingContext

随后,Scheduler 根据 TaskContext 创建不同类型的 Task。

可以简单表示为:

Dispatchers.Default
        │
        ▼
NonBlockingContext
        │
        ▼
NonBlockingTask

以及:

Dispatchers.IO
        │
        ▼
BlockingContext
        │
        ▼
BlockingTask

这也是为什么同样一个 Runnable,在不同 Dispatcher 下会拥有完全不同的调度策略。


一个重要的问题

看到这里,我产生了一个新的疑问。

既然 Scheduler 已经知道:

  • 哪些任务属于 CPU 计算;
  • 哪些任务属于 IO 阻塞;

那么它下一步会如何利用这些信息?

答案就是本文开头提出的问题。

为什么 Dispatchers.IO 会创建更多线程?

不过,在回答这个问题之前,还有一个重要的问题需要解决。

Task 创建出来以后,到底放在哪里?

CoroutineScheduler 为什么设计了 LocalQueue、GlobalCpuQueue 和 GlobalBlockingQueue?

下一篇文章,我们继续分析 CoroutineScheduler 的队列设计,以及 Worker 是如何寻找任务、如何实现 Work Stealing 的。


本文总结

本文主要介绍了 CoroutineScheduler 的整体设计目标。

几个比较重要的结论如下。

  1. CoroutineScheduler 并不是简单地管理线程,而是希望最大化 CPU 的吞吐量。

  2. Scheduler 将任务分成两类:

    • NonBlockingTask:CPU 密集型任务。
    • BlockingTask:IO 阻塞型任务。
  3. Dispatchers.Default 和 Dispatchers.IO 的区别,并不仅仅是线程池参数不同,更重要的是它们会创建不同类型的 Task。

  4. 这种分类,为后续 CPU Permit、Worker 创建策略以及 Work Stealing 提供了基础。

下一篇,我们将继续分析 CoroutineScheduler 的任务队列设计,以及 Worker 是如何寻找 Task 的。