3

goroutine调度器揭秘

 1 month ago
source link: https://colobu.com/2024/03/19/goroutine-scheduler-revealed/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

behind-goroutines.webp

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

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

  • 就原始执行速度而言,goroutine 并不一定比线程更快,因为它们需要在实际线程上运行。
  • goroutine 真正的优势在于上下文切换、内存占用、创建和销毁的成本等方面。

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

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

goroutine M:N 调度器模型

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

go doWork()

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

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

scheduler.webp

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

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

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

  • 并发: 指同时处理多个任务,这些任务都在运行,但不一定在同一时间运行。
  • 并行: 指多个任务在同一时刻真正同时运行,通常使用多个CPU核心。
    cp.webp

让我们看看 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)上积极执行。它将一直运行直到任务完成,除非调度器中断它或其他事物阻碍了它的运行。

goroutine-states.webp

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。

p.webp

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

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

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

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

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

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

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

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

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

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

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

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

MPG是如何协同工作的

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

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

scheduler-works.webp
  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团队合作。

blocked.webp

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

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

steal.webp
  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。

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK