work-stealing 在p本地队列为空且全局队列、netpoll均无g可取时触发,按伪随机顺序尝试最多4轮窃取,每次只偷目标p队列长度一半(≤256个),从尾部窃取、头部消费,依赖原子操作实现无锁安全。

Work-Stealing 是怎么被触发的?
当某个 P 的本地运行队列(LRQ)为空,且全局队列、网络轮询器(netpoll)也都拿不到可运行的 G 时,调度器才会启动窃取流程。它不是“随时都在偷”,而是明确的“饿了才偷”——这是避免无谓开销的关键设计。
- 触发前会先查自己
P的runq(本地队列),再查globrunq(全局队列),再查netpoll,最后才去偷 - 最多尝试
stealTries = 4轮,每轮随机遍历所有P,但用互质偏移保证每个P都有机会被访问到 - 一旦成功窃取一个
G,就立刻返回执行,不会等凑够一批再动身
从哪个 P 偷?偷多少?
Go 不是随便挑个 P 就猛薅,而是按确定性伪随机策略选目标,并严格控制窃取量:每次只偷目标 P 本地队列长度的一半(向下取整),且最多偷 256 个(因为单个 LRQ 容量上限就是 256)。
- 偷的来源是目标
P的队列尾部(runqtail端),而该P自己消费是从头部(runqhead),天然避免读写竞争 - 源码中对应函数是
runqsteal→runqgrab,核心逻辑是n := (t - h) / 2,其中t和h是原子读取的尾/头指针 - 如果目标
P只有 1 个G,那就只偷 0 个(1/2 == 0),此时本轮窃取失败 —— 这也是为什么有时你会看到“空转几轮才偷到”的现象
为什么不用锁也能安全偷?
关键在于双端队列 + 原子操作 + 方向隔离。每个 P 的本地队列是 lock-free 的双端结构:自己只从 head 消费,别人只从 tail 窃取,中间靠 atomic.LoadAcq 和 atomic.Xadd 同步状态,完全避开互斥锁。
- 如果两个
P同时对同一个目标P发起窃取,runqgrab内部会用 CAS 更新runqhead,失败者自动跳过,不阻塞也不报错 - 全局队列
globrunq才需要锁(globalRunqLock),所以本地队列优先级永远高于全局队列 —— 这就是减少锁争用的设计意图 - 别指望通过
runtime.GOMAXPROCS调大就能“让偷得更勤”,它只影响P数量,不改变窃取阈值或策略
什么情况下 Work-Stealing 几乎不发生?
在典型 Web 服务或 I/O 密集型程序里,Work-Stealing 往往静默存在、极少被观测到。真正频繁触发的场景反而是 CPU 密集 + Goroutine 分布严重不均的批量计算任务。
立即学习“go语言免费学习笔记(深入)”;
- 大量 goroutine 在创建后立刻阻塞(如
time.Sleep、channel receive 等待),它们会被移到netpoll或等待队列,不进 LRQ,也就无法被偷 - 所有
P都有活干(哪怕只是短任务),本地队列永不为空,自然没机会触发窃取 - 用
go tool trace查ProcStatus时,若长期看不到Steal事件,不是机制失效,而是负载太均衡或太“懒”(大量阻塞)
真正容易被忽略的是:窃取只发生在“找 runnable G”这一步,它不参与 goroutine 创建、阻塞唤醒、GC 标记等任何其他阶段;你没法用 go:linkname 或 unsafe 去干预它,也不该试图绕过它——它是 runtime 的呼吸节奏,不是你能调的参数。










