现在不要担心理解上面的图片,因为我们将从非常基础的知识开始。
Goroutines分布在线程中,由Goroutine调度器在幕后处理。根据我们之前的讨论,我们知道一些关于Goroutines的事情:
•从原始执行速度来看,Goroutines不一定比线程更快,因为它们需要一个实际的线程来运行。•Goroutines的真正优势在于上下文切换、内存占用、创建和拆除的成本等方面。
你可能之前听说过Goroutine调度器,但我们真正了解它是如何工作的吗?它是如何将Goroutines与线程配对的?
现在让我们一步一步地分解调度器的操作。
1. Goroutine的M:N调度器
Go团队为我们真正简化了并发处理,想想看:创建一个Goroutine就像在函数前面加上 go
关键字一样容易。
go doWork()
但在这个简单的步骤背后,有一个更深层的系统在运作。
从一开始,Go并不是简单地提供了线程。相反,在中间有一个辅助工具,Goroutine调度器,它是Go运行时的关键部分。
那么什么是M:N标签?
它表示Go调度器在将M个Goroutines映射到N个内核线程时所起的作用,形成了M:N模型。你可以拥有更多的操作系统线程,就像可以拥有更多的Goroutines一样。
在我们深入研究调度器之前,让我们澄清一下经常混淆的两个术语:并发和并行。
•并发:这是关于同时处理多个任务,它们都在运动,但不总是在同一时刻。•并行:这意味着许多任务在完全相同的时间运行,通常使用多个CPU核心。
让我们看看Go调度器是如何与线程配合运作的。
2. PMG 模型
在我们深入研究内部工作原理之前,让我们分解一下P、M和G代表什么。
G(Goroutine)
Goroutine充当Go的最小执行单元,类似于轻量级线程。
在Go的运行时中,它由一个名为g
的struct{}
表示。一旦创建,它会找到一个逻辑处理器P的本地可运行队列(或G队列)中的位置,然后P将其交给一个实际的内核线程M。
Goroutines通常存在三种主要状态:
•等待:在这个阶段,Goroutine停滞不前,可能因为通道或锁之类的操作而暂停,或者可能被系统调用暂停。•可运行:Goroutine已经准备好运行,但尚未开始运行,它正在等待轮到它在一个线程(M)上运行。•运行:现在,Goroutine正在一个线程(M)上积极执行。它会一直执行,直到任务完成,除非调度器中断它或其他原因阻止了它的执行。
Goroutines并不仅仅被使用一次然后被丢弃。
相反,当启动新的Goroutine时,Go的运行时会从Goroutine池
中选择一个,但如果找不到任何可用的Goroutine,它会创建一个新的。然后,这个新的Goroutine加入到一个P的可运行队列中。
P(逻辑处理器)
在Go调度器中,当我们提到“处理器”时,我们指的是逻辑实体,而不是物理实体。
默认情况下,P的数量设置为可用的核心数,你可以使用runtime.GOMAXPROCS(int)
函数来检查或更改这些处理器的数量。
runtime.GOMAXPROCS(0) // 获取当前允许的逻辑处理器数量
如果你想更改这个数量,最好是在应用程序启动时更改它,如果在运行时更改,它会导致STW(停止一切),直到重新调整处理器。
每个P都拥有自己的可运行Goroutines列表,称为本地运行队列,最多可以容纳256个Goroutines。
调度器 — P(逻辑处理器)
如果P的队列达到了最大Coroutines数(256),那么就有一个共享队列,称为全局运行队列,但我们将稍后讨论这个。
"那么 'P' 的这个数量到底代表什么?" 它表示可以同时运行的Goroutines数量 — 想象它们并行运行。
M(机器线程 — 操作系统线程)
一个典型的Go程序最多可以使用1万个线程。
是的,我说的是线程,而不是Goroutines。如果超出这个限制,你可能会使你的Go应用程序崩溃。
"什么情况下会创建一个线程?" 想象一种情况:一个Goroutine处于可运行状态并需要一个线程。如果所有线程已经被阻塞,可能是因为系统调用或非抢占操作,会怎么样?在这种情况下,调度器会介入并为该Goroutine创建一个新线程。一个需要注意的事情是,如果一个线程只是忙于昂贵的计算或长时间运行的任务,它不被视为被卡住或被阻塞。如果你想更改默认线程限制,你可以使用
runtime/debug.SetMaxThreads()
函数,它允许你设置你的Go程序可以使用的操作系统线程的最大数量。此外,值得知道的是,线程是可以重复使用的,因为创建或销毁线程是消耗资源的。
3. MPG 工作原理
让我们通过项目符号逐步了解 M、P 和 G 如何一起运作。
我不会在这里深入讨论每个细节,但我将在即将发布的故事中深入探讨。如果你感兴趣,请订阅。
Go Scheduler 的工作原理
1.初始化 goroutine:通过使用 go func()
命令,Go Runtime 要么创建一个新的 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 连接起来,然后与 P 协作。
被阻塞的线程
窃取过程
当一个线程(M)完成了它的任务并没有其他事情可做时,它不会坐在那里。
相反,它积极地寻找更多工作,观察其他处理器并获取它们一半的任务,让我们来详细了解一下:
1.每 61 个时钟滴答,M 检查全局可运行队列,以确保执行的公平性。如果在全局队列中找到一个可运行的 goroutine,就停止。2.然后,线程 M 检查其本地运行队列,与其处理器 P 相关联,以查看是否有可运行的 goroutine 可以处理。3.如果线程发现它的队列是空的,那么它会查看全局队列,看看那里是否有等待处理的任务。4.然后,线程会检查网络轮询器,以查看是否有与网络相关的任务。5.如果线程在检查了网络轮询器后仍然没有找到任务,它将进入主动搜索模式,我们可以将其视为旋转状态。6.在这种状态下,线程试图从其他处理器的队列中“借用”任务。7.经过所有这些步骤后,如果线程仍然找不到工作,它将停止主动搜索。8.现在,如果有新的任务进来,而且有一个没有在搜索状态的空闲处理器,那么可以提示另一个线程开始工作。
需要注意的细节是全局队列实际上被检查了两次:每 61 个时钟滴答一次以确保公平性,如果本地队列为空,就再次检查。
“如果 M 与其 P 相关联,它怎么能从其他处理器那里获取任务呢?M 会更改其 P 吗?” 答案是不会。即使 M 从另一个 P 的队列中获取任务,它仍然使用其原始处理器来运行该任务。因此,在 M 承担新任务的同时,它仍然忠实于其处理器。
“为什么是 61?” 在设计算法时,特别是哈希算法,通常会选择质数,因为它们除了 1 和它们自己之外没有除数。这可以降低出现模式或规律的机会,从而防止“碰撞”或其他不希望出现的行为。如果太短,系统可能会浪费资源频繁检查全局运行队列。如果太长,goroutine 可能会在执行之前等待过长的时间。
网络轮询器
我们还没有详细讨论网络轮询器,但它在窃取过程图表中提到了。
与 Go Scheduler 一样,网络轮询器是 Go Runtime 的组成部分,负责处理与网络相关的调用(例如,网络 I/O)。
让我们比较两种系统调用类型:
•与网络相关的系统调用:当一个 goroutine 执行网络 I/O 操作时,它不会阻塞线程,而是会在网络轮询器中注册。轮询器会异步等待操作完成,一旦完成,goroutine 就会再次可运行,可以在一个线程上继续执行。•其他系统调用:如果它们可能会阻塞并且不由网络轮询器处理,它们可能会导致 goroutine 将其执行卸载到操作系统线程上。只有特定的操作系统线程会被阻塞,Go 运行时调度程序可以在不同线程上执行其他 goroutine。