Work-Stealing是Go调度器在findrunnable中触发的协作行为,仅当P本地队列、全局队列及netpoll均无G时才启动,按优先级顺序查找,最多4轮伪随机窃取,每次从目标P队列尾部偷一半G。
work stealing 不是独立线程或后台任务,而是 go 调度器在 findrunnable 函数中触发的协作行为——仅当当前 p 的本地运行队列(runq)为空、全局队列(globrunq)也无可用 g、且 netpoll 未就绪时,才启动。
它本质是“饿了才偷”,不是轮询,也不依赖定时器。你不会看到日志提示“正在偷”,但能通过 trace 观察到间接信号:比如某 P 突然从空闲态跳转为运行态,且左侧标有 steal 事件。
work stealing 触发时机与流程
调度器按严格优先级顺序查找可运行 G:
- 先查当前
P的本地队列(_p_.runqhead/_p_.runqtail) - 再查全局队列(需加锁
globalRunqLock) - 再查
netpoll(网络 I/O 就绪的G) - 最后才进入
steal流程:最多尝试stealTries = 4轮,每轮伪随机遍历所有P,用互质偏移保证公平访问
一旦某轮成功窃取至少一个 G,立即返回执行,不等凑够一批。
为什么从尾部偷、且只偷一半?
这是避免竞争和保障性能的关键设计:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
立即学习“go语言免费学习笔记(深入)”;
- 本地
P消费G是从头部(runq.popHead()),而偷是从尾部(runq.popTail()),天然读写隔离,无需锁 - 偷的数量是
(t - h) >> 1(即队列长度右移一位,向下取整),不是四舍五入;若目标P只有 1 个G,则偷 0 个,本轮失败 - 尾部
G多为批量入队的“冷数据”,延迟几微秒被拿走,对响应敏感型任务影响极小;头部则是刚唤醒/新建的热数据,必须留给本P - 若改偷头部,就得引入 CAS 或更重原子操作,实测在 64+ 核机器上显著抬高
sched路径争用
常见误判和排查线索
work stealing 静默失效时,往往没有报错,但现象明确:
-
runtime: gp 0xdeadbeef has status Gwaiting but is on run queue:多见于 runtime 补丁不一致,状态同步断裂 - 大量
G积压在全局队列(trace 中globrunq长期 > 0)、P频繁进入自旋态(spinning)、GOMAXPROCS提升后吞吐不增反降 -
GODEBUG=schedtrace=1000输出末尾steal字段持续为 0:说明所有P都没成功偷过,可能是负载极度不均,或 GC STW 干扰太强 - 真正频繁触发
work stealing的场景,反而是 CPU 密集 +G分布严重不均的批量计算;Web 服务中它常处于静默状态
别碰 runtime/proc.go 里的 trySteal 和 runqsteal
这两个函数和 gopark、goready 的状态机深度耦合。改错一行可能引发 G 永久丢失或 double-run —— 这类问题极难复现,调试成本极高。
真正可控的调节面只有两个:GOMAXPROCS(影响 P 数量,间接改变偷的候选池大小)和应用层任务分发逻辑(比如批处理分片时主动打散初始 G 到不同 P)。如果你观察到某些 P 的 runqueue 长期远高于其他 P,问题大概率不在调度器,而在 goroutine 创建时的亲和性——比如所有 go f() 都发生在同一个 goroutine 内,它们默认全进同一个 P 的本地队列。

















