goroutines 在设计上是为了轻量和易于使用,这使得在 Go 语言中编写同时执行多种独立任务的并发程序变得非常简单。同时,它们与 channels 的结合使用也是 Go 并发模型的一个特色,提供了一种优雅的方式来进行 goroutines 之间的通信。
2. GMP 指的是什么
在 Go 语言的并发模型中,GMP 是内部用于描述 goroutine 调度的三个主要组件:
G(Goroutine): G 代表一个 goroutine,它包含了执行一个 goroutine 所需的所有信息,比如 goroutine 的堆栈、goroutine 的状态以及 goroutine 正在执行的任务等。每个 G 对于一个独立执行的活动。
M(Machine):M 代表一个 OS 线程(machine),负责自行 code。OS 线程 M 在 Go 的运行时中是与内核线程一一对应的。M 需要获取一个 G 才能执行里面的代码。
P(Processor):P 代表处理器,实际上是用来给 G 提供运行环境的资源。每个 P 都有一个本地的运行队列(runqueue),用于调度准备要运行的 G。P 的数量决定了系统同时运行的 G 的最大数量,因为每个 G 在执行时都必须绑定到一个 P 上,即使是有成千上万的 G,如果 P 的数量有限,那也只有有限的 G 可以并行运行。
GMP 模型的设计使得 Go 可以在少量的线程上高效地调度大量的 goroutines,有效地使用系统资源,同时减少了不必要的调度开销。
Goroutine 到线程的多路复用:许多 G 可以被多路复用到较少的 M 上运行,这样可以在用户级别快速地进行调度,而内核线程数量相对较少(通过 M 表示),从而减少了操作系统调度的负担。
工作窃取:为了平衡各个 P 上的工作量,Go 语言的运行时采用了工作窃取的策略。空前的 P 可以从其他 P 的 runqueue 中“窃取”G 来执行。
系统调度与线程阻塞:如果一个 goroutine 需要进行系统调用而阻塞,它所在的 M 会被阻塞,而该 M 上的 P 则会分离出来,临时绑定到另一个新的线程(M)上继续执行其他的 Goroutine,以此保持运行不受影响。
调度器的扩展性:随着可用的 P 的数量的增加,Goroutine 的调度和执行也可以水平扩展。比如,当我们在多核处理器上运行 Go 程序时,可以通过增加 P 的数量来充分利用多核资源。
GMP 是 Go 调度器的内部实现细节,对于大多数 Go 程序员而言是透明的,它们无需关系调度器如何工作,只需直到 goroutines 将以并发的方式执行即可。然而,理解 GMP 对于神日掌握 Go 的并发性能优化和问题分析是由帮助的。
3. 1.0 之前 GM 调度模型
在 Go 1.0 发布之前的早期版本中,Go 使用的是基本的 GM(Goroutine-Machine)调度模型,而没有我们现在知道的 P 的概念。在这个基本的调度模型中,关键点如下:
G(Goroutine):Goroutine 是 Go 的并发执行单元,早期版本中的 goroutines 在调度和本质上与现在的版本相似,但是它们被调度到线程上的方式有所不同。
M(Machine):在没有 P 的概念时,每个 OS 线程直接从全局队列中获取 G(goroutine) 进行执行。这意味着所有可运行的 goroutines 都放在同一个全局队列中。
这个 GM 调度模型存在的一些缺点:
全局队列导致竞争:因为所有的 OS 线程都要从同一个全局队列获取 goroutines,所以随着 CPU 内核数量的增加,会出现线程之间的竞争,从而影响性能。
没有本地队列优化:现在的 Go 调度器为每个 P 维护一个本地队列,能够减少锁竞争,并有助于提高缓存的效率(因为相同线程重复执行相同的任务更有可能利于缓存)。早期的模型没有这样的优化。
没有工作窃取:现代的 Go 调度器允许一个线程(具有 P)从另一个撑满工作的线程偷取 goroutines(成为工作窃取)。早期的模型没有工作窃取,因此不太可能平衡线程之间的工作量。
为了解决这些问题,Go 在 1.0 的版本之后引入了 P(Processor)的概念。这使得每个 P 可以拥有自己的本地 goroutine 队列,并且执行工作窃取算法。当一个线程(M)要执行 goroutine 时,它必须首先获取一个 P(即执行资源),然后再从 P 的本地队列获取全局队列中,或通过工作窃取获取 goroutine 来执行。这样的改进大大减少了线程间的竞争,并优化了调度器的性能和效率。 此外,P 的引入还允许在更细的粒度上处理系统调用阻塞的问题,当 goroutine 需要执行系统调用时,它所在的线程(M)会释放 P 给其他线程使用,直到系统调用完成,这样就不会阻塞其他 goroutine 的执行。
4. GMP 调度流程
Go 语言的调度器是一个复杂系统,使用了一种被成为 M:P:G 的工作量管理模型来实现高效的并发。下面概述了 Go 调度器中 GMP 调度流程的基本概念:
启动阶段:
当 Go 程序开始执行时,它会初始化一定数量的 P,P 的数量默认等于及其的 CPU 核心数。同时,程序至少会启动一个 OS 线程(即 M),这些线程从 P 的本地允许队列中获取 goroutine(即 G)来执行。
新建 Goroutine:
当 Go 代码中执行 go语句以启动新的 goroutine 时,该 goroutine 会被放入当前 P 的本地运行队列。
如果当前 P 的本地玉兴队列已满,它会尝试将该 goroutine 放入全局队列,或者将一部分 goroutine 分发到其他 P 的本地队列。
运行阶段:
每个 M 都必须持有一个 P 才能执行 G。M 从绑定的 P 的本地运行队列中弹出 G 并执行。
如果本地队列为空,M 可以尝试从全局队列中获取 G,获取从其他 P 的本地队列中“偷取”G。
阻塞和唤醒:
如果正在执行的 G 进入了阻塞的系统调用(比如文件 I/O),它所在的 M 会被阻塞,P 会解绑该 M,并尝试唤起或创建一个新的 OS 线程(M)来接管 P,并继续从运行队列中运行 G。
当阻塞的系统调用完成后,该 M 可能会再次尝试获取一个 P。
同步调度(Scheduling):
当 G 执行完毕或者在特定的同步点上(如 channel 操作,锁机制等)等待时,调度器会进行调度,将其挂起,然后选择另一个 G 执行。
P 中可能维护一个有待执行的 G 列表,调度器从中选择下一个要执行的 G。
工作窃取:
当一个 M 的本地队列耗尽工作时,它将尝试偷取来自另一个 P 队列一半的 G。
这种窃取机制有助于平衡不同 P 间的工作负载。
休眠与唤醒:
如果 M 在偷取尝试后仍无法找到 G,它将与 P 一起进入休眠状态。当新的 G 被创建或现有 G 可执行时,P/M 组合将被唤醒以执行新的或变为可执行状态的 G。
这个调度过程的目的是为了最大化 CPU 利用率和最小化延迟,并通过避免不必要的 OS 线程创建来减少资源消耗。它通过保持调度器的活跃度以及尽量使 OS 线程处于工作状态来实现,并且还避免了锁的竞争,这得益于每个 P 都有自己的本地队列和工作窃取策略。需要注意的使,Go 的调度器细节可能随着版本更新而变化,上述描述适用于 1.x 系列的 Go 版本。
5. GMP 中 work stealing 机制
在 Go 语言的 GMP 调度模型中,工作窃取(work stealing)是一个核心机制,用来平衡各个处理器 P 中的工作负载。这种机制允许闲置的线程(或者说绑定处理器 P 的线程 M)从忙碌的线程中窃取 G 来执行。下面是工作窃取机制的工作流程:
本地运行队列检查:
当某个线程(M)完成了其当前的 G 的执行或者它的本地运行队列为空时,它会首先检查其绑定的处理器(P)的本地运行队列是否有待执行的 G。
全局队列检查:
如果 P 的本地运行队列为空,M 将尝试从全局运行队列中获取一个新的 G。
窃取尝试:
当全局队列也为空时,M 会随机选择一个 P,并尝试从它的本地运行队列中窃取一半的 G。选择哪个 P 是随机的,这样可以增加窃取成功率并降低特定 P 队列的竞争。
恢复工作:
如果窃取成功,M 会将这些从其他 P 队列窃取的 G 放入本地运行队列,并开始执行它们。
休眠与唤醒:
如果窃取没有成功(即其他所有 P 都没有可运行的 G),这个线程 M 可能会进入休眠状态,直到有新的 G 被创建或现有的变为可运行状态,此时,它可以被唤醒。
这个机制确保了 CPU 时间不会被浪费在搜索可运行的 G 上,同时还能避免某个线程空闲而其他线程过载的情况发生。通过使用本地队列,Go 减少了锁竞争,由于大部分时间每个 M 之访问其绑定的 P 的本地队列,这样线程键几乎无需相互干扰。当必须进行交互时(例如,在工作窃取的情况下),Go 使用精心设计的算法来最小化锁争用并保持高效的并发执行。 工作窃取因此时支撑 Go 高性能并发调度的关键机制之一,它可以动态地适应各种工作负载,从而优化了 G 的执行和调度。这也提醒按了 Go 调度器的设计理念,即尝试尽可能在用户空间内解决问题,而非依靠操作系统内核的调度。
6. GMP 的 hand off 机制
在 Go 语言的 GMP 调度模型中,交接(hand off)机制通常指的是在 goroutine 执行系统调用或者其他可能导致线程阻塞时处理 M(Machine,操作系统线程)和 P(Processor,处理器上下文)之间关联的过程。这个机制确保了系统调用不会阻断整个调度器的工作,特别是在只有一个或几个线程(M)阻塞的情况下,让其他待运行的 goroutine (G)能够继续执行。 下面是 hand off 过程的基本步骤:
系统调用阻塞:
如果一个 G 需要执行可能会阻塞的系统调用(例如,读取一个未就绪的文件),它所在的线程 M 将会被操作系统阻塞。
解绑 P:
为了不浪费处理器资源,阻塞线程 M 将其处理器 P 解绑并放回处理器池中,这样其他可运行的线程 M 可以使用这个处理器 P。
创建或唤醒线程:
然后,Go 调度器可能会选择唤醒一个闲置的线程,或者创建一个新的线程(如果没有足够的闲置线程),将其与处理器 P 绑定,继续执行其他的 G。
系统调用完成:
当初步阻塞的系统调用完成后,原来的线程 M 不会立即尝试获取处理器 P 继续原理的工作。取而代之的是,这个线程将会将完成的 G 放入全局队列或者其他处理器 P 的本地队列,并尝试找到一个空闲的 P 与之绑定。
重新进入调度器:
一旦 M 绑定了一个新的 P,它变可以继续执行其他 G。如果没有 P 可用,线程 M 可能会进入休眠状态,等待被唤醒。
通过这种方式,hand off 机制降低了因系统调用而导致的线程阻塞对整体调度器性能的影响。Go 以用户态的方式关联多数的并发调度任务,只有在不得不进行系统调用时才与操作系统的内核调度器发生交互,从而实现了轻量级且高效的并发模型。
7. 协作式的抢占式调度
1.14 版本之前,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。这种方式存在问题有:
在标记过程中发现存活对象:当一个对象(设为 A)被标记并被视为“黑色”后,理论上它不应再指向任何未标记的“白色”对象。如果在并行标记过程中,黑色对象 A 被更改以指向一个白色对象 B(即应用代码创建了 A 到 B 的新引用),那么混合写屏障确保将对象 B 标记为“灰色”,即加入到待处理队列中,以便稍后进行标记,从而保持不变性。