work stealing发生在findrunnable函数阶段,即当前p本地队列和全局队列均为空时,主动从其他p尾部窃取半个批次goroutine以实现负载均衡。

Go调度器的work stealing发生在哪个阶段
work stealing 不是独立运行的后台线程,而是 findrunnable 函数在找不到本地可运行 goroutine 时主动触发的协作行为。它只在 P(processor)空闲或本地队列耗尽时发生,不是周期性扫描,也不依赖定时器。
- 触发时机:当前 P 的本地运行队列(
runq)为空,且全局队列也暂时没活儿时,才会尝试从其他 P “偷” - 偷的不是 goroutine 本身,而是其他 P 本地队列尾部的半个批次(默认 4 个,由
int32(atomic.Load(&sched.nmspinning))等状态共同控制) - 偷的过程带自旋保护:若目标 P 正忙(比如正在执行、正在被抢占),这次 steal 就直接放弃,不阻塞、不重试
为什么偷尾部而不是头部
偷尾部(runq.popTail())是为了避免和目标 P 自身的出队操作(runq.popHead())竞争。P 执行 goroutine 是从头部取,而偷是从尾部取,天然形成生产者-消费者隔离,几乎不需要锁。
- 头部是热区:goroutine 刚被唤醒、刚被 newproc 创建,大概率立刻执行,必须留给本 P
- 尾部是冷区:往往是批量入队时堆积的“后备军”,延迟几微秒被偷走,对延迟敏感型任务影响极小
- 如果偷头部,就得加锁或用更重的原子操作,实测会抬高
sched路径的争用开销,尤其在 64+ 核机器上明显
steal 失败的常见现象和排查线索
你不会看到 “steal failed” 日志,但能观察到间接信号:大量 goroutine 积压在全局队列、P 频繁进入自旋态(spinning)、GOMAXPROCS 提升后吞吐不增反降。
- 典型错误现象:
runtime: gp 0xdeadbeef has status Gwaiting but is on run queue—— 这往往源于 steal 过程中 goroutine 状态未及时同步,多见于 patch 版本不一致或非标准 runtime 补丁 - 容易被忽略的配置点:
GODEBUG=schedtrace=1000输出里,看每行末尾的steal字段是否长期为 0;若持续为 0,说明所有 P 都没成功偷过,可能是负载极度不均或 GC STW 干扰太强 - steal 效率低的真凶常是:大量 goroutine 阻塞在系统调用(如
read、accept)导致 P 被抢占释放,新 goroutine 全挤进全局队列,而 steal 只查本地队列
修改 steal 行为的风险点
别碰 runtime/proc.go 里的 trySteal 和 runqsteal —— 它们和 gopark、goready 的状态机深度耦合,改错一行就可能引发 goroutine 永久丢失或 double-run。
立即学习“go语言免费学习笔记(深入)”;
- 真正可控的调节面只有两个:
GOMAXPROCS(影响 P 数量,从而改变 steal 拓扑密度)和GODEBUG=scheddelay=10ms(延长自旋等待,间接增加 steal 机会) - 想“强制均衡”?别用自定义调度器。Go 1.22+ 的
runtime.SetSchedulerMode("adaptive")已开始实验性支持动态 P 分配,比手动干预 steal 更安全 - 最常被低估的瓶颈:steal 本身不慢,但 stolen goroutine 第一次执行时的栈复制(如果用了
go func() { ... }()闭包且捕获大对象)会触发写屏障和辅助 GC,这才是毛刺主因










