gmp模型是go runtime实际调度机制,g必须绑定p才能执行,因p提供本地队列、内存缓存等关键资源;m仅是os线程封装,无p则无法调度g;gomaxprocs控制p数量,m数量弹性伸缩;work stealing在p本地队列和全局队列为空时触发,从其他p尾部偷一半g;hand off应对阻塞系统调用,netpoller优化网络i/o;抢占式调度通过函数调用边界检查实现,非强制中断。

Go 的 GMP 模型不是“理论模型”,而是 runtime 实际调度 goroutine 的机制——它直接决定你写的 go func() 会不会卡住、能不能压满多核、为什么 time.Sleep 和 conn.Read 阻塞行为完全不同。
为什么 G 必须绑定 P 才能执行,而 M 可以闲置?
P 是调度的“上下文容器”:它持有本地运行队列(LRQ)、内存分配器缓存(mcache)、栈缓存(stackcache)等关键资源。G 没有 P 就没法分配栈、没法找可运行任务、甚至没法做垃圾回收标记。M 则只是操作系统线程的封装,它本身不存状态;只有绑上 P,才能从 P 的 LRQ 取 G 执行。
-
GOMAXPROCS设置的是 P 的数量,不是 M 的数量;P 数量固定后,runtime 不会动态增减 P - M 的数量是弹性的:阻塞系统调用时会新建 M,空闲超时(约 10 分钟)会销毁 M
- 一个 M 空闲 ≠ 一个 P 空闲:P 被占用时,即使 M 在休眠,其他 G 也无法使用该 P 的资源
work stealing 发生在什么时机?偷的是谁的 G?
当某个 P 的本地队列为空,且全局队列(GRQ)也取不到新 G 时,该 P 会启动 work stealing:随机选一个其他 P,从其本地队列**尾部**拿走一半 G(向下取整)。
在 Golang 中使用 samber/hot 进行内存缓存,支持 LRU、LFU、TinyLFU、W‑TinyLFU、S3FIFO、ARC、TwoQueue、SIEVE、FIFO 等淘汰算法,提供 TTL、缓存加载器及分片功能。
- 偷尾部是为了避免和原 P 的入队/出队竞争(原 P 从头部取 G,偷尾部是无锁的)
- 只偷一半,防止把对方“掏空”,导致后续负载不均
- 偷不到或对方队列太短(≤1)时,P 会退回到全局队列再尝试一次,之后才进入休眠
- 注意:netpoller 唤醒的 G 默认放入被唤醒 G 原属 P 的本地队列,不走 GRQ,所以不会触发 stealing
hand off 和 netpoller 唤醒的区别在哪?
hand off 是为**阻塞系统调用**设计的保活机制;netpoller 唤醒是为**网络 I/O** 设计的零阻塞路径。两者都解耦 G 与 M,但触发条件、恢复路径、资源开销完全不同。
- hand off 场景:
open、read(文件)、time.Sleep等真正陷入内核态的调用 → M 挂起,P 被 hand off 给另一个空闲 M → 系统调用返回后,G 被放回 GRQ 或某个 P 的 LRQ - netpoller 场景:
net.Conn.Read、http.Serve等基于 epoll/kqueue 的 I/O → G 标记为_Gwaiting,挂到 netpoller 等待队列 → 数据就绪后,由 sysmon 线程或 poller 直接将 G 放回原 P 或其他 P 的 LRQ,不经过 GRQ - 关键区别:hand off 必然伴随 M 的切换和锁操作;netpoller 唤醒几乎无锁、无队列跳转,延迟更低
抢占式调度真的能“打断”正在运行的 G 吗?
能,但不是靠信号中断指令流,而是靠函数调用入口的“协作点”检查。runtime 在每个函数序言(prologue)插入检查逻辑,若发现当前 G 已超时(默认 10ms),就主动让出 P。
- 抢占只发生在函数调用边界,不会打断循环体内部或 CPU 密集型计算(比如
for { i++ }不调用任何函数就无法被抢占) - GC 扫描阶段会强制所有 G 进入安全点(safepoint),这也是抢占的一种形式
- 可通过
runtime.Gosched()主动让出,但生产代码中应避免显式调用,优先靠合理拆分任务和 channel 控制节奏
真正容易被忽略的,是 P 的本地队列长度对调度延迟的影响:LRQ 太长(比如 >256),会导致新创建的 G 要等很久才能轮到;太短(比如频繁偷取失败),又会抬高锁竞争和跨 P 唤醒开销。这不是靠调大 GOMAXPROCS 就能解决的问题。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










