Archipelago

Lec 3GMP 调度器模型与架构设计

讲义完成更新于 2026-09-28

GMP 调度器模型与架构设计

TL;DR

  • Go 早期“无 P 调度器”受限于单全局队列锁争用、上下文频繁迁移导致局部性差,以及 mcache 绑定 M 造成阻塞系统调用下的内存暴涨。
  • 引入逻辑处理器 P(Processor)后,Goroutine 本地队列与 mcache 归属 P,实现了 M 与 P 在阻塞系统调用时的轻量解绑,且将自旋线程数量压制在活跃 P 的一半以下。
  • Goroutine 创建(newproc)巧妙复用了普通函数调用的栈帧内存,死亡的 G 结构体放入 gFree 列表实现零内存分配复用。
  • P 的本地队列采用 256 容量循环数组与单一高优先级 runnext 槽位结合,优先调度刚生成的子 Goroutine,大幅提升了亲缘任务的时间局部性。

调度器演进史:从单全局队列到 GMP

在多线程并发模型中,主流设计分为 1:1(内核级线程)、N:1(纯用户态协程)与 M:N(两级线程模型)。Go 采用 M:N 模型,在内核提供的少量系统线程上复用海量用户态 Goroutine。

在 Go 1.1 之前,调度器设计极为朴素:没有逻辑处理器 P 的概念,所有运行就绪的 Goroutine 均存放在一个全局唯一的运行队列中,所有物理线程 M 通过一把互斥锁竞争出队。

+---+   +---+   +---+   (OS Threads: M)
| M |   | M |   | M |
+---+   +---+   +---+
  \       |       /
   \      |      /   <- 激烈的锁争用 (Global Sched Lock)
+-----------------------+
|  Global Run Queue     |
+-----------------------+

Dmitry Vyukov 在著名的设计文档《Scalable Go Scheduler Design》中指出了早期架构的三大核心瓶颈:

  1. 全局调度锁瓶颈:所有 M 在创建、销毁或执行 Goroutine 时都必须加锁访问全局队列,高并发多核机器上锁争用严重恶化了扩展性。
  2. 严重的亲缘性丢失与缓存失效:子 Goroutine 往往需要与父 Goroutine 进行通信或共享热点数据。在全局队列中,子 G 极大概率被另一个完全不同的 M 抢走,导致 CPU L1/L2 缓存频繁失效,上下文切换开销巨大。
  3. 内存分配缓存浪费:Go 继承自 TCMalloc 的内存分配器为每个 M 绑定了约 2MB 的本地缓存 mcache。当 M 发起系统调用被内核阻塞时,其绑定的 mcache 无法被其他正在执行 Go 代码的线程使用;随着系统调用并发增加,大量内存被无谓浪费在阻塞线程上。

为此,Go 引入了逻辑处理器 P(Processor),将本地运行队列和 mcache 的所有权从物理线程 M 剥离,转移至数量固定的 P。当 M 陷入系统调用时,P 能够迅速脱离并转交给其他空闲 M,维持了 CPU 的最大化利用率。

+---+             +---+
| M |             | M |   (OS Threads)
+---+             +---+
  |                 |
+---+             +---+
| P |             | P |   (Processors: 持有 runq 与 mcache)
+---+             +---+
  |                 |
 [G, G, G]         [G, G, G]  (本地无锁队列)

G:Goroutine 核心设计与状态机

Goroutine 是 Go 运行时的基本调度执行单元。在设计上,Go 的一个 Goroutine 初始仅占用 2KB 栈内存,相比传统操作系统线程(通常 1MB–8MB)轻量几个数量级。

创建与销毁复用

用户态声明的 go f(x, y, z) 在底层被编译器转换为对 runtime.newproc 的调用。

我的理解

Go 语言之所以在语法层面支持极其轻巧的 go 语句,是因为其在底层巧妙地“复用”了常规函数调用。 普通函数调用时,调用方依次将参数压栈,然后发起 CALL;而对于 go f(1, 2, 3),编译器同样将参数压入当前栈,但随后不调用 f,而是将参数大小与函数指针传入 runtime.newproc。 newproc 将这段调用栈帧作为普通数据完整拷贝至新 Goroutine 的栈空间,然后将当前指令指针指向目标函数,避免了额外构建参数适配包装结构的复杂性。

当 Goroutine 执行完毕退出后,运行时并不会将其内存立即回收给操作系统,而是将其置为 _Gdead 状态,并放入当前 P 的本地空闲列表 pp.gFree。下次再创建新的 Goroutine 时,优先直接从空闲列表中复用已分配好的 g 结构体,大幅降低了堆分配频次。

生命周期与核心状态机

Goroutine 的整个生命周期围绕着运行时就绪队列、系统调用以及网络/同步等待展开。在物理层面上,状态的变更是通过原子 CAS 操作(atomic.Cas)直接修改 g.atomicstatus 字段完成的。这确保了在并发工作窃取或调度监控时,其他线程能够即时观察到 Goroutine 的最新生命周期状态。

典型的状态流转拓扑如下所示:

               +---------------+
               |    _Gidle     |
               +---------------+
                       | newproc
                       v
               +---------------+
     +-------->|   _Grunnable  |<--------+
     |         +---------------+         |
     |                 |                 |
     | yield / preempt | execute         | unpark / I/O ready
     |                 v                 |
     |         +---------------+         |
     +---------|   _Grunning   |         |
               +---------------+         |
                 /           \           |
      entersyscall            gopark     |
               /               \         |
              v                 v        |
      +---------------+   +---------------+
      |   _Gsyscall   |   |   _Gwaiting   |--+
      +---------------+   +---------------+
              | exitsyscall
              v
         _Grunnable

在状态转换中,最核心的分支是主动挂起与被动挂起:当执行 Channel 阻塞或显式锁等待时,Goroutine 调用 gopark 将状态从 _Grunning 改为 _Gwaiting 并让出 M;当等待的资源就绪(如数据到达或锁释放),由唤醒方调用 goready 将其重新修改为 _Grunnable 并推入运行队列。而在发起阻塞系统调用时,状态转入 _Gsyscall,此时不需要保留与 P 的独占绑定。

状态枚举状态说明
_Gidle刚被分配或重置,尚未初始化上下文
_Grunnable已挂入本地或全局队列,就绪等待分配物理线程执行
_Grunning正在被特定的 M 和 P 承载,物理 CPU 正在执行其代码
_Gsyscall正在执行阻塞式操作系统调用,未执行用户 Go 代码
_Gwaiting处于阻塞挂起状态(如等待 Channel、互斥锁、网络事件、Timer)
_Gdead执行结束进入空闲池,等待被后续 newproc 复用
_Gcopystack栈正在进行动态扩容或缩容重排,暂停执行
_Gpreempted收到抢占信号主动停机,等待恢复

g 结构体关键字段剖析

在 runtime/runtime2.go 中,g 结构体承载了执行现场与监控元数据:

  • sched (gobuf):上下文保存区,包括程序计数器 pc、栈指针 sp、当前 g 指针以及上下文对象 ctxt。Goroutine 切换时,仅需将少量物理寄存器暂存至此即可完成让出。
  • stack:记录当前虚拟栈的上下界(stack.lo 与 stack.hi)。
  • stackguard0:栈边界保护阈值,既用于检测栈扩容,也作为协作式抢占的注入点。
  • m (*m):当前绑定执行此 G 的物理线程指针。
  • waiting (*sudog):当 G 因 Channel 或信号量挂起时,指向该 Goroutine 对应的等待包装节点。

M:物理线程与系统栈 g0

m 结构体对应操作系统内核管理的轻量级进程或线程。Go 运行时代码本身(包括内存分配、垃圾回收、Goroutine 调度)也必须运行在线程上。

特殊的系统栈:g0

每个 M 内部都永久绑定了一个特殊的 Goroutine 实例——g0:

  • g0 运行在操作系统直接分配的固定大小系统栈上(通常为 64KB 到数 MB 不等),不支持也不需要动态栈扩容。
  • 绝大多数普通用户 Goroutine 的栈空间从 2KB 开始按需翻倍。当调度器需要执行调度循环 schedule()、内存分配 mallocgc()、或者栈扩容 newstack() 时,必须通过 runtime.systemstack() 切换到 g0 上执行,防止在用户栈尚未就绪或正处于临界区时引发难以恢复的栈破坏。

自旋线程限制

当 M 跑空本地队列且未找到工作时,它会进入 自旋状态(Spinning):主动在全局队列、网络轮询器以及其他 P 之间寻找待处理任务。

  • 为避免过多空转的线程挤占宿主机 CPU,Go 运行时硬性规定:处于自旋状态的 M 数量不得超过当前忙碌 P 总数的一半。
  • 彻底找不到可运行任务的 M 将被移入全局空闲 M 链表(sched.midle)并休眠挂起,不再占用任何 CPU 周期。

P:逻辑处理器与队列设计

P(Processor)在概念上代表执行 Go 代码所需的逻辑计算资源。P 的物理数量严格由环境变量 GOMAXPROCS 决定(默认为机器可用的 CPU 核心数)。

本地队列与 runnext

在传统的并发队列设计中,无锁多生产者多消费者队列往往伴随着高昂的 CAS 重试开销。Go 针对单线程作为主要消费者的场景,设计了极致轻量的本地任务队列体系:

+-------------------------------------------------------+
| Processor (P)                                         |
|                                                       |
|  [ runnext: G* ]  <- 单一高优先级槽位(优先直通执行) |
|                                                       |
|  [ runq: [256]guintptr ]  <- 256 元素无锁循环环形队列  |
|                                                       |
|  mcache *mcache   <- 私有内存分配缓存                 |
+-------------------------------------------------------+

P 内部管理着两层任务结构以化解锁竞争:

  1. 256 槽无锁环形队列 runq:队列基于原子头尾指针(runqhead 与 runqtail)管理。由于当前关联的物理线程 M 在该队列执行单生产者入队与单消费者出队,绝大部分访问完全免锁。当队列满载时,本地会将前一半的 Goroutine 打包倾倒进带锁的全局队列。
  2. 高优先级直通槽 runnext:新创建的子 Goroutine 优先放入 runnext 槽位,如果槽位原本已有 G,则将旧 G 推入 runq。这使得刚被 go 语句拉起的子任务能够立即在同一 CPU 缓存热区中获得优先调度,极好地满足了高并发请求/响应链路的时延要求。而在跨 P 窃取时,窃取方会首先偷取 runq,仅在连续失败三次后才尝试动用目标 P 的 runnext,以最大化保护亲缘调度效率。

P 的状态流转

状态枚举状态含义
_Pidle本地队列为空,未绑定任何物理线程 M
_Prunning正在被特定的物理线程 M 持有,正在执行用户代码或调度逻辑
_Psyscall关联的物理线程 M 正在执行内核系统调用
_Pgcstop在垃圾回收 STW(Stop-The-World)期间被全局暂停
_Pdead动态调小 GOMAXPROCS 时被裁撤停用的失效 P