G 不绑定 M,M 也不固定属于 P;但运行中的 G 必须在 M 上执行,每个 M 必须持有且仅持有 1 个 P 才能执行用户代码——这是调度成立的最小契约。runtime.LockOSThread() 并未改变该契约,仅使当前 M 进入独占模式:不参与调度、不执行其他 G,P 仍归属它但无法被复用;GOMAXPROCS 控制 P 数量而非线程数,P 是调度稳定锚点,M 可释放、G 可挂起,唯 P 永不销毁。

G 不绑定 M,M 也不固定属于 P;但运行中的 G 必须在 M 上执行,每个 M 必须持有且仅持有 1 个 P 才能执行用户代码——这是调度成立的最小契约,不是设计偏好,而是 runtime 的硬性约束。
为什么 runtime.LockOSThread() 看起来像“绑死”G 和 M?
它不改变 GMP 模型本质,只是让当前 curg 所在的 M 进入独占模式:该 M 不再参与调度循环、不被抢占、不执行其他 G,lockedg 字段被设为当前 G。P 仍归属它,但其他 G 无法再分配到这个 M。
- 典型场景是调用需要线程局部状态的 C 库(如
OpenGL、alsa),或依赖pthread_setspecific语义 - 副作用明显:该 M+P 组合退出调度平衡,若 G 长时间阻塞(比如等 stdin 输入),整个 P 就卡住,其他可运行 G 可能饿死
- 必须配对调用
runtime.UnlockOSThread(),否则m被标记为 locked 后无法复用,最终触发runtime: program exceeds maximum number of OS threads错误
runtime.GOMAXPROCS 控制的是 P 的数量,不是 M 或 G 的上限
P 是逻辑处理器,数量默认等于 CPU 核心数,由 runtime.GOMAXPROCS 设置。它不控制线程(M)或协程(G)总数,只决定并行执行的“调度单元”个数。
- 设置过小(如
1):所有 G 都挤在单个 P 的本地队列(runq,容量 256)里,工作窃取失效,多核闲置 - 设置过大(如远超物理核数):P 数量增加,但空闲 P 无法自动销毁,反而增加调度器元数据开销;M 不会因此变多,除非系统调用触发新 M 创建
- 修改时机很重要:在
main函数开头调用才有效;若在 goroutine 中调用,可能被忽略或引发 panic(Go 1.23+ 对并发修改更敏感)
G 阻塞时,P 怎么不丢?M 又怎么处理?
关键在“M 可以丢,P 不能丢”。G 因 channel receive、net.Read、time.Sleep 等进入阻塞时,会被挂入对应等待队列(如 sudog 链表),然后 M 执行 schedule() —— 若 M 当前无其他可运行 G,它会主动释放持有的 P,并休眠等待新任务。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
立即学习“go语言免费学习笔记(深入)”;
- P 不会被销毁,而是回到全局空闲队列(
allp数组中未被占用 slot),随时可被其他唤醒的 M 获取 - 系统调用阻塞(如
read())走另一路径:M 脱离 P 单独阻塞,运行时尝试用空闲 M 接管该 P,避免 P 空转 - 非阻塞 I/O(如
epoll_wait)由netpoller统一管理,G 直接挂起,M 立即回归调度循环,不浪费 OS 线程
工作窃取(Work Stealing)不是“随机偷”,而是有策略的轮询
当某个 P 的本地队列为空,M 进入 stealWork() 时,并非遍历所有 P,而是最多尝试 4 次随机选取(random() % len(allp)),每次偷取目标 P 一半的 G(runqlen / 2),且只偷非空队列。
- 偷取失败(目标 P 队列为空或已被其他 M 正在窃取)就跳过,不重试也不锁全局
- 偷来的 G 放入当前 P 的
runnext(高优先级槽)或runq尾部,保证尽快执行 - 如果连续多次窃取失败,M 会转向检查网络就绪 G、全局队列,最后才进入休眠;这层判断顺序直接影响高负载下调度延迟
真正容易被忽略的点在于:P 的生命周期独立于 M 和 G,它才是调度稳定性的锚点;而 M 的复用与释放、G 的创建与挂起,全部围绕 P 的可用性展开——不是“谁管理谁”,而是“谁服从谁”。

















