Work Stealing 发生在 findrunnable 函数中,仅当当前 P 的本地队列、全局队列和 netpoll 均为空时才触发;它通过 popTail 避免锁争用,且 GOMAXPROCS 决定是否可能触发 steal。

Go 的 runtime 调度器确实实现了 Work Stealing,但它不是“自动均衡负载”的万能开关——它只在明确饥饿时触发,且对用户态任务完全不感知。你看到的 goroutine 分布不均、CPU 利用率低、trace 里大量 runnable 却无 M 执行,大概率不是调度器坏了,而是它根本没被触发,或根本管不到你的逻辑。
Work Stealing 在哪个函数里真正发生?
它发生在 findrunnable 函数内部,且仅当以下三者全为空时才进入 steal 分支:
- 当前 P 的本地队列(
runq) - 全局队列(
globrunq) - 网络轮询器(
netpoll)返回的就绪 G
注意:findrunnable 是每个 M 在找活干时必走的路径,不是后台线程;它不查 channel、不查 timer、不查 sysmon 状态,只查这三处。一旦其中任一有 G,steal 就跳过。所以“P1 堆了 256 个 long-running goroutine,P2 空着却不去偷”是完全符合设计的——因为 P2 的 findrunnable 一查自己 runq 就发现有 G(哪怕刚入队),直接执行,根本不会走到 steal 那步。
为什么偷尾部(runq.popTail())而不是头部?
这不是风格选择,是避免锁和伪共享的硬性工程约束:
- P 自身消费用
runq.popHead(),从队首取;steal 方用runq.popTail(),从队尾取——生产者(本 P 入队)、消费者(本 P 出队)、窃取者(他 P 偷)三者操作区域天然隔离 - 头尾指针通过
atomic.LoadAcq和atomic.Xadd同步,无需互斥锁;若改偷头部,就得在 popHead 路径加 CAS 重试或锁,实测在 64+ 核机器上会使sched路径争用上升 30%+ - 目标 P 只有 1 个 G 时,
(tail - head) / 2 == 0,本次 steal 直接失败——这是常见“空转几轮才偷到”的根源,不是 bug,是设计
如何判断 Work Stealing 是否在起作用?
别信 pprof 或 top,看 GODEBUG=schedtrace=1000 输出末尾的 steal 字段:
- 每行代表一个 P 的某次
findrunnable调用,末尾形如steal:1表示本轮成功偷到至少 1 个 G - 若长期看到
steal:0,说明所有 P 都没触发 steal——可能因 GC STW 太长导致全局队列积压、或所有 P 都有短任务持续入队(哪怕只是time.Sleep(1)) - 典型误判信号:
runtime: gp 0xdeadbeef has status Gwaiting but is on run queue,这往往不是 steal 本身出错,而是 runtime 补丁版本不一致导致状态同步漏掉,尤其在自定义gopark行为后
你能安全调节的唯一入口是 GOMAXPROCS
GOMAXPROCS 只控制 P 数量,不改变 steal 触发逻辑、不调高窃取频率、也不影响偷多少。但它决定了“饥饿面”的宽度:
- 设
GOMAXPROCS=1:只有一个 P,steal 永远不会发生(没别人可偷) - 设
GOMAXPROCS=8但所有 goroutine 都由同一个 P 创建(比如全在 main goroutine 里启动),它们仍会优先塞进该 P 的本地队列;其他 7 个 P 若没主动参与创建或唤醒,就只能等 steal —— 而 steal 又要求它们先饿着 - 真正影响 steal 效率的,反而是你代码里大量阻塞系统调用(如
read,accept):此时 P 被抢占释放,新 goroutine 全挤进全局队列,而 steal 只查本地队列,对全局队列无动于衷
别碰 runtime/proc.go 里的 trySteal 或 runqsteal——它们和 gopark/goready 状态机深度耦合,改错一行就可能让 goroutine 永久丢失或 double-run。你唯一可控的,是让 goroutine 更早、更均匀地落到不同 P 上,比如用 runtime.LockOSThread + 显式 go 分散启动,或在 IO 密集场景下启用 GOEXPERIMENT=asyncpreemptoff 减少抢占干扰。


















