上一节看了状态机,这一节看「谁来推动状态机」。调度循环是 runtime 里被调用最频繁的一段代码:每个 goroutine 阻塞、退出、被抢占,最终都要回到它。站内 Go 专题(/posts/golang/)已经把「本地队列 + 全局队列 + 窃取」的原理讲清楚了,本节不复述,只写两件增量:用真实回显把窃取行为「看见」,以及把 schedule → findRunnable → stealWork → runqsteal 这条调用链在源码里走一遍。
本节要回答:work stealing 在真实程序里长什么样,代码是怎么实现的? 结论是:
schedtrace里每个 P 的本地队列长度会在几秒内被「抹平」成 0~1,这是窃取生效的直接证据;runtime/metrics的/sched/goroutines/runnable则量化了「排队等待的 goroutine 有多少」。源码上,窃取由stealWork驱动,每轮最多尝试stealTries=4次。
2.2.1 实验:把窃取的痕迹「看见」
证据一:本地队列被抹平。 24 个 CPU 密集型 goroutine、10 个 P、持续 2.5 秒,schedtrace 每秒采一次:
GODEBUG=schedtrace=800 ./sched
SCHED 0ms: gomaxprocs=10 idleprocs=8 threads=3 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 [ 0 0 0 0 0 0 0 0 0 0 ] schedticks=[ 0 0 0 0 0 0 0 0 0 0 ]
SCHED 811ms: gomaxprocs=10 idleprocs=0 threads=11 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=8 [ 1 0 1 0 1 1 1 0 0 1 ] schedticks=[ 34 30 31 29 31 31 31 31 33 34 ]
SCHED 1624ms: gomaxprocs=10 idleprocs=0 threads=11 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=8 [ 1 1 0 1 0 0 1 1 1 0 ] schedticks=[ 65 62 62 61 61 63 63 63 65 65 ]
SCHED 2427ms: gomaxprocs=10 idleprocs=0 threads=11 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=8 [ 1 1 0 0 0 1 0 1 1 1 ] schedticks=[ 86 83 84 82 82 84 84 84 86 86 ]
读法:方括号里是 10 个 P 的本地队列长度,24 个 goroutine 分到 10 个 P 上,平均每个 P 应持有 2.4 个。但实测每个 P 的本地队列只有 0 或 1,另有 8 个在全局队列(runqueue=8)。为什么本地队列这么空?因为这些 goroutine 一直在跑、很少阻塞,一个 P 把本地队列里最后一个 goroutine 取走后就开始窃取别的 P——本地队列里始终只有一个「正在被取走」的 G,是窃取持续生效的特征。
另一个信号是 schedticks:三个样本里每个 P 的调度次数几乎相同(34/30/31…、65/62/62…、86/83/84…)。如果某个 P 的本地队列长期堆着活儿、另一个 P 空闲,schedticks 会出现明显分化——这里没有,说明负载被窃取均衡了。
证据二:量化「排队」的规模。 schedtrace 给的是快照,runtime/metrics 给的是可编程读取的计数:
samples := []metrics.Sample{
{Name: "/sched/goroutines:goroutines"},
{Name: "/sched/goroutines/runnable:goroutines"},
{Name: "/sched/goroutines/running:goroutines"},
{Name: "/sched/goroutines/waiting:goroutines"},
{Name: "/sched/gomaxprocs:threads"},
}
// ... 启动 24 个 CPU 密集型 goroutine,800ms 后读取 ...
metrics.Read(samples)
/sched/goroutines:goroutines 30
/sched/goroutines/runnable:goroutines 15
/sched/goroutines/running:goroutines 10
/sched/goroutines/waiting:goroutines 5
/sched/gomaxprocs:threads 10
30 个 goroutine 里,running=10(正好等于 gomaxprocs,每个 P 一个)、runnable=15(排队等 CPU)、waiting=5(运行时后台协程,如 GC、sysmon)。running 顶格在 10、runnable 有 15 个,说明 CPU 已打满,瓶颈就是核数——这与 2.1 节的 idleprocs=0、runqueue=10 是同一个事实的两种观测。
证据三:并行加速比。 窃取的最终目的是让 N 个 P 拿到接近 N 倍的吞吐。用同一个 CPU 密集基准在 -cpu=1,2,4,8,10 下跑:
go test -bench=BenchmarkCPU -benchtime=300x -cpu=1,2,4,8,10 -count=3 .
BenchmarkCPU 300 26460 ns/op
BenchmarkCPU-2 300 13340 ns/op
BenchmarkCPU-4 300 6708 ns/op
BenchmarkCPU-8 300 4073 ns/op
BenchmarkCPU-10 300 4485 ns/op
从 1 P 到 8 P,耗时从 26460 降到 4073 ns/op,加速约 6.5 倍(8 P 的理想值是 8 倍);10 P 这一档本机复测为 3442~3621 ns/op,仍略快于 8 P——原稿的 4485 来自单次 -benchtime=300x 采样,迭代少、抖动大,不足以断言「多开 2 个 P 一定变慢」。M1 Pro 的 10 个核里有 2 个能效核,多出来的 2 个 P 边际收益很低,这正是 1.3 节说的「核不是均质的」。
证据四:runnext 的代价,用 ping-pong 测。 两个 goroutine 通过无缓冲 channel 来回传递一个 token,测一次往返的耗时:
go test -bench=BenchmarkPingPong -benchtime=200000x -cpu=1,2,4 -count=3 .
BenchmarkPingPong 200000 248.5 ns/op
BenchmarkPingPong 200000 232.6 ns/op
BenchmarkPingPong 200000 215.5 ns/op
BenchmarkPingPong-2 200000 253.9 ns/op
BenchmarkPingPong-2 200000 264.0 ns/op
BenchmarkPingPong-2 200000 246.5 ns/op
BenchmarkPingPong-4 200000 266.0 ns/op
BenchmarkPingPong-4 200000 273.9 ns/op
BenchmarkPingPong-4 200000 283.1 ns/op
反直觉的结果:P 越多,ping-pong 越慢(1 P:215248 ns,4 P:266283 ns)。原因是 runnext 优化只有在被唤醒的 goroutine 落到同一个 P 时才生效(继承时间片、不经过队列);一旦两个 goroutine 被分到不同 P,唤醒就变成跨 P 的 wakep + 队列投递,延迟反而更高。这条数据在 3.3 节会再次用到。
复现基线:Go 1.27.0 darwin/arm64;Apple M1 Pro,8 性能核 + 2 能效核,10 逻辑核,32 GiB 内存;
GOMAXPROCS=10,GOGC=100。sched程序:24 个 goroutine、burn(50000)、2.5 秒;metrics 程序采样点 800ms;基准-benchtime=300x -count=3,给区间。
2.2.2 源码:调度循环与窃取的实现
调度循环的入口是 src/runtime/proc.go:4150 的 schedule,它的核心只有两行:
// src/runtime/proc.go:4150(片段)
func schedule() {
mp := getg().m
top:
pp := mp.p.ptr()
pp.preempt = false
...
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
...
execute(gp, inheritTime) // Never returns.
}
findRunnable(proc.go:3404)负责找活,找不到就阻塞。它的查找顺序是有讲究的,从近到远依次是:本地 runnext → 本地 runq → 全局 runq → 网络轮询 → 窃取别的 P。源码里能看到这些分界点:
grep -n "Check the global runnable queue\|runqget(pp)\|Spinning Ms: steal work\|We have nothing to do" proc.go
3455: // Check the global runnable queue once in a while to ensure fairness.
3484: if gp, inheritTime := runqget(pp); gp != nil {
3527: // Spinning Ms: steal work from other Ps.
3555: // We have nothing to do.
为什么全局队列只是「偶尔检查」(proc.go:3455 的注释 once in a while to ensure fairness)?因为本地队列是无锁的(只有属主 P 读写头尾),而全局队列要加锁。把全局队列放在后面、且只每隔 61 次调度检查一次,是为了让绝大多数调度走无锁快路径。findRunnable 最后才进入 stealWork:
// src/runtime/proc.go:3843(片段)
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
pp := getg().m.p.ptr()
ranTimer := false
const stealTries = 4
for i := 0; i < stealTries; i++ {
stealTimersOrRunNextG := i == stealTries-1
for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
if sched.gcwaiting.Load() {
return nil, false, now, pollUntil, true
}
p2 := allp[enum.position()]
if pp == p2 {
continue
}
...
if !idlepMask.read(enum.position()) {
if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
return gp, false, now, pollUntil, ranTimer
}
}
}
}
...
}
三个关键点:① stealTries = 4——最多尝试 4 轮,避免无限自旋;② 用 cheaprand() 随机化遍历顺序(stealOrder.start),避免所有 P 都从同一个受害者开始偷、造成争抢;③ idlepMask 跳过空闲的 P——偷空闲 P 没有意义。
真正搬动 goroutine 的是 runqsteal(proc.go:7781):
// src/runtime/proc.go:7781
func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
t := pp.runqtail
n := runqgrab(p2, &pp.runq, t, stealRunNextG)
if n == 0 {
return nil
}
n--
gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()
...
return gp
}
runqgrab 会搬走受害者队列的大约一半(不是全部)——这是 work stealing 的经典策略:偷一半既平衡了负载,又让受害者还有活儿可干,避免「一偷就空、被偷者立刻变成偷窃者」的抖动。本地队列的容量是 p.runq [256]guintptr(runtime2.go:774 的 p 结构体),一个 P 本地最多排 256 个。
入队与出队的快路径在 runqput(proc.go:7529)和 runqget(proc.go:7649)。runqget 先看 runnext:
// src/runtime/proc.go:7649(片段)
func runqget(pp *p) (gp *g, inheritTime bool) {
next := pp.runnext
if next != 0 && pp.runnext.cas(next, 0) {
return next.ptr(), true // inheritTime=true:继承当前 G 剩余时间片
}
for {
h := atomic.LoadAcq(&pp.runqhead)
t := pp.runqtail
if t == h {
return nil, false
}
gp := pp.runq[h%uint32(len(pp.runq))].ptr()
if atomic.CasRel(&pp.runqhead, h, h+1) {
return gp, false
}
}
}
runnext 是一个特殊的「下一个就轮到你」槽位:被 goready 唤醒的 goroutine 若与当前 goroutine 有通信关系(典型是 channel 的发送方/接收方),会被放进 runnext 而不是队尾,从而继承当前时间片(inheritTime=true)立刻运行。这就是「通信并等待」模式(ping-pong)延迟低的原因。
当本地队列(容量 256)满了,runqput 会调用 runqputslow(proc.go:7575)把一半本地 G 连同新 G 一起挪到全局队列——这也解释了 2.2.1 里为什么全局 runqueue=8 而非 0:24 个 G 短时间内大量就绪,本地队列溢出后一部分被推到了全局队列。
2.2.3 决策:队列分布 → 结论
把实验里的观测模式整理成一张对照表,看到哪个特征就下哪个判断:
| 观测(schedtrace / metrics) | 含义 | 决策 |
|---|---|---|
各 P 本地队列 0~1、schedticks 接近 | 窃取生效、负载均衡 | 正常,无需干预 |
某 P 本地队列长期大、别的 P _Pidle | 窃取没跟上或任务不均衡 | 检查是否有 LockOSThread、长临界区 |
runnable 远大于 running | CPU 是瓶颈、任务在排队 | 加核、或减少并发度、或减少每任务耗时 |
running == gomaxprocs 且 idleprocs=0 | 所有 P 都在跑 | CPU 饱和,考虑 GOMAXPROCS 与核数是否匹配 |
waiting 占比高、running 低 | 大量 goroutine 阻塞在 I/O/锁 | 转去看 3.2 的 trace 时间线 |
| 加速比随 P 数增长但不到线性 | 存在串行段或能效核 | 用 3.3 的方法定位串行段 |
三条结论:
- work stealing 让「本地队列几乎总是空的」成为常态,而不是异常。 看到每个 P 本地只有 0~1 个 G,不要以为队列坏了——恰恰相反,这是窃取勤快的表现。
runnext是延迟敏感路径的关键。 高频的 channel ping-pong 会大量使用runnext,这也是为什么它值得单独一个槽位、以及为什么randomizeScheduler默认关闭(打开会打散这种优化)。- 加速比在性能核数附近见顶,再往上边际收益很低。 M1 Pro 上 8→10 核只多约 1.1 倍,所以「开满逻辑核」未必划算——第 3.3 节会用实验展开。
- 跨 P 唤醒比同 P 唤醒贵。 如果一段代码是「两个 goroutine 高频往返」,且对延迟敏感,把它绑在同一个 P 上(甚至
runtime.LockOSThread到同一线程)反而更快,这是 ping-pong 数据给出的反直觉结论。 runqsize长期为 0 不代表没活干。 它只反映「本地队列快照」,全局队列和runnext不在其中;判断是否有积压要看 metrics 的runnable。
一句话总结这一节:调度循环是一条「近处优先、远处兜底」的取活流水线,work stealing 是它的最后一道保险,而 runnext 是它为低延迟通信开的一条绿色通道。
判断一个服务是否受调度器拖累,最快的办法是同时看 runnable 计数和 sched/latencies 直方图:前者大说明排队多,后者右尾长说明「从就绪到真正跑起来」的等待久——后者才是用户能感知到的延迟。
下一节看当某个 goroutine「不肯让出 CPU」时,runtime 是怎么把它强行换下来的。
阅读导航:上一节:2.1 G/M/P 结构与状态机 · 下一节:2.3 抢占式调度与 sysmon 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。