Goroutine调度器揭秘

你以前可能听说过 Goroutine 调度器,但你对它的工作原理了解多少?它如何将 goroutine 与线程配对?

3f9ac5abca6823b38c2e68985c24989a.jpeg

不用着急理解上面的图像,因为我们要从最基本的开始。

goroutine 被分配到线程中运行,这由 goroutine 调度器在后台处理。根据我们之前的讨论,我们了解到以下关于 goroutine 的几点:

  • 就原始执行速度而言,goroutine 并不一定比线程更快,因为它们需要在实际线程上运行。

  • goroutine 真正的优势在于上下文切换、内存占用、创建和销毁的成本等方面。

你可能以前听说过 goroutine 调度器,但我们真正了解它的工作原理吗?它是如何将 goroutine 与线程配对的?

现在让我们一步一步地分解调度器的工作原理。

goroutine M:N 调度器模型

Go 团队真的为我们简化了并发编程,想想看:创建一个 goroutine 只需要在函数前加上 go 关键字就可以了。

go doWork()

但在这个简单的步骤背后,有一个更深层次的系统在运作。

一开始,Go 就没有简单地为我们提供线程。相反,中间有一个助手,即 goroutine 调度器,它是 Go 运行时的关键部分。

53b8b0e6f63f0a96b1bcb40b715bbcbc.jpeg

那么M:N这个标签是什么意思呢?

它体现了 Go 调度器在将M个 goroutine 映射到N个内核线程方面的作用,形成了M:N模型。操作系统线程的数量可以多于 CPU 核心数,就像 goroutine 的数量也可以多于操作系统线程一样。

在深入探讨调度器之前,让我们先区分一下经常混淆的两个概念:并发和并行。

  • 并发: 指同时处理多个任务,这些任务都在运行,但不一定在同一时间运行。

  • 并行: 指多个任务在同一时刻真正同时运行,通常使用多个 CPU 核心。

  • e58e0cd568c83afbc8db65e277ff81a4.jpeg

让我们看看 Go Scheduler 如何使用线程。

PMG 模型

在我们解开内部工作原理之前,让我们先解释一下 P、M 和 G 分别代表什么意思。

G (goroutine)

goroutine 是 Go 中最小的执行单元,类似于一个轻量级线程。

在 Go 运行时,它由一个名为g的 struct 表示。一旦创建,它就会被放入逻辑处理器 P 的本地可运行队列(或全局队列),之后 P 会将它分配给一个实际的内核线程(M)。

goroutine 通常存在三种主要状态:

  • Waiting:在这个阶段,goroutine 处于静止状态,可能是由于等待某个操作(如 channel 或锁),或者是被系统调用阻塞。

  • Runnable:goroutine 已准备就绪,但尚未开始运行,它正在等待轮到在线程(M)上运行。

  • Running:现在 goroutine 正在线程(M)上积极执行。它将一直运行直到任务完成,除非调度器中断它或其他事物阻碍了它的运行。

d189b01a8b7c447725a29a73dab58110.jpeg

goroutine 不是一次性使用后就被丢弃的。

相反,当启动一个新的 goroutine 时,Go 的运行时会从 goroutine 池中选择一个,如果池中没有,它会创建一个新的。然后,这个新的 goroutine 会加入某个 P 的可运行队列。

P(逻辑处理器)

在 Go 调度器中,当我们提到"处理器"时,指的是一个逻辑实体,而不是物理实体。

默认情况下,P 的数量设置为可用的 CPU 核心数,你可以使用 runtime.GOMAXPROCS(int)检查或更改这些处理器的数量:

runtime.GOMAXPROCS(0) // get the current allowed number of logical processors

// Output: 8 (depends on your machine)

如果你想修改 P 的数量,最好在应用程序启动时就这样做,因为如果在运行时修改,它会导致STW(stopTheWorld),所有操作都会暂停,直到处理器大小调整完成。

每个 P 都有自己的可运行 goroutine 列表,称为本地运行队列(Local Run Queue),最多可容纳 256 个 goroutine。

b3e8e9eb426171dc79fd50bb04301c19.jpeg

如果 P 的队列已满(256 个 goroutine),还有一个名为全局运行队列(Global Run Queue)的共享队列,不过我们稍后再讨论这个。

"那么,'P'的数量真正显示了什么呢?"

它表示可以并发运行的 goroutine 数量 - 想象它们并排运行。

M(机器线程 - 操作系统线程)

一个典型的 Go 程序最多可使用 10,000 个线程。

没错,我说的是线程而不是 goroutine。如果超过这个限制,你的 Go 应用程序就有崩溃的风险。

"线程是何时创建的呢?"

想象这种情况:一个 goroutine 处于可运行状态并需要一个线程。

如果所有线程都已被阻塞,可能是由于系统调用或不可抢占的操作,会发生什么?在这种情况下,调度器会介入并为该 goroutine 创建一个新线程。

(需要注意的一点是:如果一个线程只是在进行昂贵的计算或长时间运行的任务,它不被视为陷入困境或被阻塞)

如果你想改变默认的线程限制,可以使用runtime/debug.SetMaxThreads()函数,它允许你设置 Go 程序可使用的最大操作系统线程数。

另外,值得一提的是,线程会被重用,因为创建或删除线程是一个资源密集型的操作。

MPG 是如何协同工作的

让我们通过以下步骤一步步理解 M、P 和 G 是如何协同工作的。

在这里我不会深入探讨每一个细节,但在后续的文章中会更深入地探讨。如果你对此感兴趣,请关注我的公众号。

4a6aeb1b2236464f8a23b646b669be9e.jpeg

  1. 初始化一个 goroutine:使用 go func()命令时,Go 运行时会新建一个 goroutine 或从池中选择一个已存在的 goroutine。

  2. 入队排位:goroutine 会寻找一个队列来加入,如果所有逻辑处理器(P)的本地队列都已满,该 goroutine 会被放入全局队列。

  3. 线程配对:这就是 M 发挥作用的地方。它获取一个 P,并开始从 P 的本地队列处理 goroutine。当 M 与这个 goroutine 交互时,其关联的 P 就会被占用,无法分配给其他 M。

  4. 窃取行为:如果一个 P 的队列被耗尽,M 会试图从另一个 P 的队列"借用"一半可运行的 goroutine。如果失败,它会检查全局队列,然后是网络轮询器(参见下面的"窃取过程"图)。

  5. 资源分配:在 M 选择一个 goroutine(G)后,它会为运行这个 G 获取所需的所有资源。

"如果一个线程被阻塞了怎么办?"

如果一个 goroutine 启动了一个需要一段时间的系统调用(比如读取文件),M 会一直等待。

但调度器不喜欢一直等待,它会将被阻塞的 M 从它的 P 上分离,然后将队列中另一个可运行的 goroutine 连接到一个新的或已存在的 M 上,M 再与 P 团队合作。

68831e0c032fb41be8aaebd8fd22a7ec.jpeg

窃取过程

当一个线程(M)完成了它的任务,没有其他事情可做时,它不会就这样闲置。

相反,它会主动寻找更多工作,方法是查看其他处理器并接手它们一半的任务,让我们来分解一下这个过程:

cf6f52230adb7b1c4aa6ebe467bc96ca.jpeg

  1. 每 61 个嘀嗒,一个 M 会检查全局可运行队列,以确保公平执行。如果在全局队列中找到了可运行的 goroutine,就停止。

  2. 该线程 M 现在会检查与它所在的处理器 P 相连的本地运行队列,看看有没有可运行的 goroutine 需要处理。

  3. 如果该线程发现它的队列为空,它就会查看全局队列,看看是否有任何等待中的任务。

  4. 然后,该线程会向网络轮询器询问是否有任何与网络相关的工作。

  5. 如果该线程在检查完网络轮询器后仍然没有找到任何任务,它就会进入主动搜索模式,我们可以将其视为自旋状态。

  6. 在这种状态下,该线程会尝试从其他处理器的队列中"借用"任务。

  7. 经过这些步骤后,如果该线程仍然没有找到任何工作,它就会停止主动搜索。

  8. 现在,如果有新的任务到来,并且有空闲的处理器没有正在搜索的线程,另一个线程就可以开始工作。

需要注意的一点是,全局队列实际上被检查了两次:一次是每 61 个嘀嗒检查一次以保证公平性,另一次是在本地队列为空时检查。

"如果 M 已与其 P 绑定,它怎么能从其他处理器获取任务呢?M 会改变它的 P 吗?"

答案是不会。

即使 M 从另一个 P 的队列中获取任务,它也是使用原来的 P 来运行该任务。因此,尽管 M 获取了新任务,但它仍然忠于自己的 P。

"为什么是 61?"

在设计算法时,尤其是哈希算法时,通常会选择素数,因为素数除了 1 和自身之外没有其他因子。

这可以减少出现模式或规律性的可能性,从而避免发生"冲突"或其他不希望出现的行为。

如果时间过短,系统可能会频繁浪费资源检查全局运行队列。如果时间过长,goroutine 可能会在执行前过度等待。

网络轮询器(Network Poller)

我们还没有太多讨论这个网络轮询器,但它出现在了窃取过程的示意图中。

与 Go 调度器一样,网络轮询器也是 Go 运行时的一个组件,负责处理与网络相关的调用(例如网络 I/O)。

让我们比较一下两种系统调用类型:

  • 与网络相关的系统调用: 当一个 goroutine 执行网络 I/O 操作时,它不会阻塞当前线程,而是向网络轮询器注册。轮询器异步等待操作完成,一旦完成,该 goroutine 就可以再次变为可运行状态,并在某个线程上继续执行。

  • 其他系统调用: 如果它们可能会阻塞且没有由网络轮询器处理,它们可能会导致 goroutine 将执行卸载到一个操作系统线程上。只有该特定的操作系统线程会被阻塞,Go 运行时调度器可以在其他线程上执行其他 goroutine。

在后续部分,我们将更深入地探讨抢占式调度,并分析调度器在运行过程中所采取的每一步骤。

原文:Goroutine Scheduler Revealed: Never See Goroutines the Same Way Again[1]

Reference

[1]

Goroutine Scheduler Revealed: Never See Goroutines the Same Way Again: https://blog.devtrovert.com/p/goroutine-scheduler-revealed-youll

- END -


推荐阅读:

2024 Gopher Meetup 武汉站活动

go 中更加强大的 traces

2024年的Rust与Go

「GoCN酷Go推荐」我用go写了魔兽世界登录器?

Go区不大,创造神话,科目三杀进来了

Go 1.22新特性前瞻

想要了解Go更多内容,欢迎扫描下方👇关注公众号,扫描 [实战群]二维码  ,即可进群和我们交流~


- 扫码即可加入实战群 -

496976a10ab5d65ed88ac276050571b4.png

87a883b4ff7d838549d7156c625f7294.png

分享、在看与点赞Go 1bdc29d045d3cce116614d386dd784c0.gif

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值