bigywater.tech
Go · 50

GC 触发与 GOGC:堆增长到什么程度会触发下一轮

“堆长到多大才触发下一轮 GC”由 GOGC 控制,但真实行为比一个公式复杂:存活堆怎么算、标记期间分配的对象算不算、最小堆多大、分配太快时会发生什么。这篇用 runtime/metrics 与 gctrace 逐项验证,并量出 GOGC 在内存与吞吐之间的取舍。

≤ 1.5 MB
刚完成一次 GC 时,堆目标与 存活堆 × (1 + GOGC/100) 的差
313 → 32
GOGC 从 25 提高到 800,2 秒内 GC 的次数
2218 ms
8 个分配 goroutine 在存活堆 200 MB 下花在 mark assist 上的时间

目标怎么来的

实测 · 本机执行输出
  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 的取舍

实测 · 本机执行输出
  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 357

GOGC=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 的做法,本文没有试压舱石)。

mark assist

实测 · 本机执行输出
  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 才是“分配太快”最直接的信号。

分步演示

GC 触发与 GOGC
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。存活堆是 N 个 56 字节节点串成的图(runtime.GC() 后 50.23 MB);分配 goroutine 不停地 new 小对象并丢弃;堆目标、存活堆来自 runtime/metrics,GC 次数与 assist 来自 GODEBUG=gctrace=1 的逐行解析(把每行 CPU 时间各列相加,含空闲 P 上的标记 worker,所以合计可以超过墙钟时间);每个设置只跑一次
    • 没有验证:栈与全局变量对目标的贡献(程序里它们很小)、GOMEMLIMIT 与 GOGC 的交互(见下一篇)、漂浮垃圾对目标的影响的隔离实验、多次重复运行的离散度、amd64 上的数字、sync.Pool 与 GC 的关系
    京ICP备2026067502号-1京公网安备11010502063487号