Lec 3GMP 调度器模型与架构设计
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》中指出了早期架构的三大核心瓶颈:
- 全局调度锁瓶颈:所有 M 在创建、销毁或执行 Goroutine 时都必须加锁访问全局队列,高并发多核机器上锁争用严重恶化了扩展性。
- 严重的亲缘性丢失与缓存失效:子 Goroutine 往往需要与父 Goroutine 进行通信或共享热点数据。在全局队列中,子 G 极大概率被另一个完全不同的 M 抢走,导致 CPU L1/L2 缓存频繁失效,上下文切换开销巨大。
- 内存分配缓存浪费: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 内部管理着两层任务结构以化解锁竞争:
- 256 槽无锁环形队列
runq:队列基于原子头尾指针(runqhead与runqtail)管理。由于当前关联的物理线程 M 在该队列执行单生产者入队与单消费者出队,绝大部分访问完全免锁。当队列满载时,本地会将前一半的 Goroutine 打包倾倒进带锁的全局队列。 - 高优先级直通槽
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 |