两个队列:本地队列与全局队列的基本分工
如果你的服务在高峰期每秒创建几十万个 goroutine,你多半能感受到调度器带来的压力。Go 的调度器能在这样高并发下保持不错表现,与它的运行队列设计直接相关:每个处理器(P)都维护一个本地运行队列,同时整个调度器共享一个全局运行队列。两套队列之间的配合方式,就是今天要聊的主题。

要理解运行队列,得先回到 G、P、M 这三个角色。M 是操作系统线程,P 是调度所需的上下文,G 是被调度的 goroutine。P 的数量通常由 GOMAXPROCS 决定,默认等于 CPU 核数。每个 P 都绑定一个 M 来执行 G,而 G 等待执行时的位置,就是运行队列。
Go 的运行队列并不是一套,而是两套。每个 P 都有一个本地队列 local run queue,以及一个名叫 runnext 的单插槽;全局则有一个 shared run queue 作为所有 P 的共同后备池。在运行时源码里,它们分别对应 runtime2.go 中 p 结构体的 runq、runnext,以及 schedt 结构体中的 runq。很多人把 runnext 也理解为队列,但它其实是一个单独的槽位,和 runq 一起组成本地调度资源。
| 维度 | 本地队列 | 全局队列 |
|---|---|---|
| 归属 | 每个 P 单独维护 | 整个调度器共享 |
| 访问同步 | 自身访问无锁,被偷取时加锁 | 任何访问都需要全局锁 |
| 存储结构 | 固定长度环形数组 | 动态链表 |
| 默认容量 | 256 | 不固定 |
| 主要作用 | 承接当前 P 新建的任务 | 溢出缓冲、后备任务池 |
| 饥饿风险 | 低,runnext 可提升优先级 | 较高,依赖周期性检查 |
runnext:本地队列里的隐藏插槽
runnext 经常被忽略,但它对理解调度顺序非常关键。它保存的是下一个优先执行的 G。在每次调度循环的开始,调度器会先检查 runnext,如果里面有任务,就直接执行它,而不去碰本地队列。只有在 runnext 为空时,调度器才会依次处理本地队列和全局队列。
这个设计本质上是给调度器一个“插队”的能力。当某个 goroutine 被唤醒,或者某一个 G 不应该被延迟太多时,调度器可以把它放到 runnext 中,让它几乎立即运行。不过这并不是所有唤醒场景都会走这条路,只有当唤醒者与被唤醒者处于合适关系时,才会走这个快路径。
插队行为会带来一个副作用:如果 runnext 被频繁占用,排在本地队列尾部的任务可能一直得不到运行。好在调度器还设计了周期性检查全局队列的机制,从另一个方向避免饥饿。这也说明 Go 的调度器并不是单纯追求公平,而是在公平和吞吐之间做平衡。
全局队列为什么不能直接成为主力
既然全局队列可以容纳所有任务,并且从里面取任务看起来也很简单,为什么不直接用全局队列作为唯一运行队列呢?原因很直接:锁竞争。如果所有 P 都在同一个全局队列上操作,每往队列放一个 G 都需要抢锁。在多核场景下,这把锁会迅速成为调度瓶颈。
所以本地队列是主要的工作区,全局队列只是在特殊情况下才被用起来。比如本地队列满了,新建的 G 会被追加到全局队列;某些唤醒路径上,G 也会被直接放到全局队列。当某个 P 长时间取不到本地任务时,同样会试图从全局队列拿一批任务。
还有一个防止饿死的细节。调度循环并不会一直守着本地队列不放,而是周期性从全局队列取一次任务。这个机制保证即使本地队列一直有任务可做,全局队列里的 G 也不会被无限期拖延。下面是调度循环的简化伪代码,你可以看到选择顺序:
// 简化后的调度循环,保留优先级顺序
func schedule() {
if gp := getRunnext(); gp != nil {
execute(gp)
}
// 周期性检查全局队列,避免全局队列饥饿
if shouldPollGlobalQueue() {
if gp := getGlobalQueue(); gp != nil {
execute(gp)
}
}
// 正常情况从本地队列取任务
if gp := getLocalQueue(); gp != nil {
execute(gp)
}
// 本地队列为空,再从全局队列取一批
if gp := getGlobalBatch(); gp != nil {
execute(gp)
}
// 仍然没有任务,就触发 work stealing
gp := stealWork()
execute(gp)
}
这段代码不是 runtime 的真实实现,但优先级关系是准确的。注意在实际代码里,每个步骤之间还有条件判断和返回路径,伪代码把最核心的决策链抽了出来。
真正的负载均衡:工作窃取是怎么发生的
运行队列的负载均衡主要不是靠“分发”,而是靠“偷取”。当一个 P 的本地队列为空,而其他 P 有大量排队任务时,空闲的 P 会随机选择目标,从目标 P 的本地队列中拿走大约一半的任务。这个动作会加锁,但只在窃取时发生,所以成本是可以接受的。
为什么是随机挑选而不是按照固定顺序轮询?如果所有空闲 P 都按同一顺序扫描,它们可能会同时扑向同一个满载 P,形成新的热点。随机化让窃取行为分散开,降低了碰撞概率。为了减少无谓尝试,运行时在窃取时会从一个随机位置开始扫描,并优先选择队列长度更长的 P 作为目标。
还有一个细节:窃取只发生在对方的 runq 上,不会去动 runnext。因为 runnext 是目标 P 的私有快路径,偷走它会影响那个 P 的调度效率。从全局队列取任务时,调度器也不是一次只取一个,而是批量取走一批,批量化降低了锁的拿取频率,也让偷取方有足够的任务避免频繁重启调度循环。
假设一个 32 核的网关服务正在处理一批批 WebSocket 消息,每个消息触发几个 goroutine。刚进入高峰期时,各 P 的本地队列都比较饱满。当某些 P 处理完当前请求后,它们会迅速变空,然后立刻去偷取其他 P 的任务。如果消息模型非常均匀,每个 P 都会持续处于忙碌状态,偷取频率很低;如果有的 P 被长任务占据,偷取压力就会集中到少数几个 P 上。
另一种常见场景是大量 goroutine 短暂阻塞后恢复。比如一个 goroutine 等待锁,锁被释放后它被唤醒。这类 goroutine 往往会走 runnext 快路径,直接抢占下一次调度。结果就是,本地队列中那些已经排队的普通任务可能被一次又一次地挤到后面。
运行队列设计中的常见误区
- 误区一:本地队列是严格 FIFO。实际上 runnext 插槽和周期性全局队列检查会让调度顺序出现前后跳跃。
- 误区二:全局队列是本地队列的备用仓库。其实它更像溢出缓冲与跨 P 调度交接点,正常运行时访问频率很低。
- 误区三:负载均衡主要靠任务分配。真正起作用的是工作窃取,新建任务优先放在当前 P 的本地队列中,均衡发生在后面的窃取阶段。
- 误区四:全局队列始终公平。它只能通过周期性检查来避免饥饿,公平性完全依赖调度器的 tick 策略。
什么时候运行队列会成为瓶颈
多数组件在物理资源耗尽前不会出问题。运行队列也一样,当 P 的本地队列频繁填满,全局队列开始堆积,说明系统正在产生远超处理能力的可运行 G。这时候观察调度日志,往往能看到全局队列长度居高不下。但这并不是队列设计本身的问题,而是业务侧创建了太多无法及时消费的 goroutine,或者那些 goroutine 的执行时间比预期长。
另一个容易被忽略的瓶颈与局部性有关。本地队列设计的目标之一是保持局部性,让 G 尽量在同一个 P 上运行。但 work stealing 会破坏这种局部性:被偷走的 G 会在另一个 P 上执行,访问它曾经依赖的堆内存或者锁状态时,可能带来额外的缓存开销。好在偷取频率有限,只在本地队列为空时发生,所以大多数情况下代价可以忽略。
还有一点:如果你把 GOMAXPROCS 调到远大于 CPU 核数,比如 8 核机器上设成 64,调度器会创建 64 个 P。P 之间频繁地窃取任务,自旋线程增多,调度开销会显著上升。此时观察运行队列,你可能会看到大量 P 的空闲时间很少,但吞吐并没有提升。这个现象其实和运行队列的结构关系不大,更像是过度并行化的副作用。
在真实项目中,判断运行队列是否健康,不能只看本地队列是否为空。真正要关注的是任务从生成到被调度执行的延迟,以及全局队列长度的变化趋势。
落地观察:用调度日志判断队列状态
理解运行队列的最佳途径是直接观察运行时状态。Go 提供了 GODEBUG 参数来输出调度日志,例如 GODEBUG=schedtrace=1000。这个参数会每隔 1000 毫秒打印一行调度摘要,其中包含可运行任务的数量、空闲线程数以及每个 P 的状态。如果你加上 play=true,还能在日志里看到每个 G 的状态变化。
GODEBUG=schedtrace=1000 go run main.go
如果日志显示全局队列长度持续大于 0,说明任务生产速度超过了消费速度。如果本地队列频繁为空,但全局队列也有积压,往往是业务侧创建 goroutine 的模式不均衡,需要结合 pprof 进一步定位。
理解运行队列,才能理解 Go 的性能边界
Go 调度器的运行队列设计是典型的工程折中:本地队列换低锁竞争,全局队列兜底,runnext 提供快速插队能力,工作窃取负责整体负载均衡。每一层都有它的适用条件,也都有它的代价。
本地队列容量固定,注定它只能承接短期突发任务;全局队列有锁,注定它只适合低频访问;runnext 牺牲公平换优先级;窃取破坏局部性换整体吞吐。理解这些“注定”,你不会在遇到调度相关问题时,把锅甩到“队列不够大”或者“全局队列没用好”上。
如果你正在排查高并发服务的性能问题,不妨从运行队列的角度看看:本地队列有没有频繁溢出?全局队列是否持续有积压?空闲 P 是否总是忙着偷取?想清楚这几个问题,你离真正的问题点往往已经很近。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/542/