G (Goroutine) :即 Go 协程。它是 Go 程序中并发执行的基本单元,拥有自己的栈空间、指令指针以及其他用于调度和执行的上下文信息。G 的数量可以非常庞大。
P (Processor) :逻辑处理器。P 并非指物理 CPU 核心,而是 Go runtime 中的一个概念,它代表了 M (内核线程) 执行 Go 代码所需要的上下文和资源,例如本地可运行 G 的队列(Local Run Queue, LRQ)、内存分配状态等。每个 P 同时只能运行一个 G。P 的数量通常由环境变量 GOMAXPROCS 决定,默认情况下等于可用的 CPU 核心数。
M (Machine) :内核线程,即操作系统管理的线程。M 是实际执行 Go 代码的实体。一个 M 必须与一个 P 关联才能执行 G。
我们首先从宏观层面理解这种设计背后的考量:
通过设定 GOMAXPROCS 来控制 P 的数量,Go 程序既能确保充分利用多核 CPU 的计算能力,又避免了因过多线程竞争 CPU 资源而导致的性能下降。通常,P 的数量与 CPU 核心数相等,这意味着在理想情况下,每个核心都有一个 P 在积极地调度和执行 G。
P 的角色至关重要,它作为 G 和 M 之间的桥梁。P 持有一个本地可运行 G 的队列 (LRQ),当 M 需要执行任务时,它会从其关联的 P 的 LRQ 中获取 G 来执行。这种设计使得 G 的调度大部分发生在用户态,避免了频繁的内核态切换。
此外,P 与 M 的结合实现了线程的复用。当一个 M 因为执行的 G 进行了阻塞性的系统调用(syscall)而被阻塞时,它所关联的 P 可以被释放,并被另一个空闲的 M 或者一个新创建的 M 获取,从而继续执行 P 本地队列中的其他 G。这样就避免了因为少数阻塞操作导致大量线程闲置,同时也减少了线程频繁创建和销毁的开销,相当于 Go runtime 内部实现了一个高效的线程池。这一切对编写 Go 代码的用户来说是透明的。
GPM 是如何调度的?
要理解 GPM 的调度机制,首先需要了解几个关键的概念和数据结构:
全局运行队列 (Global Run Queue, GRQ) :当 P 的本地运行队列没有空间,或者某些 G(例如从网络调用返回的 G、被抢占的 G)被唤醒或需要重新调度时,它们可能会被放入全局运行队列。
P 的本地运行队列 (Local Run Queue, LRQ) :每个 P 都有一个自己的 LRQ,用于存放待在该 P 上执行的 G。M 会优先从其关联 P 的 LRQ 中获取 G。LRQ 的存在减少了对 GRQ 的竞争,提高了调度效率。
g0 :每个 M 都有一个特殊的 goroutine,称为 g0。g0 拥有自己的栈空间(独立于用户 G 的栈,通常较大),主要用于执行调度相关的代码、垃圾回收的辅助工作以及其他运行时任务。当 M 需要切换到某个用户 G 执行时,会从 g0 栈切换到用户 G 的栈;反之亦然。
m.curg :指向当前在 M 上运行的用户 G。
G 的状态 :Goroutine 在其生命周期中会经历多种状态,例如 _Gidle(闲置,刚被分配还未使用)、_Grunnable(可运行,在运行队列中等待调度)、_Grunning(运行中,正在 M 上执行)、_Gsyscall(进行系统调用,M 已与 P 分离)、_Gwaiting(等待中,如等待 channel 操作、锁、或定时器)、_Gdead(已结束,资源可回收)、_Gcopystack(栈复制中,通常在栈增长时发生)、_Gpreempted(被抢占,等待重新调度)。
P 的状态 :P 也有不同的状态,如 _Pidle(闲置,没有 M 与之关联或没有可运行的 G)、_Prunning(运行中,有 M 与之关联并正在执行 G 或调度代码)、_Psyscall(其关联的 M 正在进行一个阻塞的系统调用,P 本身可能被其他 M 使用)、_Pgcstop(因垃圾回收而停止)、_Pdead(不再使用,例如 GOMAXPROCS 被调小时)。
调度决策在很大程度上是每个 M 各自独立在其 g0 栈上执行的。当一个 M 空闲下来(例如,其当前 G 执行完毕或被阻塞),它会运行调度代码来寻找下一个可运行的 G。
没有单一的“总控”M :Go 的调度器设计上是去中心化的,没有一个特定的 M 作为“总控制器”来指挥所有其他 M。这种设计避免了单点瓶颈,提高了并发度。
协调机制 :尽管调度是分布式的,但 M 之间通过一些共享结构和机制进行协调:
全局运行队列 (GRQ) :为所有 P 提供了一个共享的 G 来源。
工作窃取 (Work Stealing) :空闲的 M 会尝试从其他 P 的 LRQ 中“窃取”任务。
sysmon 后台监控线程 :这是一个特殊的 M(不与 P 绑定),它负责一些全局性的协调任务,比如垃圾回收的触发和辅助、网络轮询器(Netpoller)事件的处理(间接影响调度,通过将等待 I/O 的 G 变为可运行状态)、以及检测并抢占长时间运行的 G。sysmon 更像是一个维护者和协调者,而非一个命令下发者。
P 的管理 :Go runtime 负责管理 P 的池。当 M 因系统调用阻塞时释放 P,或当有空闲 P 和可运行 G 时,runtime 会尝试唤醒或创建 M 来绑定这些 P。
GRQ 也为空 :如果 GRQ 也为空,M0 可能会将 P0 置为 _Pidle 状态,并解除 M0 与 P0 的关联,M0 自身也可能进入休眠(park)状态,等待新的 G 到来时被唤醒。或者,M0 会去自旋(spinning)一段时间,期望短期内有新的 G 产生。
自旋 (spinning) 是指 M 在一个紧密的循环中不断检查是否有可运行的 G,而不立即放弃 CPU。
CPU 占用 :在自旋期间,M 会持续消耗 CPU 资源,如果该 CPU 核心上没有其他更高优先级的任务,它可能会达到 100% 的占用率。
为何自旋 :这是一种以 CPU 时间换取调度延迟的策略。如果新的 G 很快就能变为可运行状态(例如,另一个 M 正在处理一个即将完成的短任务,或者一个 I/O 事件即将触发),那么自旋可以避免 M 进入休眠和随后被唤醒所带来的开销(这通常涉及操作系统层面的上下文切换,成本相对较高)。
自旋的条件与限制 :Go runtime 中的自旋不是无限制的。
通常,只有当系统中存在其他活跃的 P(意味着其他 M 正在工作,有可能产生新的 G)时,M 才会进入自旋状态。如果所有 P 都已空闲,则 M 倾向于直接休眠。
同时,runtime 会限制并发自旋的 M 的数量,以避免过多的 M 同时无效自旋。
自旋的持续时间或迭代次数是有限的。如果经过短暂的自旋后仍未找到 G,M 将停止自旋,释放其 P(如果 P 上确实没有 G),并进入休眠(park)状态,将 CPU 让给其他进程或线程。
自旋是一种短期内积极寻找任务的优化手段,适用于预期任务会很快出现的场景,以减少调度开销,但它确实会短暂地增加 CPU 使用率。
在这个过程中,P 的状态也会相应变化。例如,当一个 M 成功与一个 P 绑定并开始查找或执行 G 时,P 的状态会是 _Prunning。如果 P 的 LRQ 和 GRQ 都长时间为空,并且没有 M 依附于它,它可能进入 _Pidle 状态。
G 的栈数据切换发生在 M 从 g0 栈切换到用户 G 的栈,以及从用户 G 的栈切回 g0 栈时。这个切换操作会保存和恢复各自的栈指针和寄存器等上下文信息。 2. 栈的伸缩与 P 的竞争
栈的动态伸缩 :Goroutine 的栈在创建时通常较小(例如 2KB)。当 G 执行的函数调用深度增加,需要的栈空间超过当前大小时,Go runtime 会触发一个称为 morestack 的机制。该机制会分配一个新的、更大的栈段,并将旧栈的内容拷贝到新栈段,然后 G 继续在新栈上执行。这个过程对用户是透明的。当函数返回,栈使用量减少时,虽然不会立即缩小,但在垃圾回收期间,如果发现栈使用率过低,可能会进行栈的收缩(shrinkstack)。
P 的竞争 :在 Go 程序启动时,会根据 GOMAXPROCS 创建相应数量的 P。如果 M 的数量少于 P 的数量(例如,某些 M 因为系统调用阻塞了),或者有空闲的 P 和待运行的 G,运行时可能会唤醒或创建新的 M 来绑定这些 P。一个 M 必须获取到一个 P 才能运行 Go 代码。如果所有 P 都在 _Prunning 状态(即都有 M 在其上运行 G),那么新创建的 G 只能进入 LRQ 或 GRQ 等待。当一个 M 从阻塞的系统调用返回,或者一个 G 执行完毕,它会尝试获取一个 P 来继续执行。
G 的唤醒 :Netpoller 收到内核通知后,会识别出是哪个 G 在等待这个 FD 上的事件。它会将该 G 从 _Gwaiting 状态转换回 _Grunnable 状态,并将其放入一个运行队列 (通常是 GRQ,有时也可能是某个 P 的 LRQ,例如上次运行该 G 的 P,以期利用缓存局部性)。
重新调度执行 :一旦 Gx 变为 _Grunnable,它就和其他可运行的 G 一样,等待某个 M/P 组合来执行它。当轮到它时,它会从上次阻塞的地方继续执行。
这种机制确保了少数 G 的阻塞性 I/O 不会阻塞整个程序的并发执行。M 的数量可能会根据需要动态调整(在一定范围内),以适应负载情况。 创建一个 go func(){}() 发生了什么?
当你执行一行代码 go func(){ ... }() 时,Go runtime 会执行以下步骤:
创建 G 对象 :首先,runtime 会在堆上分配并初始化一个新的 G 对象。这个对象包含了新 goroutine 的栈信息(初始分配一个小栈)、程序计数器(指向匿名函数的起始位置)以及其他状态信息。
设置初始状态 :新创建的 G 的初始状态被设置为 _Grunnable,表示它已经准备好运行,只等待调度器的调度。
放入队列 :这个新的 _Grunnable 的 G 通常会被尝试放入当前 M 所关联的 P 的 LRQ。
如果该 P 的 LRQ 已满,runtime 会尝试将 P 的 LRQ 中的一部分 G(包括这个新的 G)均衡到 GRQ 中。
在某些情况下,如果创建 G 的 P 处于特殊状态,或者为了更好的负载均衡,新的 G 也可能直接被放入 GRQ。
创建 G 的函数返回 :go 语句本身是一个非阻塞调用。执行 go 语句的 goroutine 会继续执行其后续代码,而不会等待新创建的 goroutine 开始或完成执行。
调度与执行 :新创建的 G 现在位于某个运行队列中。当某个 M(可能就是当前的 M,也可能是其他 M)在未来的某个调度点(例如,当前 G 执行完毕、发生抢占、或 M 从系统调用返回时)查找可运行的 G 时,它就有机会从 LRQ 或 GRQ 中获取这个新的 G。获取到 G 后,M 会设置好运行环境(切换到该 G 的栈,设置 G 的状态为 _Grunning 等),然后开始执行该匿名函数内的代码。
整个过程与上面描述的 GPM 调度机制紧密相连,新的 G 只是作为调度器可调度的一个单元被高效地管理起来。 调度策略与抢占机制
Go 的调度器采用了一些关键策略来保证公平性和效率:
工作窃取 (Work Stealing) :如前所述,当一个 P 的 LRQ 为空时,其关联的 M 会尝试从其他 P 的 LRQ 中“窃取”一半的 G 到自己的 LRQ,或者从 GRQ 中获取 G。这有助于在 P 之间均匀分配工作负载,防止某些 P 空闲而另一些 P 过载。
抢占 (Preemption) :在 Go 的早期版本中(1.14 之前),抢占主要是协作式的。也就是说,一个 goroutine 主动放弃 CPU 的执行权通常发生在函数调用时(编译器会在函数入口处插入检查点,判断是否需要进行栈增长以及是否需要被抢占)、channel 操作、select 语句、以及一些同步原语的调用点。这意味着如果一个 goroutine 执行一个没有任何函数调用的密集计算循环 (for {}),它可能会长时间占据 M,导致同一个 P 上的其他 goroutine 饿死。
从 Go 1.14 版本开始,引入了 基于信号的异步抢占机制 (asynchronous preemption) ,以解决上述问题: