Work Stealing 在 findrunnable 函数中触发,仅当本地队列、全局队列和 netpoll 均为空时才进入窃取分支,执行 runqsteal 尝试最多 4 轮随机窃取。

Go 的 Work Stealing 不是独立模块,而是嵌在 findrunnable 函数里的协作逻辑;你改不了它,但必须理解它何时不工作、为何不工作、以及它根本不想为你解决哪类问题。
Work Stealing 在哪个函数里触发?
它在 findrunnable 里被调用,且仅当以下三者全为空时才进入窃取分支:
-
runqget(_p_)返回 nil(本地队列空) -
globrunqget()返回 nil(全局队列空) -
netpoll(false)返回空链表(无就绪网络 G)
注意:findrunnable 是每个 M 在调度循环中主动调用的,不是后台线程轮询。一旦满足上述条件,才会执行 runqsteal —— 这个函数本身不保证成功,只尝试最多 4 轮随机窃取,每轮遍历所有 P,用互质偏移跳过已试过的 P。
为什么从尾部偷、且只偷一半?
偷尾部(runq.popTail())是为了和目标 P 自身消费方向(runq.popHead())隔离,避免原子操作冲突。偷一半(n := (t - h) >> 1)是硬编码策略,源码中对应 runqgrab 里的位移计算:
- 若目标 P 队列有 7 个 G,
t-h == 7,右移得 3,实际偷 3 个 - 若只有 1 个 G,
1>>1 == 0,本轮偷不到,直接跳过 - 即使队列有 512 个 G(超限),也只偷 256 个,因为 LRQ 最大容量就是 256
这个“一半”不是性能调优参数,而是为防止原 P 立即变空——偷太多会导致刚被偷的 P 下次进 findrunnable 又得立刻偷回来,徒增开销。
常见误判:pprof 显示某 P goroutine 数远高 ≠ Work Stealing 失效
pprof 的 goroutines profile 统计的是所有 G 的当前状态(包括 Gwaiting、Gsyscall),不是运行队列长度。真正反映 Work Stealing 是否起作用的指标是:
-
go tool trace中 Proc 行出现带 “steal” 标签的绿色运行块 -
GODEBUG=schedtrace=1000输出末尾的steal字段是否非零 - 全局队列长度长期 > 0,但各 P 本地队列却常空 → 说明新 G 全被塞进全局队列(如大量
time.Sleep或 channel receive),而 Work Stealing 根本不查全局队列
典型陷阱:把阻塞型 G(如 http.Get 后等待响应)当成“可偷任务”,其实它们早被移到 netpoll 等待队列,不在 LRQ 里,自然无法被偷。
别碰 runtime/proc.go 里的 trySteal 和 runqsteal
这两个函数和 gopark、goready、park_m 的状态机强耦合。例如 runqsteal 内部依赖 _p_.runqhead 和 _p_.runqtail 的原子读写顺序,改错一行可能让 G 永久卡在 Grunnable 状态却不入任何队列。
真正可控的调节面只有两个:
-
GOMAXPROCS:影响 P 数量,从而改变窃取拓扑密度(P 越多,单次窃取失败概率越高) -
GODEBUG=scheddelay=10ms:延长自旋时间,间接增加窃取尝试窗口(但会抬高延迟)
如果你的任务分布不均,不要指望调参数让 runtime 偷得更勤——该自己做用户态 work-stealing 的地方,runtime 从来就没打算管。


















