全局队列非负载均衡主力,仅用于冷启动和兜底;工作窃取才是实际生效机制,P在本地队列空时伪随机偷取其他P队列约一半goroutine,最多4轮。

全局运行队列(global run queue)不是负载均衡的主力
Go 调度器不会靠 global run queue 实现真正的负载均衡——它只负责冷启动和兜底。新创建的 goroutine 默认入全局队列,但调度器优先从 P 的本地队列取任务;只有当本地队列为空、且偷不到任务时,才会从全局队列拿。这意味着:全局队列积压 ≠ 系统负载高,更可能是本地队列调度不均或偷取失败。
常见错误现象:runtime.globrunqget 被频繁调用,同时 runtime.runqsteal 失败率上升,P 的本地队列长度方差大(有的满,有的空)。这说明工作窃取没跑通,而不是全局队列“没分好”。
- 全局队列是单生产者多消费者(SPMC)无锁队列,用 CAS + double-check 实现,但插入/弹出仍需原子操作,高并发写入有竞争开销
- 全局队列无优先级,FIFO 顺序,长耗时 goroutine 一旦入队,会阻塞其后所有任务的调度时机
- 全局队列长度不可靠:
len(globalRunq)不是导出字段,无法直接观测;用debug.ReadGCStats或 pprof 查看gcount时,它不区分来源
work-stealing 才是实际生效的负载均衡机制
真正让各 P 负载趋近均衡的是工作窃取(work-stealing),不是全局队列分发。每个 P 在本地队列为空时,会按伪随机顺序尝试从其他 P 队列尾部偷取约一半(≤256 个)goroutine,最多试 4 轮。这个过程完全在用户态完成,无系统调用,也不依赖全局锁。
容易踩的坑:runtime.runqsteal 返回 0 并不意味着“没任务”,而可能是目标 P 队列太短(
- 偷取失败常见原因:目标 P 刚被偷过,剩余任务 runqput 中的 CAS 更新,导致
atomic.LoadUint64(&pp.runqtail)暂时不准 - 偷取是尾部操作(LIFO),保证 cache locality:刚偷来的 goroutine 很可能复用前一个 goroutine 的 CPU 缓存行
- 没有“主动推送”逻辑:P 不会把本地队列溢出的任务主动推给全局队列或别的 P,必须等对方来偷——所以队列设计要留余量,避免饿死
为什么不能靠调大 GOMAXPROCS 来“自动均衡”
runtime.GOMAXPROCS 只控制最大 OS 线程(M)数量,和 goroutine(G)如何分到 P 上毫无关系。它既不改变全局队列分发策略,也不影响 work-stealing 行为。设成 100 和设成 4,只要 P 数不变(默认 = GOMAXPROCS),本地队列 + 偷取逻辑就完全一样。
典型误用场景:容器环境里硬编码 GOMAXPROCS(64),但实际只分配了 2 核 CPU,结果大量 M 被 OS 调度器频繁切换,反而加剧延迟抖动。
- P 的数量决定本地队列数量,才是负载粒度的关键。P 过少 → 单队列积压严重;P 过多 → 偷取开销占比上升,且空闲 P 白占内存
- 真实瓶颈常在 goroutine 本身:CPU 密集型任务卡住整个 P 的本地队列,I/O 型 goroutine 只能干等;这不是调度器问题,是任务类型混杂导致的
- sync.Pool 与负载均衡完全无关——它只缓存对象,不参与任务分发或排队
想干预负载分布?得绕过 runtime 自己管队列
Go 运行时不暴露本地队列或偷取接口,runtime.runqget、runtime.runqput 等函数是 unexported 的。如果你需要细粒度控制(比如按请求耗时加权分发、隔离 I/O 与 CPU 任务),唯一办法是:不用 go f() 直接启动,而是自己实现 worker pool + 任务 channel + 权重更新逻辑。
关键难点不在算法,而在避免锁争用和虚假唤醒。例如:
- 别用共享无界
chan interface{}:写入竞争激烈,len(chan)不反映真实积压,且 GC 会扫描所有 pending 元素 - 推荐用
sync.Pool+[]*task模拟双端队列,偷取时用atomic.LoadUint64检查长度,再用sync.Mutex移动尾部 1–2 个任务 - 权重更新必须基于运行时反馈:比如最近 10 秒平均延迟,而非静态 CPU 核数;更新频率建议 500ms,太快易抖动,太慢跟不上突发
最常被忽略的一点:work-stealing 是尽力而为的,不是强保障。它不保证“绝对公平”,只降低长尾概率。真要确定性调度,就得放弃 goroutine 抽象,直接管理线程与任务绑定。


















