Go调度器中P的本地队列不主动均衡,仅通过新G创建时的溢出迁移(满256后搬128个至全局队列)和空闲P窃取时的尾部批量搬运(每次偷约4个)实现被动负载平衡。

Go 调度器中 P 的本地队列不是靠“主动平衡”维持负载均衡的,它根本就没有中心化调度或定期 rebalance 机制;真正的平衡只发生在两个被动时刻:新 Goroutine 创建时的队列溢出迁移,以及空闲 P 主动窃取时的尾部批量搬运。
为什么 P 的本地队列不会自动“均分”Goroutine
本地队列(pp.runq)是无锁、单生产者单消费者(SPSC)结构,设计目标就是快和局部性,不是公平。它不感知其他 P 的负载,也不上报自身长度——没有监控、没有协调、没有后台线程去“匀一匀”。所谓“平衡”,只是工作窃取(work stealing)和溢出迁移(overflow push)这两个副作用带来的间接结果。
-
pp.runq长度上限是 256,超出后 runtime 会把其中约一半(128 个)挪到全局队列sched.runq,再把新 G 塞进去——这不是为了平衡,而是为了保本地队列可用 - 当某个 P 空闲(
findrunnable返回空),它才尝试从别的 P 尾部偷 4 个 G(实际数量受sched.nmspinning影响),偷不到就继续空转或进自旋态 - 偷操作只读尾部,被偷的 P 正在从头部消费,二者完全不冲突——这是性能关键,不是为了“平均分配”而设计的
高频创建 Goroutine 时本地队列溢出的真实开销
循环调用 go f() 启动大量 Goroutine,前 256 个直接入本地队列,第 257 个触发一次“搬 128 个 G 到全局队列”的操作,之后每新增一个都伴随一次原子锁+内存拷贝+链表操作——这不是线性增长,而是阶梯式抖动。
在 Go 中使用 google/wire 实现编译时依赖注入——wire.NewSet、wire.Build、wire.Bind(接口→实现)、wire.Struct、wire.Value、wire.Interface
- 全局队列
sched.runq是加锁的,每次溢出迁移都要竞争sched.lock,P 越多,争用越明显 - 被挪走的 G 在全局队列里等待被捞,而捞取概率只有 1/61(
pp.schedtick % 61 == 0),尤其在GOMAXPROCS=1场景下,它们可能卡住几十毫秒 - 观察方式:启用
GODEBUG=schedtrace=1000,看输出行中的gqueue字段是否持续 >100
work stealing 不是“负载均衡算法”,而是空闲自救机制
窃取动作不在后台运行,也不定时扫描;它只在当前 P 执行完一个 G、准备找下一个可运行 G 却发现本地队列和全局队列都为空时,才临时发起。它不评估“谁最闲”,只是随机挑一个 P,尝试从其尾部拿几个 G——失败就放弃,不重试、不记录、不报警。
立即学习“go语言免费学习笔记(深入)”;
- 偷不到的常见信号:
runtime.ReadMemStats显示NumGoroutine很高但 CPU 利用率低,schedtrace中频繁出现spinning状态 - 偷的单位不是单个 G,而是“批次”(默认 4 个),且只取尾部——既避免与目标 P 的执行路径竞争,也降低搬运频率
- 如果所有 P 都忙,但某些 P 的本地队列已满而另一些还有空位,调度器不会主动挪,除非那个空闲 P 恰好进入
findrunnable并成功偷到
真正影响调度效率的,从来不是“有没有平衡”,而是你是否无意中把大量 Goroutine 堆在单个 P 上(比如全在主线程里启动),以及是否让 P 长期无法释放(比如阻塞系统调用未解绑、或 runtime.LockOSThread() 错误绑定)。本地队列的“不平衡”本身不是 bug,它是为速度妥协的设计选择——你要做的不是对抗它,而是绕过它最脆弱的路径。

















