go 的 work stealing 是 gmp 调度器维持高并发吞吐的刚性机制,发生在 findrunnable() 中:先查 runnext,再取本地队列,全空才调用 runqsteal() 尝试最多 4 轮随机窃取其他 p 队列尾部一半 g,失败后 fallback 到带锁的全局队列。

Go 的工作窃取(Work Stealing)不是“可选优化”,而是 GMP 调度器维持高并发吞吐的刚性机制;没有它,P 空闲时 M 就只能挂起或阻塞,百万 goroutine 会立刻退化成线程池模型。
runtime.findrunnable() 是工作窃取的总入口
所有 M 进入调度循环后,最终都会调用 findrunnable() 寻找下一个可执行的 G。它不是简单地查本地队列,而是一套有严格优先级和兜底策略的查找链:
- 先检查
runnext字段(下一个优先执行的 G),这是被 handoff 或抢占后“插队”留下的热 G - 再从 P 的本地运行队列
runq取 G(无锁、O(1)、最高效) - 若本地队列为空,才触发工作窃取:调用
runqsteal()尝试从其他 P 偷一半 G,失败则 fallback 到全局队列global runq - 最后检查网络轮询器(
netpoll)是否有就绪的 channel 操作 G
这个顺序决定了:本地优先保证低延迟,窃取兜底避免 CPU 空转,全局队列仅作后备——否则锁竞争和 GC 扫描压力会陡增。
runqsteal() 怎么偷?只偷一半,且带随机性
runqsteal() 不是遍历所有 P 去抢,而是按固定规则采样:
- 从当前 P 的 id 开始,向后偏移一个随机步长(
int32(fastrand()) % uint32(gomaxprocs)),避免多个 M 同时盯上同一个 P - 最多尝试偷 4 次(硬编码常量
stealTries = 4),每次选一个目标 P - 成功偷到后,只拿走对方本地队列长度的一半(向下取整),保留另一半给原 P 后续使用
- 偷的过程通过原子操作更新对方 P 的
runqhead/runqtail,不加锁但要求内存序严格(atomic.LoadAcq/StoreRel)
这种设计防止了“偷光导致对方后续饥饿”,也规避了全局锁——但代价是:偷不到时 M 仍需 fallback 到全局队列,那里有 sched.lock 保护,仍是潜在瓶颈点。
Go语言(Golang)1.26.0版本提供 Go 官方 Windows amd64 MSI 安装包下载入口,版本号 1.26.0,可用于旧项目维护、兼容性测试和指定版本开发环境配置。
为什么 steal 失败后还要查全局队列?
全局队列(allg 队列 + sched.runq)本质是“最后防线”,但它带锁、慢、且易成热点:
- 新创建的 G 若本地队列满(256 个),会被
globrunqput()放进全局队列 - 系统调用返回的 G、GC 栈扫描释放的 G、以及所有 steal 失败的 M 都会来这儿争抢
-
sched.lock是全局互斥锁,高并发下globrunqget()成为性能拐点——这也是为什么 Go 1.14+ 引入基于信号的抢占,减少长时间运行 G 占着 M 不放,间接缓解全局队列压力
换句话说:你看到的“G 创建很快”,背后是 runtime 在拼命避免让它落到全局队列;一旦落到那儿,就等于进了慢车道。
实际调试时怎么确认发生了 work stealing?
Go 提供了运行时 trace 工具,但关键要看两个指标:
- 启用
GODEBUG=schedtrace=1000,观察输出中steal字段是否频繁非零(如steal:12表示该秒内发生 12 次窃取) - 用
go tool trace打开 trace 文件,在 “Goroutines” 视图里筛选状态为Runnable却长期没被Scheduler分配的 G——它们大概率正卡在全局队列或等待被偷 - 注意:
runtime.GC()期间会暂停所有 P 的本地队列调度,此时 steal 会失效,所有新 G 都涌向全局队列,容易触发尖峰
真正难调的不是 steal 本身,而是当 steal 频繁发生却仍伴随大量 G 饿死时——那通常说明 P 数量(GOMAXPROCS)与实际负载不匹配,或者存在长阻塞系统调用未被 netpoll 捕获。










