你以前可能听说过 Goroutine 调度器,但你对它的工作原理了解多少?它如何将 goroutine 与线程配对?
不用着急理解上面的图像,因为我们要从最基本的开始。
goroutine 被分配到线程中运行,这由 goroutine 调度器在后台处理。根据我们之前的讨论,我们了解到以下关于 goroutine 的几点:
你可能以前听说过 goroutine 调度器,但我们真正了解它的工作原理吗?它是如何将 goroutine 与线程配对的?
现在让我们一步一步地分解调度器的工作原理。
Go 团队真的为我们简化了并发编程,想想看:创建一个 goroutine 只需要在函数前加上 go 关键字就可以了。
go doWork()
但在这个简单的步骤背后,有一个更深层次的系统在运作。
一开始,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)检查或更改这些处理器的数量:
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 的队列已满(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)。
让我们比较一下两种系统调用类型:
在后续部分,我们将更深入地探讨抢占式调度,并分析调度器在运行过程中所采取的每一步骤。
原文:Goroutine Scheduler Revealed: Never See Goroutines the Same Way Again[1]
Goroutine Scheduler Revealed: Never See Goroutines the Same Way Again: https://blog.devtrovert.com/p/goroutine-scheduler-revealed-youll