Go 语言中 runtime 调度器中全局队列与本地队列的区别
先说说本地队列和全局队列的本质区别。很多人一听到“调度器”这个词,第一反应就是“队列”,然后下意识地把注意力放在了全局队列上。其实,在 Go runtime 的设计里,本地队列才是真正的“主角”,而全局队列更像是一个兜底的协调者。 本地队列之所以被优先使用,道理其实很直接:它是每个 P 独有的、无锁
先说说本地队列和全局队列的本质区别。很多人一听到“调度器”这个词,第一反应就是“队列”,然后下意识地把注意力放在了全局队列上。其实,在 Go runtime 的设计里,本地队列才是真正的“主角”,而全局队列更像是一个兜底的协调者。
本地队列之所以被优先使用,道理其实很直接:它是每个 P 独有的、无锁的 FIFO 队列。存取操作完全不涉及任何同步开销——这意味着只要本地队列非空,M 就能直接从里面弹出一个 G 来执行,整个过程都在 CPU cache 内完成,延迟极低。
有一个很常见的误解:有人觉得“全局队列更权威”,于是手动绕过了本地调度逻辑。结果呢?锁竞争和 cache line bouncing 反而让吞吐量降下来了。
P.runq的容量上限是 256 个G。一旦超过这个数,就会触发“半队列迁移”——把其中一半丢到全局队列里。- 新建
G(比如用go f())时,默认先进当前P的本地队列,而不是全局队列。 - 本地队列里的
G,它的栈内存和调度上下文大概率还待在当前P绑定的 CPU core 的 L1/L2 cache 里,内存访问延迟自然就低了。

全局队列(globalRunq)加锁但不可替代
全局队列是所有 P 共享的,带互斥锁。它可不是什么“备用通道”,而是调度器实现公平性与初始化语义的关键枢纽。
什么样的情况下才会用到它?
- 新
G创建时,如果当前P不存在(比如在系统调用返回路径中创建),那就只能进全局队列了。 G从阻塞态(比如网络 I/O、定时器、channel recv)唤醒后,如果原来的P已经被其他M占用或者处于休眠状态,这个G也会被放进全局队列,等着重新绑定。- 当某个
P的本地队列为空,而且也没能从其他P那里窃取到任务(work stealing)时,才退而求其次,从全局队列批量取一批——通常是 32 个。
需要强调的是:globalRunq 上的锁(runqlock)是自旋锁+普通 mutex 的混合实现,争抢激烈时会短暂阻塞,但设计上已经把临界区长度压缩得相当短了。
本地队列满时会发生什么?
这不是什么异常,恰恰相反,这是常态下主动的负载均衡机制。一旦 P.runq 达到 256,下一次新 G 进来时,runtime 会先把本地队列前 128 个 G 批量迁移到 globalRunq,再把新 G 塞进本地队列尾部。
很多人看到这个“迁移”动作,容易误以为这是某种性能劣化的信号。其实反过来想:
- 它避免了单个
P的队列无限膨胀,导致其他P饿死。 - 让全局队列始终保有新鲜任务,大幅提升了 work stealing 的成功率。
- 迁移本身是批量原子操作,比逐个 push 到全局队列高效得多。
你可以用 go tool trace 观察 Proc/RunQueue 和 Global/RunQueue 的长度波动。正常服务中,二者应该是弱相关震荡的状态,而不是单边持续增长。
什么时候会从全局队列取任务?
触达 globalRunq 的 pop 操作,只有三种明确路径:
- 在
findrunnable()函数中,当前P本地队列为空,并且所有其他P的本地队列都窃取失败后,才会尝试从globalRunq取一批(通过globrunqget)。 - 系统调用返回时,如果
M找不到空闲的P,它就把刚唤醒的G放进globalRunq,等别的M来消费。 - GC stw 阶段结束后,部分被暂停的
G会统一注入globalRunq,这是为了避免集中唤醒压垮单个P。
顺便说一句:别指望靠增大 GOMAXPROCS 来“减少全局队列的使用”。GOMAXPROCS 只管 P 的数量,不影响队列选择逻辑。真正影响全局队列压力的,是 goroutine 的创建节奏、阻塞比例以及 P 负载分布的均匀程度。

































