GMP 协程调度模型
前面几篇讲了怎么用 go 开协程、用 channel / 锁协作。
这篇往下挖一层:这些协程到底是谁在跑、怎么排到 CPU 上的——也就是 Go 运行时的 GMP 调度模型。
入门先记住四件事:
- G:Goroutine,一个待执行的任务。
- M:Machine,操作系统内核线程,真正干活的人。
- P:Processor,逻辑处理器,手里攥着一批 G,并绑定到某个 M 上跑。
- Go 尽量在用户态切换 G,所以比直接开大量 OS 线程轻得多。
口诀:G 是任务,M 是工人,P 是工位。工人必须坐到工位上,才能领任务干活。
为什么要有 GMP:直接用 OS 线程太贵
如果并发全靠操作系统原生线程:
- 一个线程栈通常大约 1MB,开多了内存先顶不住。
- 线程切换要进内核态,上下文切换开销大。
- 想支撑十几万、上百万并发,用「一任务一线程」基本不现实。
Go 的做法是自己管一批轻量协程(G),再映射到少量 OS 线程(M)上跑:
- 切换多在用户态完成,不用每次都进内核。
- G 的初始栈大约只有 2KB,还带着栈扩缩容:用多少占多少,大任务再长大,用完可再缩。
- 所以同样硬件上,轻松扛住十几万甚至百万级并发,这就是 GMP 要解决的痛点。
G、M、P 分别是什么
| 缩写 | 全称 | 可以怎么理解 |
|---|---|---|
| G | Goroutine | 协程 / 任务:go func() 出来的就是一个 G |
| M | Machine | OS 内核线程:真正执行机器码的人 |
| P | Processor | 逻辑处理器:管理一批 G,并决定「下一个跑谁」 |
几个关键关系:
- M 必须绑定 P,才能执行 G。没有 P,M 就算醒着也拿不到本地任务队列。
- P 的数量默认等于 CPU 逻辑核数(由
GOMAXPROCS控制)。核有几个,默认就准备几个「工位」,尽量把多核吃满。 - G 可以远多于 M 和 P:任务很多没关系,轮流上工位执行就行。
可以脑补成奶茶店:
- G = 一杯待做的订单
- P = 一个操作台(台上最多排队一批订单)
- M = 一个店员
店员必须站到某个操作台前,才能按台上的订单做茶。
旧模型的问题:没有 P,大家抢一把全局锁
更早的调度(可以记成 GM 模型)大致是:
- 只有一个全局队列装着所有待跑的 G
- 多个 M 直接去这个全局队列里领任务
多核 CPU 上一忙起来就会难受:
- 多个 M 同时抢同一条全局队列
- 队列本身要加锁保护
- 抢锁、放锁变成常态,锁竞争很重,调度本身反而拖慢业务
所以后来改进成现在的 GMP:加上 P 这一层,把「抢全局」改成「大部分时候各忙各的」。
改进后的 GMP:每个 P 有自己的本地队列
现在的主流程可以记成:
- 每个 M 绑定一个 P
- 每个 P 管一个本地队列(容量常见是 256 个 G)
- 新 G 优先放进当前 P 的本地队列
- 本地队列满了,再往全局队列放(或把一部分挪到全局)
好处很直观:
- 多数时候,M 只从自己绑的 P 的本地队列取 G
- 本地取任务基本不用和别的核抢同一把大锁
- 多核可以真正并行调度,性能上去了
不是「完全没有锁」,而是把热点从「所有人抢一个全局队列」拆开了:本地为主,全局为辅。
本地空了、全局也空了:Work Stealing(工作窃取)
那如果某个 P:
- 本地队列已经跑完了
- 全局队列也空了
这个核就要闲着吗?不会。运行时会触发 Work Stealing(工作窃取):
- 当前 P 去看别的 P 的本地队列
- 若别人还有积压,就偷走大约一半任务到自己这边
- 目的很简单:别让 CPU 空转,有活大家一起干
可以继续用奶茶店类比:自己台子上的单做完了,别的台子还堆着,就过去搬一半回来做——总比干站着强。
M 卡住了怎么办:Hand-off(交接)
还有一种常见情况:某个 M 正在跑 G,结果这个 G 阻塞了(比如陷入会卡住线程的系统调用)。
这时如果还死抱着 P,那个「工位」就浪费了:核还在,却派不出新活。
所以会触发 Hand-off(交接):
- 当前阻塞的 M 和 P 立刻解绑
- P 马上去找空闲的 M 接手
- 若没有空闲 M,就再创建一个 M
- 新的(或空闲的)M 绑上这个 P,继续跑队列里剩下的 G
这样多核不会因为「某个线程卡住」而整段闲置。等原来那个阻塞结束,M 还可以再找机会绑回某个 P 继续干活。
补充一句入门口径:网络 I/O 很多会被运行时的 netpoller 做成「看起来阻塞、其实不白白占死线程」;这里说的 Hand-off,重点记「线程级阻塞时,P 会交出去」即可。
串起来看一眼完整链路
一次比较典型的调度路径:
go xxx→ 产生一个 G- G 进当前 P 的本地队列(满了再进全局队列)
- M 绑定 P,从本地队列取 G 执行
- 本地空了 → 先看全局队列;还没有 → Work Stealing 偷别人一半
- 执行中线程阻塞 → Hand-off:P 换人(换 M)继续跑
所以你写业务时只看到 go,底下其实是:
很多轻量 G → 少量 P(约等于核数)→ 再映射到若干 M(OS 线程)
这也是 Go 能「代码写起来像随便开协程,跑起来又相对省」的原因。
和前面几篇怎么对上
| 你在业务里写的 | 在 GMP 里大致对应 |
|---|---|
go func() { ... }() | 创建一个 G |
| 同时跑很多协程也不慌 | G 很轻,初始栈小,用户态切换 |
GOMAXPROCS | 大致控制 P 的个数 |
| 协程里做很重的阻塞调用 | 可能让 M 卡住,触发 Hand-off |
| channel / 锁让出执行权 | 当前 G 可以停下,P/M 去跑别的 G |
入门阶段不必把源码细节背下来。先抓住这句就够:
G 是任务,P 是工位,M 是工人;本地队列减少抢锁,偷工避免空闲,交接避免阻塞拖死多核。