Go的Work Stealing不是后台线程轮询,而是P在本地队列、全局队列、netpoll均空时触发的一次性协作行为;按固定顺序检查,饿了才偷,从其他P队列尾部窃取一半(向下取整)G,最多4轮随机尝试。

Go 的 Work Stealing 不是后台线程自动轮询,而是每个 P 在本地队列空、全局队列空、netpoll 也无就绪 G 时,才触发的一次性协作行为;它不解决长耗时任务堆积导致的“假忙真饿”问题,也不能绕过 goroutine 状态同步缺陷。
Work Stealing 触发时机:不是“随时偷”,而是“饿了才动身”
调度器只在 findrunnable 函数里检查可运行 G,顺序固定:先查当前 P 的本地队列(runq),再查全局队列(globrunq),再查 netpoller,三者全空,才进入 steal 流程。这意味着:
- 只要某个 P 的本地队列非空(哪怕只剩 1 个 G),就不会触发偷——哪怕其他 P 堆了 256 个 G
- 大量 goroutine 阻塞在系统调用(如
read、accept)时,它们被移出本地队列进等待队列,P 实际已空,但 steal 不会因此提前启动 -
GOMAXPROCS调大只增加 P 数量,不改变触发条件;若所有 P 都有短任务持续入队,steal 永远不会发生
偷谁?偷多少?为什么一定是尾部?
偷的目标是其他 P 的本地队列尾部,数量为 (runqtail - runqhead) / 2(向下取整),且最多 256 个。关键点在于:
- 遍历目标 P 用的是伪随机偏移(
offset += coprime),最多尝试stealTries = 4轮,每轮遍历全部 P,但不保证本轮一定偷到 - 若目标 P 只有 1 个 G,
1/2 == 0,本次偷失败——这是常见“空转几轮才成功”的根源 - 尾部偷取(
runq.popTail())和头部消费(runq.popHead())天然隔离,避免读写竞争;改用头部偷需加锁或重原子操作,实测在 64+ 核机器上显著抬高调度开销
为什么你观察不到 steal?常见误判信号
Go 不输出 “steal success” 日志,只能通过间接指标判断是否生效:
-
GODEBUG=schedtrace=1000输出中每行末尾的steal字段长期为0,说明所有 P 都未成功偷过——可能因负载极度不均,或 GC STW 干扰太强 -
pprof显示某些 P 的 goroutines 数量持续远高于其他 P,而 CPU 利用率却上不去 -
go tool trace里看到大量 G 处于runnable状态长时间卡住,但 M 却在自旋或休眠 - 典型错误现象:
runtime: gp 0xdeadbeef has status Gwaiting but is on run queue,多源于 steal 过程中 goroutine 状态未及时同步,常见于 runtime 补丁不一致
别碰 runtime 内部函数,但可以控制它的“饥饿感”
你不能也不该修改 trySteal 或 runqsteal——它们与 gopark、goready 的状态机深度耦合,错一行就可能导致 goroutine 永久丢失。真正可控的调节面只有两个:
-
GOMAXPROCS:影响 P 数量,从而改变 steal 拓扑密度;但设得过高(如 > 物理核数 2 倍)反而因 cache line false sharing 降低效率 -
GODEBUG=scheddelay=10ms:延长 P 自旋等待时间,让 steal 更“耐心”,但会略微抬高短任务延迟
最易被忽略的点是:steal 只搬运 G,不搬运正在执行的栈帧或寄存器上下文;如果一个 P 被长循环霸占(无安全点),它本地队列即使为空,M 也无法脱身去偷——此时 steal 完全失效,只能靠 runtime.Gosched() 或插入检查点来解套。


















