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

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 的整体设计目标。

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

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

  2. Scheduler 将任务分成两类:

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

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

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

相关推荐
熊猫钓鱼>_>2 小时前
Kotlin Multiplatform for OpenHarmony 实战:为 kotlin-inject 实现依赖注入适配
开发语言·华为·kotlin·ai编程·inject·鸿蒙·openharmony
martindelophy2 小时前
用自然语言剪视频:我们在 Timeline Studio 中实现了 ChatCut 对话剪辑
android·音视频
ao-weilai3 小时前
MySQL数据库:复合查询
android·数据库·mysql
翼辉cto3 小时前
Kotlin Multiplatform 三方库 SQLDelight 的 OpenHarmony 鸿蒙化适配实战
开发语言·kotlin·harmonyos
熊猫钓鱼>_>14 小时前
Kotlin Multiplatform for OpenHarmony 实战:为 KMPNotifier 实现 OpenHarmony 本地通知引擎
kotlin·ai编程·harmonyos·鸿蒙·openharmony·适配·kmp
三少爷的鞋16 小时前
AI 时代,我们都将成为通才型开发者:只懂 Android,已经不够了
android
Dovis(誓平步青云)20 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
嘎嘎风20 小时前
MiniMind 学习笔记之 04 注意力之外:位置、记忆、省显存、省时间、深加工
架构·源码阅读
嘎嘎风20 小时前
MiniMind 学习笔记之 07 模型怎么学会猜词(旋钮是怎么被拧动的)
算法·源码阅读