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 主要持有:
- 本地运行队列 local run queue;
- 调度相关状态;
- 内存分配缓存,例如小对象分配用到的本地缓存;
- GC 和 runtime 调度相关的一些状态。
M 如果没有 P,就不能正常执行普通 Go goroutine,因为执行 Go 代码所需的一些 runtime 资源在 P 上。
面试表达
P 是执行 Go 代码所需的 runtime 资源容器。它持有本地 G 队列、调度状态和内存分配缓存。M 只有绑定 P 后,才能执行普通 Go goroutine。
4. 为什么要引入 P
理解偏差
原先说法偏泛:
P 解决了 M 的调度问题。
这个表述不够准确。
修正后理解
P 的引入主要解决三个问题:
- 减少全局队列锁竞争;
- 将 G 队列、调度资源、内存分配缓存从 M 上解耦;
- 通过
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 继续执行其他 GP 必须解绑的原因是: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 核心数和实际瓶颈分析。