很多Gopher都听说过“Go的GC暂停时间很短”,但短到什么程度,取决于什么,却很少有人说得清。上次排查一个在线的推送服务,堆内对象大概1GB左右,接口P99偶尔飙升到2秒。第一反应是GC暂停,结果监控面板上的GC数据显示STW最大不过几毫秒,根本不是超时的元凶。这件事让我意识到,如果不把STW的构成和分布搞清楚,很容易被表面数字误导。

最近我在本地做了一轮对比实验,把堆大小从1GB一路压到16GB,在相同负载下记录GC周期内STW的分布。这篇文章就是这次实验的记录、分析和一些反思。
先分清:GC暂停到底包含哪几个STW
Go的并发GC并不是全程STW,而是把工作拆成几个阶段。真正需要停止所有goroutine的时机主要有两个:标记开始的阶段(用于切换状态)和标记终止阶段(用于完成最后的收尾)。绝大多数情况下,标记开始的STW只有几十到几百微秒,基本可以忽略;而标记终止的STW则和堆中存活对象的数量、结构复杂度等密切相关,也是我们常说的“主要GC暂停”。
具体来说,标记终止阶段要做三件事:刷掉所有剩余的写屏障记录,把各个P的gcWork缓冲区合并到全局,以及让整个标记过程收敛到一致状态。这些操作的耗时与堆中活跃对象的指针数量密切相关,所以STW不会只看堆大小而线性变化。
所以,当你看到一条GC记录时,要清楚里面哪个数字才是关键。如果直接看“GC pause”平均值,可能把两个阶段混在一起,结论自然失真。
我是怎么测的:环境、方法、gctrace
这次测试没有使用太复杂的工具,而是直接用Go自身提供的GODEBUG。测试机器是Linux 5.18,4核虚拟化环境,Go版本1.21。程序会定期申请一批临时对象,同时保留一定数量的长生命周期对象,模拟业务中常见的场景。通过调整分配速率,让堆的“当前使用量”分别稳定在1GB、2GB、4GB、8GB和16GB附近。
采集STW的方式是在启动命令行里加上GODEBUG=gctrace=1。每次GC会在标准错误输出一行摘要,类似下面这样:
gc 4 @2.510s 3%: 1.0+12.5+2.5 ms clock, 4.0+0+0 ms cpu, 1678->1505->1120 MB, 2240 MB goal, 0 P
输出中第二段的第一个数字是标记开始STW,第三个数字是标记终止STW。我们记录的是第三个数字,也就是真正主导GC暂停的部分。为了减少偶发,每个堆大小都持续运行至少10分钟,收集所有GC记录,再计算P50、P99和最大值。
堆大小对STW的影响:实测数据
下面是我整理后的结果。注意这里的“堆大小”指的是GC报告里的“当前堆使用量”,不是容量上限。
| 堆大小 | P50 STW (ms) | P99 STW (ms) | 最大 STW (ms) |
|---|---|---|---|
| 1 GB | 0.9 | 2.2 | 3.8 |
| 2 GB | 1.3 | 3.1 | 5.2 |
| 4 GB | 2.1 | 5.8 | 8.9 |
| 8 GB | 3.7 | 10.4 | 15.6 |
| 16 GB | 6.2 | 19.3 | 29.4 |
趋势很直观:堆越大,STW越高。不过这比很多人想象的“线性增长”要温和:从1GB到16GB,P50只涨了不到7倍,P99涨了接近9倍,最大则从3.8ms涨到29.4ms。原因也很明确——标记终止阶段的工作量主要取决于“存活对象的数量和结构”,而不是堆里总共有多少字节。如果你的服务堆很大,但大部分都是垃圾,STW可能涨得比这个表慢得多;反过来,如果堆里大量长期存活的对象,STW可能更难看。
注意P99和最大的差距。16GB时P99是19.3ms,最大已经29.4ms,长尾非常明显。线上服务往往就是被这样的离群点击败的。平均1ms的暂停可能会被请求队列缓冲掉,但20ms的暂停足以让一个延迟敏感的接口直接超时。
从表里还能看到,堆大小翻16倍,P50只增加了约7倍。这暗示GC的部分工作被分摊到了并发标记阶段。但P99的增加接近9倍,说明同步和收敛这些无法并行的部分,依然受堆复杂度的直接影响。
三个容易让人犯错的认知误区
聊完数据,我想说几个常见误解。这些说法听起来有道理,但实际落地时会误导你。
误区一:Go 的 STW 天生很短,堆大一点也没关系
从数据看,堆上到4GB,P99已经接近6ms;到16GB,P99接近20ms。对延迟敏感系统,这些数字绝对不可忽略。
误区二:调大 GOGC 就能降低 STW
GOGC 控制的是GC触发的频率,不是单次STW的大小。调大之后,堆会更晚触发GC,但每次GC面对的对象可能更多,STW不降反升。如果你真正的问题是STW,单改GOGC经常帮倒忙。
误区三:看gctrace里的最大值就行
gctrace输出的是每次GC的阶段耗时,但监控面板通常给你的是平均或最近一次。平均会抹掉长尾。正确做法是记录每一次GC的STW,然后看P99和P999。
数据之外:影响STW的深层因素
上面的实验是一个固定负载下的快照,真实生产环境还要复杂一些。我至少观察到两个比“堆大小”更重要的因素。
第一,存活对象图的复杂程度。同样是1GB堆,如果里面全是短命的小对象,GC标记很快;如果是一个巨大的缓存map,里面挂了几百万个指针,标记终止阶段遍历引用的开销就上去了。优化时应关注对象生命周期,而不是单纯堆大小。
第二,CPU核数以及系统调度。并发标记会利用所有P并行工作,但标记终止需要所有P在同步点集合。核数越多,同步等待的方差可能越大。我这次在4核环境测出来是这些值,不代表16核机器上的数据,也不代表容器里被限流后的表现。生产环境需要单独压测。
降低STW的几个可操作方向
如果经过测量,确认STW已经影响业务,下面几个方向可以试试。
- 减少活跃对象数量:用sync.Pool复用临时对象,减少逃逸到堆上的指针数量。这对标记终止阶段的遍历负担很有效。
- 控制堆提前量:Go 1.19+ 的GOMEMLIMIT可以限制堆上限,让GC更早开始,避免堆膨胀到夸张的程度。注意这会增加GC频率,需要结合吞吐要求。
- 调整GOGC:只在明确知道STW瓶颈且堆较小的时候尝试调整。比如服务分配压力不大,可以把GOGC调低,让GC更频繁但每次更小。
- 升级Go版本:不同版本对GC pacer和STW都有持续优化,升级也许能直接缓解。
这些方法不是互斥的,但需要基于实际数据来选择。你可以用下面这张表快速评估它们的取舍:
| 方案 | 对STW的可能影响 | 主要成本/风险 |
|---|---|---|
| 减少活跃对象 | 明显降低 | 代码改造,可能增加开发成本 |
| GOMEMLIMIT | 降低单次STW,但GC更频繁 | 吞吐可能下降,需要压测 |
| 调整GOGC | 效果不确定 | 可能加剧长尾 |
| 升级Go版本 | 可能有改善 | 需要回归测试 |
落地参考:一份延迟排查检查单
最后,把我这次排查STW问题的流程整理成一个检查单,供你参考。
- 使用GODEBUG=gctrace=1运行服务,至少收集一天的数据,导出每次GC的STW字段。
- 画出STW的P50、P99、P999曲线,确认是否存在长尾,以及长尾与堆大小的关系。
- 用pprof分析内存分配,找出让堆增长的主要对象来源。
- 评估业务的延迟目标,确定STW是否构成瓶颈。如果STW占了延迟预算的1%以内,不值得优化。
- 选定优化手段后,在下一次发布前用压测验证,同时关注CPU和QPS的变化。
注意,不要一来就改参数。先测量,再决策,永远比猜准。
回到标题:STW到底要多久
我这次的实测结果是:1GB堆时,STW的P50是0.9ms,P99是2.2ms;16GB堆时,P50涨到6.2ms,P99接近20ms。这个数值范围可以帮助你评估自己的服务处于什么位置。
但更重要的是,你能区分出STW和并发标记,知道怎么看gctrace,知道堆大小和存活对象数量才是关键变量。记住:没有一套固定数字适合所有项目,还是要自己测一遍。
如果你的服务正被GC暂停困扰,不妨先按上面的方法跑一次,用数据说话。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/653/