gpm是go runtime中真实存在的三类结构体,g创建后必须绑定p才能被m执行,因p持有内存分配、栈管理等关键上下文;p数量由gomaxprocs固定,m通过抢占机制动态接管空闲p;本地队列满时g自动分流至全局队列或直接执行,系统调用时p被移交以保障调度连续性。

GPM不是抽象概念,而是 runtime 里真实分配、调度、状态流转的三类结构体。你写的 go func() 不是“启动一个协程”这种模糊说法,而是触发 runtime 创建一个 G 结构体,把它塞进某个 P 的本地队列或全局队列,并等待某个 M 拿走执行——整个过程不经过操作系统调度器,全在用户态完成。
为什么 G 必须绑定 P 才能被 M 执行
一个 M(系统线程)不能直接运行 G,它必须先获得一个 P。这是因为 P 不只是个队列容器,它还持有:mallocgc 内存分配缓存、timer 堆、netpoll 网络轮询器上下文、以及最关键的——G 运行所需的栈管理元信息和 defer 链表头指针。
如果 M 没有绑定 P,它连给新 G 分配初始栈(2KB)都做不到,更别说执行 defer 或触发 GC 扫描了。
-
P的数量默认等于 CPU 核心数,由GOMAXPROCS控制;它不是动态创建的,程序启动时就初始化好所有P实例 - 当
M因系统调用阻塞(比如read等待磁盘 IO),runtime 会把它的P抢走,交给另一个空闲M继续跑——所以P是调度资源,M是执行载体,二者可解耦 - 一个
P同一时刻只能被一个M占用;但一个M可能在不同时间绑定不同P
本地队列(LRQ)满时,G 不是“丢弃”而是“分流”
新建 G 优先入当前 P 的本地队列(runq),但该队列长度上限是 256。超过后不会 panic,也不会阻塞创建,而是触发一次“队列平衡”:
- 把本地队列后一半(约 128 个)
G搬到全局队列(global runq) - 若全局队列也快满(实际阈值是 64),则部分
G会被直接插入到其他P的本地队列尾部(通过runqputslow) - 极端情况下(所有队列都饱和),
G会被立即执行(execute),跳过排队——这是为了防止创建开销反成瓶颈
这个逻辑藏在 newproc1 函数里,不是靠文档记住的,而是看 src/runtime/proc.go 第 4000 行左右的注释和分支判断。
系统调用阻塞时,M 与 P 的分离不是“挂起”,而是主动移交
常见误解:M 调用 read 就“卡住”了。实际是:M 在进入系统调用前,会调用 entersyscall,把当前绑定的 P 置为 _Psyscall 状态并释放;同时唤醒一个空闲 M(或新建一个)去接管这个 P,继续从队列取 G 执行。
这意味着:哪怕你写了个死循环 for { time.Sleep(time.Second) },只要它不阻塞在系统调用上(比如纯 CPU 计算),就不会导致其他 G 饿死——因为 M 仍在运行,没交出 P。
- 真正的阻塞点是
sysmon监控线程检测到M在_Psyscall状态超时(默认 20ms),才会强制抢走P并唤醒新M - 网络 IO(如
net.Conn.Read)走的是netpoll机制,通常不触发_Psyscall,而是让G挂起、M去执行别的G——这才是 Go 并发高效的关键
真正难啃的点不在“GPM 是什么”,而在于:当你看到 goroutine 泄漏、CPU 利用率低但 G 数暴涨、或者 pprof 显示大量 GC assist 占用时,得能顺着 G 的 gstatus 状态(_Grunnable/_Gwaiting/_Gsyscall)、P 的 status(_Prunning/_Psyscall)、以及 M 是否在 findrunnable 循环里空转,一层层定位到具体哪段代码卡住了调度链路——这比背模型重要得多。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











