go work stealing 是确定性伪随机行为:仅当 findrunnable 中本地队列、全局队列、netpoll 均为空时触发,按固定顺序检查,四轮伪随机遍历其他 p,每轮从目标队列尾部窃取一半(向下取整)g,失败即 park。

Go 运行时的 Work Stealing 不是“随机乱偷”,而是严格受控的确定性伪随机行为——它只在 findrunnable 被调用且本地队列、全局队列、网络轮询器均无任务时才触发,且每轮最多尝试 4 次,失败即 park,不会重试或忙等。
窃取何时发生?看 findrunnable 的执行顺序
调度器不是一空就偷,而是按固定优先级逐层检查:
- 先调用
runqget(_p_)从自己 P 的本地队列头部取 G(LIFO 局部性) - 再查
globrunqget(_p_, 0),需加globalRunqLock锁,仅作兜底 - 接着调
netpoll(0)非阻塞轮询网络就绪 G - 最后才进入四轮
runqsteal循环:每轮用fastrand()+ 互质偏移遍历所有 P,但只对当前 P 以外的目标尝试窃取
关键点:findrunnable 是阻塞函数,一旦四轮全失败,当前 M 就会进入 park_m 睡眠,直到被唤醒(如新 G 入队、网络事件、定时器触发)。它不“轮询等待”,也不“退避重试”。
runqsteal 怎么选目标 P?不是真随机
源码中用的是 stealOrder.start(fastrand()),本质是构造一个长度为 gomaxprocs 的伪随机排列,保证每轮遍历中每个其他 P 至少被访问一次,且访问顺序不随轮次重复。例如有 8 个 P,stealOrder 会生成类似 [3,6,1,7,2,5,0,4] 的序列,而非 rand.Intn(8) 可能反复命中同一 P。
常见误解:fastrand() 不是用于“挑一个 P”,而是生成遍历起点的偏移量;真正保证覆盖的关键是 stealOrder.next() 内部的模加递推逻辑(基于与 gomaxprocs 互质的步长)。
偷多少?怎么偷?runqgrab 的原子截断逻辑
核心公式在 runqgrab 中:n := (t - h) / 2,其中 t = atomic.LoadAcq(&_p_.runqtail)、h = atomic.LoadAcq(&_p_.runqhead),结果向下取整(Go 用右移 (t-h)>>1 实现)。
- 若目标 P 队列只有 1 个 G(
t-h == 1),则n == 0,本轮窃取直接跳过 - 若队列有 7 个 G,则偷 3 个;有 256 个则偷 128 个(上限受 LRQ 容量约束,实际不可能超)
- 偷的来源是尾部:循环中用
_p_.runq[(h+i)%len]复制,但 commit 阶段靠atomic.CasRel(&_p_.runqhead, h, h+n)原子推进头指针——失败则说明该 P 在此期间已自行消费了部分 G,当前偷操作自动放弃
这个设计意味着:偷操作不锁队列、不阻塞原 P 消费、不依赖全局状态同步,纯粹靠双端隔离(P 自己从 head 消费,别人从 tail 侧“读取+原子截断 head”)实现 lock-free。
为什么你用 pprof 看不到 steal 事件?
runtime/pprof 默认不采集调度事件;必须用 go tool trace 才能观察到 steal 标记的调度块。典型误判场景:
- 看到某 P 的
goroutines数长期为 0,就以为“没偷到”——其实可能是四轮全失败后直接 park,trace 里会显示一段灰色“Sleeping”区间 - 看到
runqueue显示[256 0] 12,误以为“12 个 G 在全局队列等着被偷”——但全局队列需锁,调度器优先偷本地队列,除非所有 P 都满载且全局队列非空,否则不会主动去拿 - 在低并发短任务场景下反复调
runtime.Gosched(),期望触发 steal——但 Gosched 只是让出时间片,不重置本地队列状态,若当前 P 仍有 G 可跑,下次仍从本地取
真正影响 steal 成功率的,是活跃 goroutine 的分布密度:单个 P 上堆积大量长耗时 goroutine(如密集计算 loop),会导致其他 P 在四轮尝试中反复遇到“目标 P 队列只剩 1 个 G”的情况,从而偷不到——这不是算法缺陷,而是负载本身失衡的信号。











