work stealing 在当前 p 的本地队列、全局队列、netpoll 全空时,由 findrunnable() 按固定顺序检查失败后触发;只解决“真饿”,不处理“假忙”。

Go runtime 的 Work Stealing 不是后台线程自动扫描,而是每个 P 在本地队列、全局队列、netpoll 全空时,由 findRunnable() 主动触发的一次性协作行为;它只解决“真饿”(无 G 可取),不解决“假忙”(队列里全是长耗时 G)。
Work Stealing 在什么条件下被触发?
触发不是靠定时器或轮询,而是在调度循环中严格按顺序检查失败后才进入:
-
findRunnable()先查当前P的runnext(抢占预留位) - 再查本地运行队列
runq(最快路径,无锁) - 以约 1/61 概率查全局队列
sched.runq(防饿死,非每次必查) - 再调用
netpoll()唤醒因 I/O 就绪的 G - 三者全空,才启动 steal 流程——此时
P确实“饿了”
常见误判:看到某个 P 长期高负载,就以为该偷,其实只要它的 runq 非空(哪怕只剩 1 个 G),steal 就不会发生。
从哪个 P 偷?偷多少?为什么一定是尾部?
偷的目标和数量由原子状态和确定性伪随机策略决定,不是随意选:
- 最多尝试
stealTries = 4轮,每轮用互质偏移(offset += coprime)遍历所有P,保证公平访问 - 每次只偷目标
P本地队列长度的一半:n := (runqtail - runqhead) / 2(向下取整),且n ≤ 256 - 若目标
P当前只有 1 个 G,则1/2 == 0,本轮偷失败——这是“空转几轮才成功”的根本原因 - 偷从
runq.popTail(),本P消费走runq.popHead(),天然读写隔离,无需锁
改用头部偷需加锁或重原子操作,在 64+ 核机器上会显著抬高调度路径争用开销。
steal 失败时有哪些间接信号?
Go 不输出 “steal failed” 日志,但可通过以下现象定位问题:
-
GODEBUG=schedtrace=1000输出中,每行末尾的steal字段长期为0 -
pprof显示某些P的 goroutines 数量持续远高于其他P,但 CPU 利用率却上不去 -
go tool trace里大量 G 卡在runnable状态,对应M却在自旋或休眠 - 典型错误信息:
runtime: gp 0xdeadbeef has status Gwaiting but is on run queue,多源于 steal 过程中状态未及时同步(如 runtime 补丁不一致)
真正影响 steal 效率的常是:大量 goroutine 阻塞在系统调用(如 read、accept),导致 P 被抢占释放,新 G 全挤进全局队列——而 steal 只查本地队列,完全绕不开。
能调什么?不能碰什么?
用户可控的调节面极窄,但容易误操作的地方很关键:
- 唯一安全调节项是
GOMAXPROCS:它只改变P数量,不改变 steal 策略或阈值 - 绝对不要修改
runtime/proc.go中的trySteal、runqsteal或runqgrab——它们与gopark、goready的状态机深度耦合,改错一行就可能引发 goroutine 永久丢失 - 别指望靠
runtime.Gosched()或time.Sleep()“辅助” steal——steal 是调度器内建行为,用户代码无法驱动或加速它 - 若需更细粒度任务均衡(如批处理分片、递归并行),必须在用户态自己实现带
trySteal的 worker 队列,用atomic.LoadUint64管理头尾指针,而非依赖 runtime 层的 steal
最易被忽略的一点:steal 只搬运 G,不搬运正在执行的栈或寄存器上下文;它解决的是“谁来跑”,不是“怎么跑得更快”。纯计算循环(如 for {} 内无函数调用)仍可能逃逸抢占,此时即便 steal 成功,那个 G 也会独占 M 直到下一个安全点。











