你以前可能听说过 Goroutine 调度器,但你对它的工作原理了解多少?它如何将 goroutine 与线程配对?
原文:Goroutine Scheduler Revealed: Never See Goroutines the Same Way Again
不用着急理解上面的图像,因为我们要从最基本的开始。
goroutine 被分配到线程中运行,这由 goroutine 调度器在后台处理。根据我们之前的讨论,我们了解到以下关于 goroutine 的几点:
你可能以前听说过 goroutine 调度器,但我们真正了解它的工作原理吗?它是如何将 goroutine 与线程配对的?
现在让我们一步一步地分解调度器的工作原理。
Go 团队真的为我们简化了并发编程,想想看:创建一个 goroutine 只需要在函数前加上 go 关键字就可以了。
|
|
但在这个简单的步骤背后,有一个更深层次的系统在运作。
一开始,Go 就没有简单地为我们提供线程。相反,中间有一个助手,即 goroutine 调度器,它是 Go 运行时的关键部分。
那么M:N
这个标签是什么意思呢?
它体现了Go调度器在将M
个goroutine映射到N
个内核线程方面的作用,形成了M:N
模型。操作系统线程的数量可以多于CPU核心数,就像goroutine的数量也可以多于操作系统线程一样。
在深入探讨调度器之前,让我们先区分一下经常混淆的两个概念:并发和并行。
让我们看看 Go Scheduler 如何使用线程。
在我们解开内部工作原理之前,让我们先解释一下P、M和G分别代表什么意思。
goroutine是Go中最小的执行单元,类似于一个轻量级线程。
在Go运行时,它由一个名为g
的struct表示。一旦创建,它就会被放入逻辑处理器P的本地可运行队列(或全局队列),之后P会将它分配给一个实际的内核线程(M)。
goroutine通常存在三种主要状态:
goroutine不是一次性使用后就被丢弃的。
相反,当启动一个新的goroutine时,Go的运行时会从goroutine池中选择一个,如果池中没有,它会创建一个新的。然后,这个新的goroutine会加入某个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数量 - 想象它们并排运行。
一个典型的Go程序最多可使用10,000个线程。
没错,我说的是线程而不是goroutine。如果超过这个限制,你的Go应用程序就有崩溃的风险。
"线程是何时创建的呢?"
想象这种情况:一个goroutine处于可运行状态并需要一个线程。
如果所有线程都已被阻塞,可能是由于系统调用或不可抢占的操作,会发生什么?在这种情况下,调度器会介入并为该goroutine创建一个新线程。
(需要注意的一点是:如果一个线程只是在进行昂贵的计算或长时间运行的任务,它不被视为陷入困境或被阻塞)
如果你想改变默认的线程限制,可以使用runtime/debug.SetMaxThreads()
函数,它允许你设置Go程序可使用的最大操作系统线程数。
另外,值得一提的是,线程会被重用,因为创建或删除线程是一个资源密集型的操作。
让我们通过以下步骤一步步理解M、P和G是如何协同工作的。
在这里我不会深入探讨每一个细节,但在后续的文章中会更深入地探讨。如果你对此感兴趣,请关注我的公众号。
"如果一个线程被阻塞了怎么办?"
如果一个goroutine启动了一个需要一段时间的系统调用(比如读取文件),M会一直等待。
但调度器不喜欢一直等待,它会将被阻塞的M从它的P上分离,然后将队列中另一个可运行的goroutine连接到一个新的或已存在的M上,M再与P团队合作。
当一个线程(M)完成了它的任务,没有其他事情可做时,它不会就这样闲置。
相反,它会主动寻找更多工作,方法是查看其他处理器并接手它们一半的任务,让我们来分解一下这个过程:
需要注意的一点是,全局队列实际上被检查了两次:一次是每61个嘀嗒检查一次以保证公平性,另一次是在本地队列为空时检查。
"如果M已与其P绑定,它怎么能从其他处理器获取任务呢?M会改变它的P吗?"
答案是不会。
即使M从另一个P的队列中获取任务,它也是使用原来的P来运行该任务。因此,尽管M获取了新任务,但它仍然忠于自己的P。
"为什么是61?"
在设计算法时,尤其是哈希算法时,通常会选择素数,因为素数除了1和自身之外没有其他因子。
这可以减少出现模式或规律性的可能性,从而避免发生"冲突"或其他不希望出现的行为。
如果时间过短,系统可能会频繁浪费资源检查全局运行队列。如果时间过长,goroutine可能会在执行前过度等待。
我们还没有太多讨论这个网络轮询器,但它出现在了窃取过程的示意图中。
与Go调度器一样,网络轮询器也是Go运行时的一个组件,负责处理与网络相关的调用(例如网络I/O)。
让我们比较一下两种系统调用类型:
在后续部分,我们将更深入地探讨抢占式调度,并分析调度器在运行过程中所采取的每一步骤。