G必须绑定P才能执行,因为P持有本地队列、mcache、GC状态等执行所需上下文;M仅是执行载体,可动态解绑并重绑其他P。调度顺序:先LRQ→再GRQ(加锁批量取)→最后work stealing(从其他P尾部偷一半)。

GMP不是概念图,是运行时真实调度结构;答错P和M的绑定关系、队列优先级或阻塞处理,基本等于没答到点上。
为什么G必须绑定P才能执行,而M可以换P?
因为P持有G执行所需的全部上下文:本地运行队列、内存分配器缓存(mcache)、栈管理器、GC相关状态。G本身不保存这些,只存栈指针、指令地址、状态等轻量字段。G 要跑起来,必须挂在一个有资源的 P 上;M 只是执行载体,不保存G状态,所以一个 M 执行完G1后可以立刻解绑P、再绑定另一个P去执行G2。
常见错误是认为“M固定绑定某个P”。实际中:M 在系统调用阻塞时会主动释放 P,由其他空闲 M 接手;P 也会在本地队列为空时主动寻找新 M —— 它们是松耦合的动态配对关系。
本地队列、全局队列、work stealing 的触发顺序是什么?
调度器从不直接查全局队列,除非本地队列已空。正确顺序是:
立即学习“go语言免费学习笔记(深入)”;
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
- 先从当前
P的本地队列(LRQ)取G(无锁,最快) - LRQ为空 → 尝试从全局队列(GRQ)批量取(最多
len(GRQ)/GOMAXPROCS + 1个,加锁) - GRQ也空 → 启动 work stealing:随机选一个其他
P,从它本地队列尾部偷一半G(避免与该P的入队操作竞争)
注意:runtime.GOMAXPROCS 直接决定P数量,也间接影响GRQ取值上限;偷取是从尾部(LIFO),不是头部,这是为了保持局部性——刚被偷走的G很可能还热着。
网络I/O阻塞和文件I/O阻塞,调度器行为为什么完全不同?
根本区别在于是否进入内核态阻塞:
-
netpoller(epoll/kqueue)让G在等待网络数据时标记为_Gwaiting,并立即与M、P解绑,P马上调度下一个G —— 整个过程无系统调用、无线程切换 - 而
read/write文件、time.Sleep等会真正陷入内核态,M进入_Msyscall状态,此时必须释放P,否则P会被卡死;调度器会唤醒或新建一个M来继续使用该P
这也是为什么Go里混用阻塞式文件I/O容易拖垮并发吞吐:它强制触发M增减和P handoff,带来额外开销;而纯网络服务几乎不触发这些路径。
面试时画GMP关系图最容易漏掉的关键细节
多数人画出G→P→M的单向箭头就停了,但真实调度依赖三个隐含连接:
-
netpoller和所有P之间有反向唤醒通路(G就绪后能被放回任意P的LRQ) - 每个
M都持有一个mcache,但只有绑定P时才可用;P销毁前会把mcache归还给中心mcentral -
runtime.runqget函数内部有 fallback 逻辑:LRQ→GRQ→steal,这个三级回退链不能省略,否则无法解释高负载下G的延迟分布
真正难的不是记住GMP缩写,而是理解P作为“资源协调者”如何在无锁、低延迟、防饥饿之间做权衡——它既不是线程池,也不是任务队列,而是一个带缓存、带窃取、带唤醒回调的调度单元。

















