GMP 协程调度模型

前面几篇讲了怎么用 go 开协程、用 channel / 锁协作。
这篇往下挖一层:这些协程到底是谁在跑、怎么排到 CPU 上的——也就是 Go 运行时的 GMP 调度模型。

入门先记住四件事:

  1. G:Goroutine,一个待执行的任务。
  2. M:Machine,操作系统内核线程,真正干活的人。
  3. P:Processor,逻辑处理器,手里攥着一批 G,并绑定到某个 M 上跑。
  4. Go 尽量在用户态切换 G,所以比直接开大量 OS 线程轻得多。

口诀:G 是任务,M 是工人,P 是工位。工人必须坐到工位上,才能领任务干活。

GMP 三者关系

为什么要有 GMP:直接用 OS 线程太贵

如果并发全靠操作系统原生线程:

  • 一个线程栈通常大约 1MB,开多了内存先顶不住。
  • 线程切换要进内核态,上下文切换开销大。
  • 想支撑十几万、上百万并发,用「一任务一线程」基本不现实。

Go 的做法是自己管一批轻量协程(G),再映射到少量 OS 线程(M)上跑:

  • 切换多在用户态完成,不用每次都进内核。
  • G 的初始栈大约只有 2KB,还带着栈扩缩容:用多少占多少,大任务再长大,用完可再缩。
  • 所以同样硬件上,轻松扛住十几万甚至百万级并发,这就是 GMP 要解决的痛点。

G、M、P 分别是什么

缩写全称可以怎么理解
GGoroutine协程 / 任务:go func() 出来的就是一个 G
MMachineOS 内核线程:真正执行机器码的人
PProcessor逻辑处理器:管理一批 G,并决定「下一个跑谁」

几个关键关系:

  • M 必须绑定 P,才能执行 G。没有 P,M 就算醒着也拿不到本地任务队列。
  • P 的数量默认等于 CPU 逻辑核数(由 GOMAXPROCS 控制)。核有几个,默认就准备几个「工位」,尽量把多核吃满。
  • G 可以远多于 M 和 P:任务很多没关系,轮流上工位执行就行。

可以脑补成奶茶店:

  • G = 一杯待做的订单
  • P = 一个操作台(台上最多排队一批订单)
  • M = 一个店员
    店员必须站到某个操作台前,才能按台上的订单做茶。

旧模型的问题:没有 P,大家抢一把全局锁

更早的调度(可以记成 GM 模型)大致是:

  • 只有一个全局队列装着所有待跑的 G
  • 多个 M 直接去这个全局队列里领任务

多核 CPU 上一忙起来就会难受:

  • 多个 M 同时抢同一条全局队列
  • 队列本身要加锁保护
  • 抢锁、放锁变成常态,锁竞争很重,调度本身反而拖慢业务

所以后来改进成现在的 GMP:加上 P 这一层,把「抢全局」改成「大部分时候各忙各的」。

旧 GM 与新 GMP 对比

改进后的 GMP:每个 P 有自己的本地队列

现在的主流程可以记成:

  1. 每个 M 绑定一个 P
  2. 每个 P 管一个本地队列(容量常见是 256 个 G)
  3. 新 G 优先放进当前 P 的本地队列
  4. 本地队列满了,再往全局队列放(或把一部分挪到全局)

好处很直观:

  • 多数时候,M 只从自己绑的 P 的本地队列取 G
  • 本地取任务基本不用和别的核抢同一把大锁
  • 多核可以真正并行调度,性能上去了

不是「完全没有锁」,而是把热点从「所有人抢一个全局队列」拆开了:本地为主,全局为辅。

本地空了、全局也空了:Work Stealing(工作窃取)

那如果某个 P:

  • 本地队列已经跑完了
  • 全局队列也空了

这个核就要闲着吗?不会。运行时会触发 Work Stealing(工作窃取):

  • 当前 P 去看别的 P 的本地队列
  • 若别人还有积压,就偷走大约一半任务到自己这边
  • 目的很简单:别让 CPU 空转,有活大家一起干

可以继续用奶茶店类比:自己台子上的单做完了,别的台子还堆着,就过去搬一半回来做——总比干站着强。

Work Stealing 工作窃取

M 卡住了怎么办:Hand-off(交接)

还有一种常见情况:某个 M 正在跑 G,结果这个 G 阻塞了(比如陷入会卡住线程的系统调用)。

这时如果还死抱着 P,那个「工位」就浪费了:核还在,却派不出新活。

所以会触发 Hand-off(交接):

  1. 当前阻塞的 M 和 P 立刻解绑
  2. P 马上去找空闲的 M 接手
  3. 若没有空闲 M,就再创建一个 M
  4. 新的(或空闲的)M 绑上这个 P,继续跑队列里剩下的 G

这样多核不会因为「某个线程卡住」而整段闲置。等原来那个阻塞结束,M 还可以再找机会绑回某个 P 继续干活。

补充一句入门口径:网络 I/O 很多会被运行时的 netpoller 做成「看起来阻塞、其实不白白占死线程」;这里说的 Hand-off,重点记「线程级阻塞时,P 会交出去」即可。

Hand-off 交接

串起来看一眼完整链路

一次比较典型的调度路径:

  1. go xxx → 产生一个 G
  2. G 进当前 P 的本地队列(满了再进全局队列)
  3. M 绑定 P,从本地队列取 G 执行
  4. 本地空了 → 先看全局队列;还没有 → Work Stealing 偷别人一半
  5. 执行中线程阻塞 → Hand-off:P 换人(换 M)继续跑

GMP 完整调度链路

所以你写业务时只看到 go,底下其实是:

很多轻量 G → 少量 P(约等于核数)→ 再映射到若干 M(OS 线程)

这也是 Go 能「代码写起来像随便开协程,跑起来又相对省」的原因。

和前面几篇怎么对上

你在业务里写的在 GMP 里大致对应
go func() { ... }()创建一个 G
同时跑很多协程也不慌G 很轻,初始栈小,用户态切换
GOMAXPROCS大致控制 P 的个数
协程里做很重的阻塞调用可能让 M 卡住,触发 Hand-off
channel / 锁让出执行权当前 G 可以停下,P/M 去跑别的 G

入门阶段不必把源码细节背下来。先抓住这句就够:

G 是任务,P 是工位,M 是工人;本地队列减少抢锁,偷工避免空闲,交接避免阻塞拖死多核。