“Go 的 GC 停顿很短”是对的,但“应用延迟不受 GC 影响”就未必。这篇分开量两件事:STW 停顿本身多长,以及应用里一个定时唤醒的 goroutine 在 GC 期间实际被耽误多久,并用对照实验排除一些常见的解释。
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,与并发标记的持续时间同步增长。
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 数。
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。
不分配、不 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。