你以前可能听说过 Goroutine 调度器,但你对它的工作原理了解多少?它如何将 goroutine 与线程配对?
原文:Goroutine Scheduler Revealed: Never See Goroutines the Same Way Again
不用着急理解上面的图像,因为我们要从最基本的开始。
goroutine 被分配到线程中运行,这由 goroutine 调度器在后台处理。根据我们之前的讨论,我们了解到以下关于 goroutine 的几点:
- 就原始执行速度而言,goroutine 并不一定比线程更快,因为它们需要在实际线程上运行。
- goroutine 真正的优势在于上下文切换、内存占用、创建和销毁的成本等方面。
你可能以前听说过 goroutine 调度器,但我们真正了解它的工作原理吗?它是如何将 goroutine 与线程配对的?
现在让我们一步一步地分解调度器的工作原理。
goroutine M:N 调度器模型
Go 团队真的为我们简化了并发编程,想想看:创建一个 goroutine 只需要在函数前加上 go 关键字就可以了。
|
|
但在这个简单的步骤背后,有一个更深层次的系统在运作。
一开始,Go 就没有简单地为我们提供线程。相反,中间有一个助手,即 goroutine 调度器,它是 Go 运行时的关键部分。
那么M:N
这个标签是什么意思呢?
它体现了Go调度器在将M
个goroutine映射到N
个内核线程方面的作用,形成了M:N
模型。操作系统线程的数量可以多于CPU核心数,就像goroutine的数量也可以多于操作系统线程一样。
在深入探讨调度器之前,让我们先区分一下经常混淆的两个概念:并发和并行。
- 并发: 指同时处理多个任务,这些任务都在运行,但不一定在同一时间运行。
- 并行: 指多个任务在同一时刻真正同时运行,通常使用多个CPU核心。
让我们看看 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不是一次性使用后就被丢弃的。
相反,当启动一个新的goroutine时,Go的运行时会从goroutine池中选择一个,如果池中没有,它会创建一个新的。然后,这个新的goroutine会加入某个P的可运行队列。
P(逻辑处理器)
在Go调度器中,当我们提到"处理器"时,指的是一个逻辑实体,而不是物理实体。
默认情况下,P的数量设置为可用的CPU核心数,你可以使用runtime.GOMAXPROCS(int)检查或更改这些处理器的数量:
|
|
如果你想修改P的数量,最好在应用程序启动时就这样做,因为如果在运行时修改,它会导致STW
(stopTheWorld),所有操作都会暂停,直到处理器大小调整完成。
每个P都有自己的可运行goroutine列表,称为本地运行队列(Local Run Queue),最多可容纳256个goroutine。
如果P的队列已满(256个goroutine),还有一个名为全局运行队列(Global Run Queue)的共享队列,不过我们稍后再讨论这个。
"那么,'P'的数量真正显示了什么呢?"
它表示可以并发运行的goroutine数量 - 想象它们并排运行。
M(机器线程 - 操作系统线程)
一个典型的Go程序最多可使用10,000个线程。
没错,我说的是线程而不是goroutine。如果超过这个限制,你的Go应用程序就有崩溃的风险。
"线程是何时创建的呢?"
想象这种情况:一个goroutine处于可运行状态并需要一个线程。
如果所有线程都已被阻塞,可能是由于系统调用或不可抢占的操作,会发生什么?在这种情况下,调度器会介入并为该goroutine创建一个新线程。
(需要注意的一点是:如果一个线程只是在进行昂贵的计算或长时间运行的任务,它不被视为陷入困境或被阻塞)
如果你想改变默认的线程限制,可以使用runtime/debug.SetMaxThreads()
函数,它允许你设置Go程序可使用的最大操作系统线程数。
另外,值得一提的是,线程会被重用,因为创建或删除线程是一个资源密集型的操作。
MPG是如何协同工作的
让我们通过以下步骤一步步理解M、P和G是如何协同工作的。
在这里我不会深入探讨每一个细节,但在后续的文章中会更深入地探讨。如果你对此感兴趣,请关注我的公众号。
- 初始化一个goroutine:使用go func()命令时,Go运行时会新建一个goroutine或从池中选择一个已存在的goroutine。
- 入队排位:goroutine会寻找一个队列来加入,如果所有逻辑处理器(P)的本地队列都已满,该goroutine会被放入全局队列。
- 线程配对:这就是M发挥作用的地方。它获取一个P,并开始从P的本地队列处理goroutine。当M与这个goroutine交互时,其关联的P就会被占用,无法分配给其他M。
- 窃取行为:如果一个P的队列被耗尽,M会试图从另一个P的队列"借用"一半可运行的goroutine。如果失败,它会检查全局队列,然后是网络轮询器(参见下面的"窃取过程"图)。
- 资源分配:在M选择一个goroutine(G)后,它会为运行这个G获取所需的所有资源。
"如果一个线程被阻塞了怎么办?"
如果一个goroutine启动了一个需要一段时间的系统调用(比如读取文件),M会一直等待。
但调度器不喜欢一直等待,它会将被阻塞的M从它的P上分离,然后将队列中另一个可运行的goroutine连接到一个新的或已存在的M上,M再与P团队合作。
窃取过程
当一个线程(M)完成了它的任务,没有其他事情可做时,它不会就这样闲置。
相反,它会主动寻找更多工作,方法是查看其他处理器并接手它们一半的任务,让我们来分解一下这个过程:
- 每61个嘀嗒,一个M会检查全局可运行队列,以确保公平执行。如果在全局队列中找到了可运行的goroutine,就停止。
- 该线程M现在会检查与它所在的处理器P相连的本地运行队列,看看有没有可运行的goroutine需要处理。
- 如果该线程发现它的队列为空,它就会查看全局队列,看看是否有任何等待中的任务。
- 然后,该线程会向网络轮询器询问是否有任何与网络相关的工作。
- 如果该线程在检查完网络轮询器后仍然没有找到任何任务,它就会进入主动搜索模式,我们可以将其视为自旋状态。
- 在这种状态下,该线程会尝试从其他处理器的队列中"借用"任务。
- 经过这些步骤后,如果该线程仍然没有找到任何工作,它就会停止主动搜索。
- 现在,如果有新的任务到来,并且有空闲的处理器没有正在搜索的线程,另一个线程就可以开始工作。
需要注意的一点是,全局队列实际上被检查了两次:一次是每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。
在后续部分,我们将更深入地探讨抢占式调度,并分析调度器在运行过程中所采取的每一步骤。