头图

Go 语言(Golang)的一大显著特性是在其语法层面就内建了对协程,即 goroutine 的支持,并且其运行时(runtime)系统为这一功能提供了强大且原生的支撑。在我看来,选择使用协程而非传统的线程来支持高并发任务,带来了诸多益处:

  1. 切换成本更低 :协程的切换是纯用户态的操作,由 Go runtime 直接控制,避免了线程切换时需要在内核态和用户态之间传递上下文信息的开销。相比之下,线程切换由操作系统(OS)层面实现,成本更高。
  2. 调度灵活性 :goroutine 的调度由 Go runtime 决定,而非操作系统。这使得 Go 可以根据应用特性实现更优化的调度策略。
  3. 支持大规模并发 :由于协程在用户态实现且资源占用小(例如,初始栈空间通常只有几 KB),因此可以轻松创建和管理成千上万甚至数百万的 goroutine,远超传统线程所能支持的并发量。
  4. 创建与销毁成本低 :goroutine 的创建和销毁由 Go runtime 管理,其开销远小于操作系统线程。它们的栈空间是动态伸缩的,初始分配很小,按需增长,回收也更高效。
  5. 简化的并发编程模型 :通过 channelselect 等机制,goroutine 使得编写和理解并发逻辑更为简单和安全,减少了对传统并发编程中复杂锁机制的依赖。

然而,这些轻量级的 goroutine 终究需要依托实际的操作系统线程才能在 CPU 上执行。Go 语言是如何高效管理这些 goroutine 的呢?这就引出了我们今天要深入探讨的核心机制—— GPM 模型


总体谈谈 GPM

GPM 是 Go 调度器中三个核心组件的缩写:

  • 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,称为 g0g0 拥有自己的栈空间(独立于用户 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。
  • 这种分布式的调度配合全局协调机制,使得 Go 的调度器既高效又具有良好的伸缩性。

接下来,我们通过几个例子来具体阐述调度过程:

1. 基本调度流程

假设我们有一个 P0 和一个 M0,P0 的 LRQ 中有 G1。

  1. 获取 G :M0 启动后,或者当它完成了前一个 G 的执行后,它会首先查看其关联的 P0 的 LRQ。此时,M0 在 g0 栈上执行调度逻辑。
  2. 执行 G :M0 从 P0 的 LRQ 中取出 G1。G1 的状态从 _Grunnable 变为 _Grunning,P0 的状态保持或变为 _Prunning。M0 的 m.curg 指向 G1。随后,M0 从 g0 栈切换到 G1 的栈,开始执行 G1 的代码。
  3. G 执行完毕 :当 G1 执行完毕(例如函数返回),它会切换回 g0 栈。G1 的状态变为 _Gdead,其资源会被回收。M0 (在 g0 栈上) 接着会再次尝试从 P0 的 LRQ 寻找下一个可运行的 G。
  4. LRQ 为空 :如果 P0 的 LRQ 为空,M0(在 g0 栈上)会尝试进行 工作窃取 (work-stealing) ,它会随机查看其他 P 的 LRQ,如果发现有 G,就窃取一半过来放到自己的 P0 的 LRQ 中。如果其他 P 的 LRQ 也都为空,M0 会尝试从 GRQ 获取 G。
  5. 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 来继续执行。

3. I/O 操作与网络调度

当一个 G (假设为 Gx,在 M1/P1 上运行) 发起一个阻塞性的 I/O 操作,比如网络读写时,情况会变得特殊:

  1. 进入系统调用 :Gx 在 M1 上调用了一个阻塞的 read。Go runtime 的 syscall 包中的函数通常会进行特殊处理。M1 会即将进入阻塞状态。
  2. 释放 P :为了不让 P1 上的其他 G 被饿死,M1 会释放 P1。P1 此时 通常会连同其 LRQ 中的 G 一起 ,被移交给一个其他可用的、处于空闲状态的 M (例如 M2,可以是已存在的空闲 M,或者是 runtime 根据需要新创建的 M 来接管这个 P)。M1 则带着 Gx 进入阻塞的系统调用。Gx 的状态变为 _Gsyscall

这里需要考虑到调度器内部实现的复杂性和一些边缘情况。核心原则是: LRQ 始终与 P 绑定 。当 M1 因 Gx 的系统调用而将要阻塞时,它会释放 P1。

  • 主要情况 :调度器会立即尝试寻找一个空闲的 M (M2) 来接管 P1。如果找到,M2 就绑定 P1,并开始执行 P1 的 LRQ 中的 G。这时,P1 及其 LRQ 完整地从 M1 转移到了 M2。
  • 没有立即可用的 M:如果暂时没有空闲的 M 可以立即接管 P1,P1 会被放入一个空闲 P 队列 (pidle 列表)。其 LRQ 中的 G 仍然与 P1 绑定并处于 _Grunnable 状态。一旦有 M 可用(例如 M1 从系统调用返回后变为空闲,或者 sysmon 检测到需要更多 M 并创建/唤醒了一个),这个 M 就会从 pidle 列表中获取 P1,并开始执行其 LRQ 中的 G。
  • 因此,P1 的 LRQ 中的 G 总是和 P1 在一起 。关键在于 P1 由哪个 M 来服务。如果 M1 阻塞了,它就不能服务 P1,所以 P1 必须寻找新的 M,或者等待 M 变为可用。
  1. 网络轮询器 (Netpoller) :Go runtime 内部维护了一个网络轮询器(在 Linux 上通常基于 epoll,在 macOS 上基于 kqueue,在 Windows 上基于 iocp)。当 Gx 发起网络 I/O 时,其对应的文件描述符会被注册到这个网络轮询器中。M1 线程本身会阻塞在系统调用上(或者对于非阻塞 I/O,G 会等待 netpoller 的通知),但它不再持有 P。
  2. I/O 就绪与唤醒 :当网络轮询器检测到 Gx 等待的文件描述符上的 I/O 操作就绪(例如数据可读),它会通知调度器。Gx 会被标记为 _Grunnable,并被放回到某个 P 的 LRQ (可能是原来的 P1,如果它恰好空闲) 或者 GRQ 中。

Go 的标准库网络操作在底层通常被封装为非阻塞模式,并与 netpoller 集成。

  1. 注册与等待 :当 Gx 调用如 net.Conn.Read() 时,如果数据尚未到达,runtime 不会真的让 M1 线程阻塞在内核的 read() 调用上。相反,它会将 Gx 的状态置为 _Gwaiting,并将与该连接对应的文件描述符 (FD) 注册到 netpoller 中,请求 netpoller 在该 FD 可读时通知。然后,M1 释放 P1(或 P1 被其他 M 接管),M1 可以去执行其他 G 或者休眠。
  2. Netpoller 的监控 :Netpoller (通常是一个独立的系统线程或由 sysmon 驱动) 使用操作系统提供的事件通知机制 (如 epoll_waitkevent 等) 来同时监控大量已注册的 FD。这些机制允许一个线程高效地等待多个 FD 上的事件,而无需为每个 FD 单独创建一个线程。
  3. 事件通知 :当操作系统内核检测到某个 FD 上的数据已到达(对于 read 操作)或可以发送数据(对于 write 操作)时,它会通知 netpoller。
  4. G 的唤醒 :Netpoller 收到内核通知后,会识别出是哪个 G 在等待这个 FD 上的事件。它会将该 G 从 _Gwaiting 状态转换回 _Grunnable 状态,并将其放入一个运行队列 (通常是 GRQ,有时也可能是某个 P 的 LRQ,例如上次运行该 G 的 P,以期利用缓存局部性)。
  5. 调度执行实际读操作 :一旦 Gx 变为 _Grunnable,它就和其他等待调度的 G 一样。当某个 M/P 组合选中它执行时,它会从之前中断的地方恢复。此时,由于 netpoller 已经确认数据就绪,G 可以执行实际的、现在不会阻塞的 read() 操作来获取数据。
  6. 重新调度执行 :一旦 Gx 变为 _Grunnable,它就和其他可运行的 G 一样,等待某个 M/P 组合来执行它。当轮到它时,它会从上次阻塞的地方继续执行。

这种机制确保了少数 G 的阻塞性 I/O 不会阻塞整个程序的并发执行。M 的数量可能会根据需要动态调整(在一定范围内),以适应负载情况。


创建一个 go func(){}() 发生了什么?

当你执行一行代码 go func(){ ... }() 时,Go runtime 会执行以下步骤:

  1. 创建 G 对象 :首先,runtime 会在堆上分配并初始化一个新的 G 对象。这个对象包含了新 goroutine 的栈信息(初始分配一个小栈)、程序计数器(指向匿名函数的起始位置)以及其他状态信息。
  2. 设置初始状态 :新创建的 G 的初始状态被设置为 _Grunnable,表示它已经准备好运行,只等待调度器的调度。
  3. 放入队列 :这个新的 _Grunnable 的 G 通常会被尝试放入当前 M 所关联的 P 的 LRQ。
  • 如果该 P 的 LRQ 已满,runtime 会尝试将 P 的 LRQ 中的一部分 G(包括这个新的 G)均衡到 GRQ 中。
  • 在某些情况下,如果创建 G 的 P 处于特殊状态,或者为了更好的负载均衡,新的 G 也可能直接被放入 GRQ。
  1. 创建 G 的函数返回go 语句本身是一个非阻塞调用。执行 go 语句的 goroutine 会继续执行其后续代码,而不会等待新创建的 goroutine 开始或完成执行。
  2. 调度与执行 :新创建的 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) ,以解决上述问题:

  1. sysmon 后台监控线程 :Go runtime 有一个名为 sysmon 的特殊 M(不关联 P),它会定期进行一些维护工作,其中就包括检查是否有 G 运行时间过长(例如,超过一个固定的时间片,通常是 10ms)。
  2. 发送信号 :如果 sysmon 发现某个 G 在一个 M 上运行时间过长,它会向该 M 发送一个抢占信号(例如,在 Unix 系统上是 SIGURG)。
  3. 信号处理 :M 接收到信号后,会中断当前正在执行的 G。G 的上下文(主要是寄存器)会被保存,其状态会被标记为 _Gpreempted 或类似状态,然后被放回到运行队列(通常是 GRQ,以给其他 P 机会执行它,避免立即在同一个 P 上再次调度)。
  4. 重新调度 :M 随后会进入调度循环(在其 g0 栈上),选择下一个可运行的 G 来执行。

这种异步抢占机制确保了即使是那些没有主动让出 CPU 的计算密集型 goroutine 也能够被公平地调度,从而提高了整个系统的响应性和并发任务的并行度。它使得调度器更加健壮,不易受到不良编写的 goroutine 的影响。


func main 也是一个 goroutine

当一个 Go 程序启动时,main 包下的 main 函数并不是直接在某个原始线程上执行,而是由 Go runtime 创建的第一个用户级 goroutine,通常被称为 main goroutine

  • 初始化过程 :Go 程序的入口点实际上是 runtime 的一段引导代码。这段代码会负责初始化调度器、垃圾回收器、创建必要的 M 和 P,然后创建一个 G 来执行用户编写的 main.main() 函数。
  • 与其他 goroutine 平等 :这个 main goroutine 在行为上与用户通过 go 关键字创建的其他 goroutine 是平等的。它也拥有自己的栈,受 GPM 调度器的管理,可以被抢占,也可以创建新的 goroutine。
  • 程序生命周期main goroutine 的结束标志着整个程序的结束。当 main 函数返回时,Go runtime 会开始关闭程序。此时,所有其他仍在运行的 goroutine 都会被强制终止,除非程序使用了像 sync.WaitGroup 这样的机制来显式等待其他 goroutine 完成。
  • 退出码main 函数没有返回值。程序如果正常退出,通常退出码为 0。如果发生 panic 且未被 recover,或者调用了 os.Exit(code),则会以相应的状态退出。

理解 main 函数本身也是一个 goroutine 有助于更好地认识 Go 的并发模型的一致性:所有用户代码都运行在 goroutine 之上,由统一的 GPM 模型进行调度和管理。这体现了 Go 在语言层面和运行时层面将并发作为一等公民的设计哲学。


user_zsXbv7Bi
14 声望2 粉丝