Work Stealing 在 findrunnable 函数中触发,仅当当前 P 的本地队列、全局队列和 netpoll 全为空时才进入 steal 分支,按固定顺序检查失败后最多尝试 4 轮伪随机窃取,每次从目标 P 队列尾部偷取一半(向下取整)G。

work stealing 触发时机在 findrunnable 函数里
Go 调度器不会“一直偷”,而是在每个 M 进入调度循环、准备找下一个可运行 G 时,才调用 findrunnable。这个函数有严格检查顺序:先查当前 P 的本地队列(runq),再查全局队列(globrunq),再查 netpoll 就绪的 G;三者全空,才会进入 steal 流程。
这意味着:
- 只要本地队列还有 1 个 G,哪怕其他 P 堆了 200 个 G,也不会触发 steal
- 系统调用阻塞的 G 已被移出本地队列,P 实际已空,但 steal 不会因此提前启动
-
GOMAXPROCS调大只增加 P 数量,不改变触发条件——如果所有 P 都有短任务持续入队,steal 永远不会发生
stealWork 函数只从其他 P 的 runq 尾部取一半 G
stealWork 是实际执行窃取的函数,它不遍历全部 P,而是最多尝试 4 轮伪随机偏移(offset += coprime),每轮按固定顺序扫描所有 P,但只对第一个非空且非自身的 P 动手。
关键细节:
- 偷取数量 =
(runqtail - runqhead) / 2(向下取整),所以目标 P 只有 1 个 G 时,偷 0 个,本轮失败 - 总是从尾部(
runq.popTail())偷,和本地队列头部消费(runq.popHead())天然隔离,避免锁或原子操作开销 - 偷到的 G 直接放入当前 P 的
runq,不经过全局队列,减少竞争
runtime.schedule() 中的 steal 是协作式,不是抢占式
runtime.schedule() 是 M 的主调度循环入口,它每次取 G 前都会调用 findrunnable,而 steal 是其中一环。它本质是“协作”:P 自己饿了才去偷,不是后台线程轮询,也不主动唤醒其他 P。
这带来几个现实影响:
- 长耗时 G(比如死循环)霸占 P,会导致该 P 永远不饿,steal 不触发,其他 P 可能闲置
- GC STW 期间所有 P 暂停,steal 被打断,STW 结束后需重新“饥饿检测”
- 你无法通过修改
stealTries或coprime来“加速偷取”——这些值硬编码在 runtime,且与gopark/goready状态机强耦合,改错一行可能让 G 永久丢失
观察 steal 是否生效只能靠间接指标
Go 不输出 “steal success” 日志,runtime 内部也不暴露 steal 计数器。你得靠这些信号判断:
-
GODEBUG=schedtrace=1000输出中每行末尾的steal字段长期为 0,说明所有 P 都未成功偷过 -
go tool trace里看到大量 G 处于runnable状态卡住,同时 M 却在自旋或休眠 - pprof 显示某些 P 的 goroutines 数量持续远高于其他 P,但对应 CPU 利用率却上不去
- 出现
runtime: gp 0xdeadbeef has status Gwaiting but is on run queue,多源于 steal 过程中 goroutine 状态未及时同步(常见于 runtime 补丁不一致)
真正难调试的,从来不是 steal 有没有发生,而是它为什么没发生——往往卡在某个 P 的本地队列“假非空”(比如一个 G 正在系统调用中,还没被移走),或者 GC 干扰太强导致饥饿检测失效。


















