bigywater.tech
Go · 52

GC 停顿:STW 到底停多久,哪几个阶段要停

“Go 的 GC 停顿很短”是对的,但“应用延迟不受 GC 影响”就未必。这篇分开量两件事:STW 停顿本身多长,以及应用里一个定时唤醒的 goroutine 在 GC 期间实际被耽误多久,并用对照实验排除一些常见的解释。

≤ 0.5 ms
10 MB 到 1 GB 的存活堆、最多 50 万个 goroutine 下,STW 停顿的直方图上界
p99 27 ms
存活堆 ≥ 500 MB 时,1 ms 睡眠的多余延迟(10 MB 时是 0.93 ms)
30 ms
没有任何其它负载、只有 GC 在跑时,1 ms 定时唤醒的 p99 多余延迟

STW 很短,但应用延迟随堆变大

实测 · 本机执行输出
  live=10MB g=0 P=10 GC=605 次 | 总停顿(stop+run)直方图:最大≈197µs p99≤57µs(1217 次) 停止阶段最大≈197µs | 1ms 睡眠的多余延迟:p50=0.25ms p99=0.93ms p99.9=1.03ms max=1.18ms(1592 次采样) | 累计 PauseTotal=32.02ms
  live=100MB g=0 P=10 GC=76 次 | 总停顿(stop+run)直方图:最大≈197µs p99≤197µs(160 次) 停止阶段最大≈197µs | 1ms 睡眠的多余延迟:p50=0.26ms p99=11.27ms p99.9=13.44ms max=14.47ms(1067 次采样) | 累计 PauseTotal=5.08ms
  live=500MB g=0 P=10 GC=15 次 | 总停顿(stop+run)直方图:最大≈524µs p99≤524µs(40 次) 停止阶段最大≈524µs | 1ms 睡眠的多余延迟:p50=0.26ms p99=26.50ms p99.9=35.74ms max=43.56ms(1028 次采样) | 累计 PauseTotal=1.13ms
  live=1000MB g=0 P=10 GC=5 次 | 总停顿(stop+run)直方图:最大≈98µs p99≤98µs(20 次) 停止阶段最大≈25µs | 1ms 睡眠的多余延迟:p50=0.26ms p99=27.06ms p99.9=31.85ms max=34.94ms(1069 次采样) | 累计 PauseTotal=0.38ms

GC 里需要 STW 的只有清扫终止与标记终止两个很短的阶段,这个实验用 /sched/pauses/total/gc:seconds 直方图量到的最大值始终在几百微秒内,与存活堆大小(10 MB 到 1 GB)基本无关——堆的大小影响的是并发标记持续多久,不在 STW 里。注意直方图的桶宽较粗,文中的“约 X µs”是桶的上界。同时测的另一个指标是应用自己的:一个每 1 ms 睡一次的 goroutine 实际多睡多久。堆小(10 MB)时 p99 不到 1 ms;堆大了以后 p99 变成 10 到 27 ms,与并发标记的持续时间同步增长。

goroutine 数量

实测 · 本机执行输出
  live=50MB g=0 P=10 GC=173 次 | 总停顿(stop+run)直方图:最大≈197µs p99≤57µs(354 次) 停止阶段最大≈164µs | 1ms 睡眠的多余延迟:p50=0.26ms p99=4.41ms p99.9=5.64ms max=7.05ms(1147 次采样) | 累计 PauseTotal=10.36ms
  live=50MB g=10000 P=10 GC=123 次 | 总停顿(stop+run)直方图:最大≈164µs p99≤164µs(255 次) 停止阶段最大≈164µs | 1ms 睡眠的多余延迟:p50=0.26ms p99=5.58ms p99.9=6.64ms max=7.02ms(1096 次采样) | 累计 PauseTotal=6.19ms
  live=50MB g=100000 P=10 GC=35 次 | 总停顿(stop+run)直方图:最大≈393µs p99≤393µs(81 次) 停止阶段最大≈393µs | 1ms 睡眠的多余延迟:p50=0.26ms p99=26.74ms p99.9=28.66ms max=29.19ms(789 次采样) | 累计 PauseTotal=1.83ms
  live=50MB g=500000 P=10 GC=7 次 | 总停顿(stop+run)直方图:最大≈115µs p99≤115µs(29 次) 停止阶段最大≈98µs | 1ms 睡眠的多余延迟:p50=0.26ms p99=27.37ms p99.9=39.05ms max=44.39ms(721 次采样) | 累计 PauseTotal=0.42ms

额外的阻塞 goroutine(1 万、10 万、50 万个)没有让 STW 线性变长(上界分别是 164、393、115 µs)。睡眠延迟的 p99 在 10 万个以上变成 26–27 ms,但这组实验里 GC 次数也同步减少(173 → 7),我没有把“goroutine 多”与“标记慢”两个因素分开,所以不能把延迟变化单独归因于 goroutine 数。

GOMAXPROCS

实测 · 本机执行输出
  live=100MB g=0 P=1 GC=24 次 | 总停顿(stop+run)直方图:最大≈33µs p99≤33µs(53 次) 停止阶段最大≈0µs | 1ms 睡眠的多余延迟:p50=30.63ms p99=51.41ms p99.9=51.41ms max=55.26ms(64 次采样) | 累计 PauseTotal=0.67ms
  live=100MB g=0 P=2 GC=43 次 | 总停顿(stop+run)直方图:最大≈49µs p99≤49µs(94 次) 停止阶段最大≈25µs | 1ms 睡眠的多余延迟:p50=11.88ms p99=31.32ms p99.9=31.35ms max=31.35ms(150 次采样) | 累计 PauseTotal=2.02ms
  live=100MB g=0 P=4 GC=61 次 | 总停顿(stop+run)直方图:最大≈164µs p99≤49µs(131 次) 停止阶段最大≈164µs | 1ms 睡眠的多余延迟:p50=0.26ms p99=16.51ms p99.9=16.74ms max=16.85ms(823 次采样) | 累计 PauseTotal=2.92ms
  live=100MB g=0 P=10 GC=76 次 | 总停顿(stop+run)直方图:最大≈1311µs p99≤82µs(163 次) 停止阶段最大≈1311µs | 1ms 睡眠的多余延迟:p50=0.26ms p99=11.13ms p99.9=12.46ms max=15.39ms(1037 次采样) | 累计 PauseTotal=4.60ms

P 越少,应用越难在 GC 期间拿到 CPU:P=1 时睡眠延迟的 p50 就是 30.63 ms,因为两个分配 goroutine、后台标记和这个睡眠 goroutine 抢同一个 P;P=10 时 p50 只有 0.26 ms,p99 是 11.13 ms。STW 本身在各个 P 数下都不超过约 1.3 ms。

不是别的 goroutine 的错

实测 · 本机执行输出
  不分配、不 GC(基线):  live=500MB g=0 P=10 GC=0 次 | 总停顿(stop+run)直方图:最大≈524µs p99≤524µs(10 次) 停止阶段最大≈459µs | 1ms 睡眠的多余延迟:p50=0.27ms p99=0.33ms p99.9=0.56ms max=1.49ms(1575 次采样) | 累计 PauseTotal=0.00ms
  只有一个 goroutine 不停 runtime.GC():  live=500MB g=0 P=10 GC=44 次 | 总停顿(stop+run)直方图:最大≈57µs p99≤57µs(99 次) 停止阶段最大≈33µs | 1ms 睡眠的多余延迟:p50=0.00ms p99=29.50ms p99.9=33.03ms max=33.88ms(559 次采样) | 累计 PauseTotal=1.45ms
  2 个分配 goroutine(同第一节):  live=500MB g=0 P=10 GC=18 次 | 总停顿(stop+run)直方图:最大≈197µs p99≤197µs(46 次) 停止阶段最大≈197µs | 1ms 睡眠的多余延迟:p50=0.26ms p99=25.79ms p99.9=31.30ms max=39.77ms(968 次采样) | 累计 PauseTotal=1.50ms
  10 个分配 goroutine(超过 P 的数量,抢满 CPU):  live=500MB g=0 P=10 GC=15 次 | 总停顿(stop+run)直方图:最大≈1573µs p99≤1573µs(40 次) 停止阶段最大≈1573µs | 1ms 睡眠的多余延迟:p50=19.26ms p99=52.61ms p99.9=52.74ms max=53.53ms(115 次采样) | 累计 PauseTotal=1.69ms

对照实验把嫌疑逐个排除:没有 GC 时延迟只有 0.33 ms;只留一个 goroutine 反复 runtime.GC()、没有任何分配的竞争者,STW 约 57 µs,p99 仍是 29.5 ms。所以这不是“分配 goroutine 抢了 CPU”,也不是 STW。我没有查明原因,只有一个线索:源码里空闲 P 上的标记 worker 判断“是否该让出 CPU”用的 pollWork 检查的是全局与本地运行队列和 netpoll,没有检查已到期的定时器。这与现象吻合,但我没有做实验证明,不要把它当成结论。实践上的含义:关心延迟尾部时,要在真实负载下量应用自己的延迟分布,不能只看 gctrace 里的 STW。

分步演示

GC 停顿
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。停顿来自 runtime/metrics 的 /sched/pauses/total/gc:seconds 与 /sched/pauses/stopping/gc:seconds 直方图(取有计数的最大桶上界);应用延迟是一个每 1 ms 睡眠一次的 goroutine 记录的“实际睡眠时间减 1 ms”;存活堆是 N 个 56 字节节点的图,分配 goroutine 不停地 new 小对象;每个设置只跑一次 2 秒;读过的源码:runtime/proc.go 的 pollWork
    • 没有验证:定时器延迟的根本原因(pollWork 是线索不是证明)、GOGC/GOMEMLIMIT 对尾延迟的缓解效果、真实服务里的请求延迟、不同 GC 实现(nogreenteagc)下是否相同、amd64 与 Linux 上的数字、多次重复运行的离散度
    京ICP备2026067502号-1京公网安备11010502063487号