“堆长到多大才触发下一轮 GC”由 GOGC 控制,但真实行为比一个公式复杂:存活堆怎么算、标记期间分配的对象算不算、最小堆多大、分配太快时会发生什么。这篇用 runtime/metrics 与 gctrace 逐项验证,并量出 GOGC 在内存与吞吐之间的取舍。
GOGC= 25:gogc=25→25 live_MB=50.23 goal_MB=62.8→68.1 cycles=299 ops_per_s=43683088 heapsys_MB=79
GOGC= 50:gogc=50→50 live_MB=50.23 goal_MB=75.4→88.3 cycles=232 ops_per_s=72696074 heapsys_MB=95
GOGC=100:gogc=100→100 live_MB=50.23 goal_MB=100.7→142.0 cycles=145 ops_per_s=108430798 heapsys_MB=155
GOGC=200:gogc=200→200 live_MB=50.23 goal_MB=151.1→211.6 cycles=92 ops_per_s=138022056 heapsys_MB=271
GOGC=400:gogc=400→400 live_MB=50.23 goal_MB=251.9→368.9 cycles=61 ops_per_s=163105592 heapsys_MB=423
GOGC=800:gogc=800→800 live_MB=50.23 goal_MB=453.6→742.2 cycles=30 ops_per_s=177155362 heapsys_MB=875
对照:运行开始时(刚做完一次 GC)的堆目标 = 存活堆 × (1 + GOGC/100)(栈与全局变量在这个程序里不到 1 MB,忽略):
GOGC= 25:存活堆 50.23 MB;开始时的目标 62.8 MB;按 50.23 × (1 + 25/100) = 62.8 MB 预测,差 +0.0 MB
GOGC= 50:存活堆 50.23 MB;开始时的目标 75.4 MB;按 50.23 × (1 + 50/100) = 75.3 MB 预测,差 +0.1 MB
GOGC=100:存活堆 50.23 MB;开始时的目标 100.7 MB;按 50.23 × (1 + 100/100) = 100.5 MB 预测,差 +0.2 MB
GOGC=200:存活堆 50.23 MB;开始时的目标 151.1 MB;按 50.23 × (1 + 200/100) = 150.7 MB 预测,差 +0.4 MB
GOGC=400:存活堆 50.23 MB;开始时的目标 251.9 MB;按 50.23 × (1 + 400/100) = 251.1 MB 预测,差 +0.8 MB
GOGC=800:存活堆 50.23 MB;开始时的目标 453.6 MB;按 50.23 × (1 + 800/100) = 452.1 MB 预测,差 +1.5 MB
运行结束时读到的目标比开始时大,和分配速度有关:同样的 GOGC=100,不同的分配 goroutine 数,结束时的目标:
1 个分配 goroutine:目标 100.7→118.3(MB,开始→结束);吞吐 81.7 M 次/秒
2 个分配 goroutine:目标 100.7→140.3(MB,开始→结束);吞吐 104.0 M 次/秒
4 个分配 goroutine:目标 100.7→141.8(MB,开始→结束);吞吐 109.5 M 次/秒用 runtime/metrics 读到 /gc/heap/live:bytes(上一轮标记结束时的存活堆)和 /gc/heap/goal:bytes,两者在 6 种 GOGC 下都符合 goal ≈ live × (1 + GOGC/100)(这个程序里栈与全局变量很小,公式里它们的项可以忽略)。运行中读到的目标会更大,并且和分配速度正相关——源码里说标记阶段新分配的对象直接标成黑色,所以分配得快时这部分“漂浮垃圾”被算进了存活堆。这是我对现象的解释,没有单独隔离验证。
GOGC= 25:GC 313 次;GC CPU 合计 10291 ms(其中 assist 3560 ms);峰值堆(gctrace 第一/二个数)约 69 MB;吞吐 44.5 M 次分配/秒 GOGC= 50:GC 242 次;GC CPU 合计 8401 ms(其中 assist 2359 ms);峰值堆(gctrace 第一/二个数)约 89 MB;吞吐 73.9 M 次分配/秒 GOGC=100:GC 151 次;GC CPU 合计 5816 ms(其中 assist 755 ms);峰值堆(gctrace 第一/二个数)约 144 MB;吞吐 110.0 M 次分配/秒 GOGC=200:GC 95 次;GC CPU 合计 3796 ms(其中 assist 65 ms);峰值堆(gctrace 第一/二个数)约 261 MB;吞吐 140.5 M 次分配/秒 GOGC=400:GC 59 次;GC CPU 合计 2381 ms(其中 assist 38 ms);峰值堆(gctrace 第一/二个数)约 481 MB;吞吐 161.2 M 次分配/秒 GOGC=800:GC 32 次;GC CPU 合计 1331 ms(其中 assist 26 ms);峰值堆(gctrace 第一/二个数)约 912 MB;吞吐 177.7 M 次分配/秒
GOGC 越大,GC 越稀,mutator 被 assist 打扰越少、吞吐越高,但峰值堆越大。这个分配密集的负载里,吞吐从 44.5 涨到 177.7 M 次/秒,峰值堆从 69 涨到 912 MB。没有“最好”的值,取决于你愿意用多少内存换多少吞吐;容器里还要结合内存上限(见下一篇 GOMEMLIMIT)。
GOGC=off,运行 1 秒:gogc=18446744073709551615→18446744073709551615 live_MB=50.23 goal_MB=8532210231533.0→8532210231174.2 cycles=0 ops_per_s=124456899 heapsys_MB=5952;gctrace 行数 1(没有任何一次由分配触发的 GC)
运行中途 debug.SetGCPercent(400):gogc=100→400 live_MB=50.23 goal_MB=100.7→371.9 cycles=101 ops_per_s=134011249 heapsys_MB=391;SetGCPercent(400) 返回旧值 100
gctrace 里的 “MB goal” 序列(前半 GOGC=100,后半 400):7 17 32 63 100 119 … 370 342 400 361 369 357GOGC=off 之后分配不再触发 GC,堆线性增长(1 秒涨到近 6 GB,这个实验只能跑 1 秒)。debug.SetGCPercent 可以在运行时改,返回旧值,从下一轮开始生效。
gogc=100→100 live_MB=1.22 goal_MB=4.0→4.1 cycles=1306 ops_per_s=61965476 heapsys_MB=11
前 3 行 gctrace:
gc 1 @0.000s 6%: 0.007+0.27+0.008 ms clock, 0.072+0/0.38/0.28+0.084 ms cpu, 1->1->1 MB, 4 MB goal, 0 MB stacks, 0 MB glo
gc 2 @0.001s 7%: 0.047+0.40+0.005 ms clock, 0.47+0.048/0.46/0.017+0.050 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB
gc 3 @0.001s 8%: 0.061+0.43+0.003 ms clock, 0.61+0.060/0.48/0.003+0.035 ms cpu, 3->3->1 MB, 4 MB goal, 0 MB stacks, 0 MB存活堆很小时,目标不会低于 4 MB,所以一个只有 1 MB 存活数据、高速分配的程序每秒会触发上千次 GC。这是 GC 频率与存活堆大小脱钩的情形,可以用 GOGC 调大或者给一块“压舱石”内存来减少 GC(后面一篇用 GOMEMLIMIT 的做法,本文没有试压舱石)。
1 个分配 goroutine:GC 42 次;GC CPU 5688 ms,其中 assist 6 ms;吞吐 79.8 M 次/秒 4 个分配 goroutine:GC 43 次;GC CPU 5881 ms,其中 assist 338 ms;吞吐 110.4 M 次/秒 8 个分配 goroutine:GC 44 次;GC CPU 5384 ms,其中 assist 2218 ms;吞吐 114.4 M 次/秒
如果分配速度超过后台标记的速度,分配的 goroutine 会被要求先帮忙标记(assist)再继续分配,表现为 gctrace 里 CPU 时间第一段非零。实验里 GC 次数几乎不变,但 assist 从 6 ms 涨到 2218 ms,说明 assist 才是“分配太快”最直接的信号。