go语言中work-stealing仅从其他p的本地队列尾部窃取一半g(向下取整,≤256个),不涉及全局队列;全局队列globrunq仅被单个p按需加锁消费,定位为兜底通道而非窃取源。

Go 语言里没有“runtime 全局运行队列的任务窃取”这回事——全局队列(globrunq)本身不参与 work-stealing,它只被单个 P 按需消费,且全程加锁;真正发生窃取的,是其他 P 的本地队列(runq)尾部。
为什么 globrunq 不会被 steal?
全局队列是中心化、带锁结构(globalRunqLock),所有 P 在本地队列为空时才会去查它,但查完就走,不会“偷”——而是直接 pop 一个 G 执行。它的设计定位就是兜底通道,不是负载均衡载体:
- 每次只取 1 个 G(
globrunq.get()返回单个*g),不像本地队列窃取那样批量搬一半 - 取之前要先获取
globalRunqLock,高并发下易成瓶颈;而本地队列窃取是 lock-free 的 - 源码中
findrunnable的调用顺序严格固定:runqget→globrunq.get→netpoll→stealWork;一旦从全局队列拿到 G,就立刻返回,根本不进 steal 流程
stealWork 真正从哪偷、怎么偷?
work-stealing 只发生在 P 的本地运行队列(_p_.runq)之间,且有强约束:
- 触发条件必须同时满足:
runqget(_p_) == nil且globrunq.get() == nil且netpoll(0) == nil - 最多尝试
stealTries = 4轮,每轮伪随机遍历所有 P(靠互质偏移保证覆盖) - 每次只从目标 P 的
runqtail端偷:数量为(t - h) / 2(t/h 是原子读取的尾/头指针),上限 256,下限 0(比如只剩 1 个 G,1/2 == 0,本轮偷失败) - 偷失败不报错、不重试、不阻塞,直接跳下一 P 或结束本轮
常见误判现象和排查线索
你以为 load 不均是因为没偷到?大概率想错了。这些信号才真值得看:
-
GODEBUG=schedtrace=1000输出中,某 P 的steal字段长期为 0:说明它几乎没空闲过,或者所有其他 P 的runq都是空的(任务全卡在 netpoll 或 channel wait) - pprof 显示大量 goroutine 处于
Gwaiting但堆栈停在select或chan receive:它们根本不在任何 runq 里,steal 自然无效 - 提升
GOMAXPROCS后吞吐不升反降:可能因更多 P 竞争全局队列或 netpoll,反而放大锁争用,和 steal 无关 - 错误日志如
runtime: gp 0xdeadbeef has status Gwaiting but is on run queue:多见于 runtime 补丁不一致,不是 steal 逻辑问题
你能实际调节的只有两个点
别碰 runtime/proc.go 里的 trySteal 或 runqsteal——它们和状态机深度耦合,改错一行就可能丢 goroutine。真正可控的只有:
-
GOMAXPROCS:影响 P 数量,从而改变 steal 的“拓扑密度”。P 太少(如 =1),steal 永远不会触发;P 太多(如 > 物理核数 × 2),steal 尝试频次上升但成功率下降,反而抬高调度开销 -
GODEBUG=scheddelay=10ms:延长 P 自旋等待时间,间接增加 steal 触发机会(但慎用,会拖慢唤醒延迟)
真正的负载均衡得靠上层:用带缓冲的 chan Task + 动态 worker 数 + context 控制生命周期,而不是指望 runtime 自动“偷”出平衡。











