Go 调度器 GMP:把这三个字母的关系讲清楚

学 Go 的过程中,「GMP 模型」大概是出现频率最高、也最容易被背成口诀的一个知识点。

「G 是 goroutine,M 是内核线程,P 是处理器」——这句话本身没错,但它解释不了任何问题。面试里只要追问一句「那 P 为什么必须存在」,背口诀的人就答不上来了。

这篇笔记是我把这条线索从头捋一遍的结果,尽量只讲能自洽推导出来的部分,不堆术语。

先看没有 P 的世界

Go 最早的调度器(1.0 时期)只有 G 和 M,结构非常直白:

  • 一个全局运行队列,所有待执行的 goroutine 都排队放在里面
  • 每个 M(内核线程)要跑 goroutine 时,先加锁,从全局队列里取一个,解锁,然后执行

单核时代这没问题。但多核机器上,所有 M 都去抢同一把锁,队列本身就成了瓶颈。CPU 核数越多,花在锁竞争上的时间占比越高——这就是所谓的扩展性问题:加核不加性能。

所以 P 出现的动机很具体:把一把大锁拆成若干个不需要加锁的本地队列。

G / M / P 各自持有什么

G(goroutine)

包含栈(初始很小,按需增长)、指令指针、以及调度相关的状态字段。它保存的是「一个执行流走到哪儿了」的全部信息。

关键在于:G 是用户态的。创建一个 G 不需要进内核,切换一个 G 也不需要进内核。这才是「轻量」的真正来源——不是因为栈小,而是因为调度不依赖操作系统。

M(machine)

M 就是一个内核线程,是真正被操作系统调度到 CPU 上执行的实体。它没有太多自己的状态,主要职责是执行 G 的代码,并在需要的时候进入内核(比如系统调用、网络轮询)。

P(processor)

P 是逻辑处理器,数量由 GOMAXPROCS 决定,默认等于 CPU 核数。它持有:

  • 一个本地运行队列(LRQ),存放待执行的 G,容量 256
  • 内存分配用的缓存(mcache)——这一点常被忽略,但很重要
  • 一些运行时的状态字段

一条必须记住的规则

M 必须绑定一个 P 才能执行 G。

这个约束是理解一切调度行为的前提。它意味着:如果 P 的数量是 8,那么同一时刻最多只有 8 个 G 在真正并行执行。你起十万个 goroutine 也没用,并行度不会超过 GOMAXPROCS。

它也解释了另一个现象:当一个 G 陷入阻塞的系统调用时,它占着的 M 会被阻塞,但 P 不该跟着一起等。否则一个慢系统调用就能浪费掉一个逻辑处理器的全部算力。

于是运行时做了一个动作:把 P 从阻塞的 M 上摘下来,交给另一个空闲的(或新建的)M。等原来的系统调用返回,那个 M 会尝试重新获取一个 P;拿不到就把 G 放回队列,自己休眠。

这就是「系统调用不会拖垮整个程序」的实现方式。

调度循环与 work stealing

一个 P 上的调度循环大致是这样:

  1. 从本地队列取 G。这是最常见路径,不加锁
  2. 本地队列空了,去全局队列取一批
  3. 全局队列也空了,去网络轮询器(netpoller)看看有没有就绪的 G
  4. 还空着,就去偷——随机挑一个其他 P,从它的本地队列尾部偷走一半

第 4 步就是 work stealing。注意两个细节:

  • 偷的是一半,不是全部。全偷走会让被偷的 P 立刻又变成空闲,来回震荡
  • 从尾部偷。因为 P 自己从头部取、往尾部放,从尾部偷能最大限度减少和原主人的竞争

另外,本地队列满了(超过 256)时,会把前一半挪到全局队列,给新来的 G 腾位置。

抢占是怎么做到的

如果某个 G 写了个死循环,它会不会永久占住 P?

早期(1.14 之前)Go 用的是协作式抢占:只在函数调用的地方检查抢占标志。所以一个没有函数调用的 for {} 确实能让调度器失效。

1.14 之后改成了基于信号的异步抢占:运行时会向目标 M 发送信号,M 上的 G 被强制中断,保存现场,让出 P。这解决的正是「没有安全点可以检查」的场景。

小结

把这条线串起来看:

  • G 是执行流,用户态、轻量
  • M 是内核线程,真正被 CPU 执行
  • P 是执行 G 所必需的「许可证 + 本地队列 + 分配缓存」,数量等于并行度上限
  • work stealing 让空闲的 P 有事做,避免负载不均
  • handoff 让阻塞的 M 不拖住 P

理解了这个结构,GOMAXPROCS 该怎么调、goroutine 泄漏怎么排查、为什么 CPU 密集型任务起更多 goroutine 也没用——这些问题就都有落点了。


参考:runtime/proc.go 里的 schedule()、findRunnable()、runqsteal() 三个函数,配合注释读一遍,比任何二手资料都准确。