Go GMP 模型复习笔记:理解偏差与修正

1. GMP 基础定义

原先理解

G、M、P 的基本概念能说出来:

  • G:goroutine,Go 用户态协程,用来承载并发任务。
  • M:machine,对应 OS thread,是真正执行代码的线程。
  • P:processor,调度器相关组件。

主要问题在于对 P 的理解不够准确,容易把 P 简化成“调度器”或者“和 CPU 核心绑定的东西”。

修正后理解

G 是 Go runtime 调度的执行单元。 M 是操作系统线程。 P 是 Go runtime 的逻辑处理器,持有执行 Go 代码所需的调度资源。

更准确的关系是:

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

P 不是 CPU 核心。CPU 核心由操作系统负责调度 OS thread 时分配;P 是 Go runtime 内部用来控制并行度和承载调度资源的逻辑概念。

面试表达

G 是 goroutine,是 Go runtime 调度的基本执行单元。M 是 OS thread,是真正运行代码的线程。P 是 Go runtime 的逻辑处理器,持有本地运行队列、调度状态和内存分配缓存。M 必须绑定 P 才能执行 Go 代码,P 的数量由 GOMAXPROCS 决定,限制 Go 代码的最大并行度。


2. P 不是 CPU 核心

理解偏差

容易说成:

P 和 CPU 核心绑定。

这个说法不严谨。

修正后理解

GOMAXPROCS=4 表示 Go runtime 中有 4 个 P。最多有 4 个 M 同时持有 P 并执行 Go 用户代码。

但 P 不等于 CPU 核心,也不固定运行在某个物理核心上。真正决定 OS thread 跑在哪个 CPU 核心上的是操作系统调度器。

正确理解

GOMAXPROCS 控制 P 的数量
P 控制 Go 代码的最大并行度
CPU 核心调度 M 是操作系统负责的

面试表达

P 不是 CPU 核心,而是 Go runtime 的逻辑处理器。GOMAXPROCS 决定 P 的数量,从而决定最多有多少个 M 可以同时执行 Go 用户代码。M 最终运行在哪个 CPU 核心上,由操作系统调度决定。


3. P 到底持有什么

理解偏差

原先对“P 持有调度资源和缓存”没有具体概念,只知道 M 执行 G 需要 P,但不清楚为什么。

修正后理解

P 可以理解为:

工位 + 工具箱 + 待办队列

其中:

  • G 是任务;
  • M 是工人;
  • P 是工人执行 Go 任务时必须拿到的工位和工具箱。

P 主要持有:

  1. 本地运行队列 local run queue;
  2. 调度相关状态;
  3. 内存分配缓存,例如小对象分配用到的本地缓存;
  4. GC 和 runtime 调度相关的一些状态。

M 如果没有 P,就不能正常执行普通 Go goroutine,因为执行 Go 代码所需的一些 runtime 资源在 P 上。

面试表达

P 是执行 Go 代码所需的 runtime 资源容器。它持有本地 G 队列、调度状态和内存分配缓存。M 只有绑定 P 后,才能执行普通 Go goroutine。


4. 为什么要引入 P

理解偏差

原先说法偏泛:

P 解决了 M 的调度问题。

这个表述不够准确。

修正后理解

P 的引入主要解决三个问题:

  1. 减少全局队列锁竞争;
  2. 将 G 队列、调度资源、内存分配缓存从 M 上解耦;
  3. 通过 GOMAXPROCS 控制 Go 代码并行度。

如果没有 P,多个 M 都去竞争一个全局 G 队列,高并发下调度会集中在全局锁上,扩展性较差。

引入 P 后,每个 P 有自己的本地队列,大多数调度操作都可以在本地完成。

关键点

P 和 M 解耦后,即使某个 M 阻塞,P 也可以被转移给其他 M,继续执行其他 G。

M 可以阻塞
P 不应该跟着 M 一起浪费

面试表达

P 的引入是为了把可运行 G 的队列、调度资源、内存分配缓存等从 M 上解耦,减少全局锁竞争,提高调度扩展性,并通过 GOMAXPROCS 控制 Go 代码的最大并行度。当 M 因 syscall 阻塞时,P 可以解绑并交给其他 M 继续执行其他 G。


5. 本地队列和全局队列

原先理解

本地队列用于存放待执行的 G。 全局队列用于存放本地队列放不下的 G。 如果只有全局队列,多个 M 高并发取 G 时会产生严重锁竞争。

这个理解基本正确。

补充修正

全局队列不只是“本地队列满后的溢出位置”,它也是调度公平性的补充。

runtime 会周期性检查全局队列,避免全局队列里的 G 长时间饥饿。

面试表达

P 的本地队列用于存放待运行的 G,可以减少全局锁竞争并提高调度局部性。全局队列作为溢出队列和公平性的补充存在。调度器通常优先使用本地队列,但也会定期检查全局队列,避免全局队列中的 G 饥饿。


6. Work Stealing

原先理解

当一个 M 把当前 P 本地队列中的 G 消耗完后,如果全局队列也没有 G,就会尝试从其他 P 的本地队列中偷取一部分 G。

这个方向正确。

修正后理解

严格来说,不能单独说“M 偷取”。M 必须持有 P 才能进行普通 Go 调度。

更准确是:

某个 P 当前没有可运行 G
持有该 P 的 M 在调度过程中尝试从其他 P 的本地队列偷取 G

通常会偷取一部分 G,常见说法是偷一半。

面试表达

Work stealing 是为了负载均衡。当某个 P 的本地队列为空,且全局队列、netpoll 等地方也没有可运行 G 时,调度器会尝试从其他 P 的本地队列偷取一部分 G 到当前 P,避免部分 P 空闲而部分 P 队列过长。


7. 阻塞式 syscall

原先理解

知道 G 执行阻塞 syscall 时,M 和 G 会阻塞,P 会尝试交给其他 M。

方向正确,但表述需要更精确。

修正后理解

当 G 执行阻塞式 syscall 时:

G 进入 syscall
M 陷入内核态阻塞
P 从 M 上解绑
P 交给其他 M 继续执行其他 G

P 必须解绑的原因是:M 阻塞在内核态时可能长时间无法执行 Go 代码。如果 P 继续绑在这个 M 上,那么 P 的本地队列、调度资源和并行额度都会被浪费。

syscall 返回后:

M 尝试重新获取 P
如果获取成功,继续执行原来的 G
如果获取失败,G 变为 runnable,等待之后被调度

面试表达

阻塞式 syscall 可能让 M 陷入内核阻塞。为了不浪费 P,runtime 会将 P 与该 M 解绑,交给其他 M 继续执行其他可运行的 G。syscall 返回后,原 M 会尝试重新获取 P;如果拿不到,G 会重新进入 runnable 状态等待调度。


8. Channel 阻塞

理解偏差

原先知道 G 会挂到 channel 的阻塞队列,但不清楚 P/M 的变化。

修正后理解

channel 阻塞只阻塞当前 G,不阻塞 M,也不阻塞 P。

例如:

x := <-ch

如果 channel 暂时没有数据:

G 被挂到 channel 的 recvq
G 状态变为 waiting
M 仍然持有 P
M 继续从 P 的本地队列中取其他 G 执行

关键区别

channel / mutex 等同步阻塞:
只阻塞 G,M 和 P 继续调度其他 G
 
阻塞式 syscall:
G 和 M 可能一起阻塞,P 需要解绑给其他 M

面试表达

channel 阻塞时,当前 G 会被挂到 channel 的等待队列中,状态变为 waiting。但 M 和 P 不会因此阻塞,M 会继续持有 P 并调度其他可运行的 G。它和阻塞式 syscall 的区别是,syscall 可能阻塞 M,而 channel 阻塞通常只挂起 G。


9. 网络 IO 和 netpoll

理解偏差

网络 IO 和普通 syscall 的区别原先不清楚,也不清楚 netpoll 的作用。

修正后理解

Go 的网络 IO 通常由 runtime netpoller 管理。

在 Linux 上,底层通常依赖 epoll;在 macOS/BSD 上类似 kqueue;Windows 上有 IOCP 机制。

网络 IO 的基本流程:

G 发起网络读写
如果 fd 暂时不可读或不可写
G 被挂到 netpoller
M/P 不会一起阻塞
M 继续执行其他 G
fd 就绪后,netpoller 把对应 G 重新变为 runnable

和普通阻塞 syscall 的区别

普通阻塞 syscall:
G + M 可能一起阻塞,需要解绑 P
 
Go 网络 IO:
G 挂到 netpoller,M/P 继续工作

这也是 Go 能支撑大量网络连接的重要原因之一。

面试表达

Go 的网络 IO 通常由 runtime netpoller 管理。网络 fd 会以非阻塞方式配合 epoll、kqueue、IOCP 等机制使用。当网络 IO 暂时不可读或不可写时,runtime 会挂起当前 G,而不是让 M 长时间阻塞。fd 就绪后,netpoller 再把对应 G 标记为 runnable,交给调度器执行。


10. 抢占机制

理解偏差

原先猜测为:

每个 G 占用 M 有一个时间上限,达到上限后就被放到队列末尾。

方向接近,但不准确。

修正后理解

Go runtime 会监控运行时间过长的 G,并发出抢占请求。

早期 Go 主要依赖协作式抢占。G 需要运行到函数调用、栈增长、安全点等位置,才会检查并响应抢占请求。因此,如果一个 G 执行没有函数调用的 tight loop,可能长时间无法被抢占。

现代 Go 引入异步抢占,可以通过信号机制在更多位置打断长时间运行的 G,缓解 tight loop 长时间霸占 P/M 的问题。

面试表达

Go 通过抢占机制避免某个 goroutine 长时间占用 P/M。早期 Go 主要依赖协作式抢占,goroutine 需要运行到函数调用、栈检查等安全点才会响应抢占请求,因此没有函数调用的死循环可能抢占困难。现代 Go 引入异步抢占,runtime 可以通过信号机制让长时间运行的 G 在更多位置被抢占。


11. runtime.Gosched()

理解偏差

只从名字知道它和调度有关,但不清楚具体状态变化。

修正后理解

runtime.Gosched() 的作用是让当前 goroutine 主动让出执行权,让调度器去执行其他 G。

但它不会让当前 G 进入阻塞状态。

状态变化更接近:

running -> runnable

不是:

running -> waiting

对比

channel 阻塞:
G -> waiting,等待 channel 唤醒
 
time.Sleep:
G -> waiting,等待计时器唤醒
 
runtime.Gosched:
G -> runnable,只是主动让出一次执行机会

面试表达

runtime.Gosched() 会让当前 goroutine 主动让出 CPU,让调度器执行其他 goroutine。但当前 G 本身仍然是 runnable,不是 blocked,之后仍然可以被调度回来继续执行。


12. GOMAXPROCS

原先理解

GOMAXPROCS=4 不表示最多只能创建 4 个 goroutine,而是表示最多有 4 个 P。

这个理解正确。

修正补充

GOMAXPROCS 限制的是:

同一时刻最多有多少个 M 持有 P 并执行 Go 用户代码

它不限制:

goroutine 总数
OS thread 总数

所以 GOMAXPROCS=4 时,进程里的 M 数量仍然可能超过 4。例如:

  • 某些 M 阻塞在 syscall;
  • runtime 创建额外 M;
  • cgo 场景;
  • 其他 runtime 内部需求。

性能问题

GOMAXPROCS 不是越大越好。

CPU 密集型任务中,一般接近 CPU 核心数较合理。继续调大可能导致:

  • OS thread 竞争 CPU;
  • 上下文切换增加;
  • CPU cache locality 变差;
  • 调度成本上升。

IO 密集型任务中,瓶颈可能在网络、磁盘、锁、数据库或下游服务,单纯调大 GOMAXPROCS 未必能提升性能。

面试表达

GOMAXPROCS 决定 P 的数量,也就是 Go 代码的最大并行度。它不限制 goroutine 的创建数量,也不严格限制 OS thread 总数。对于 CPU 密集型任务,通常接近 CPU 核心数较合理;过大会增加调度、上下文切换和缓存失效成本。对于 IO 密集型任务,瓶颈未必在 P 数量,需要结合 pprof、trace 和运行指标分析。


13. 调度查找顺序

原先理解

大致理解为:

本地队列 -> 全局队列 -> work stealing

这个方向可以,但不完整,缺少 netpoll,也忽略了全局队列的公平性检查。

修正后理解

不需要死背源码级顺序,面试中说清楚整体策略即可。

一个 M 持有 P 后,大致查找可运行 G 的思路是:

优先看当前 P 的本地队列
周期性检查全局队列,避免全局 G 饥饿
检查 netpoll 中是否有已就绪的网络 IO G
如果还没有任务,就从其他 P 的本地队列 work stealing
最终没有任务,M 进入休眠

面试表达

调度器优先利用当前 P 的本地队列,以保持局部性并减少锁竞争。同时会定期检查全局队列,避免全局 G 饥饿。如果有网络 IO 就绪的 G,会从 netpoll 获取。如果仍然没有可运行任务,就从其他 P 的本地队列中 work stealing。最终没有任务时,M 会进入休眠。


14. 易错点汇总

易错点 1:把 P 说成 CPU 核心

错误说法:

P 和 CPU 核心绑定。

正确说法:

P 是 Go runtime 的逻辑处理器,控制 Go 代码并行度。M 运行在哪个 CPU 核心上由操作系统决定。


易错点 2:把 GOMAXPROCS 说成限制 M 总数

错误说法:

GOMAXPROCS=4 表示最多只有 4 个 M。

正确说法:

GOMAXPROCS=4 表示最多有 4 个 P,也就是最多 4 个 M 同时持有 P 执行 Go 代码。进程内 M 的总数可能大于 4。


易错点 3:认为 channel 阻塞会阻塞 M

错误说法:

G 因 channel 阻塞时,M 也会阻塞。

正确说法:

channel 阻塞通常只挂起当前 G,M 和 P 继续执行其他 G。


易错点 4:混淆 syscall 和 network IO

错误说法:

网络 IO 和普通阻塞 syscall 一样,都会阻塞 M。

正确说法:

Go 网络 IO 通常由 netpoller 管理。G 会被挂起等待 fd 就绪,M/P 通常不会长时间阻塞。


易错点 5:认为抢占就是时间片到了直接放队尾

错误说法:

G 到时间就一定被直接放到队尾。

正确说法:

Go runtime 会对长时间运行的 G 发出抢占请求。早期主要依赖协作式抢占,需要安全点;现代 Go 支持异步抢占,可以在更多位置打断长时间运行的 G。


易错点 6:认为 Gosched() 会阻塞当前 G

错误说法:

Gosched() 会让当前 G 阻塞。

正确说法:

Gosched() 只是让当前 G 主动让出执行权,当前 G 仍然是 runnable,不是 waiting。


15. 最小复习版本

GMP 是什么

G 是 goroutine,是 Go runtime 调度的执行单元。M 是 OS thread,是真正运行代码的线程。P 是 Go runtime 的逻辑处理器,持有本地 G 队列、调度状态和内存分配缓存。M 必须绑定 P 才能执行 Go 代码。

P 的作用

P 用于承载调度资源,减少全局锁竞争,并通过 GOMAXPROCS 控制 Go 代码最大并行度。P 和 M 解耦后,M 阻塞时,P 可以转移给其他 M 继续执行其他 G。

队列与调度

本地队列减少锁竞争,全局队列用于溢出和公平性补充。当前 P 没有 G 时,调度器会尝试全局队列、netpoll、work stealing,最终没有任务则 M 休眠。

阻塞区别

channel/mutex 等同步阻塞通常只阻塞 G,M/P 继续工作。阻塞式 syscall 可能阻塞 M,所以 P 要解绑。网络 IO 通常走 netpoll,只挂起 G,不让 M/P 长时间阻塞。

抢占

早期 Go 主要依赖协作式抢占,tight loop 可能抢占困难。现代 Go 支持异步抢占,可以更好地避免 G 长时间霸占 P/M。

GOMAXPROCS

GOMAXPROCS 决定 P 的数量,限制 Go 代码的最大并行度,不限制 goroutine 数量,也不严格限制 OS thread 总数。它不是越大越好,需要结合 CPU 核心数和实际瓶颈分析。