7.1 GOGC/GOMEMLIMIT 实验矩阵
上一节我们看完了 Green Tea GC 在小对象密集负载下的新旧差异,这一节换一个角度:把同一段程序丢进不同的 GOGC / GOMEMLIMIT 组合里,看两个旋钮各自把哪些指标推动了。
本节要回答的问题是:当
GOGC和GOMEMLIMIT同时设置时,到底谁决定 GC 什么时候启动? 结论是:在本组高存活集负载中,内存限制计算的目标成为较紧的约束——六组配置里凡是带上GOMEMLIMIT=1GiB的,GC 次数都比对应的无上限配置更高(off从 0 升到 2–3 次,400从 4 升到 6–7 次,100维持 13–14 次),但次数仍随GOGC变化,并没有收敛到同一个值。本节的增量是六组配置的真实数据矩阵;至于「GOGC设多少合适」的取值建议属于卷三《Go 语言高级编程》6.2 的内容,本节不重复,只补一张可复现的对照表。
7.1.1 实验:六组配置的真实数据
复现基线
- Go 版本:
go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0) - 机器:Apple M1 Pro,10 核,32 GiB 内存(
sysctl -n machdep.cpu.brand_string/hw.ncpu/hw.memsize) GOMAXPROCS:默认 10;CGO_ENABLED=1- 负载程序参数:
n=20000000,每个 node 大小为 72 字节,在本机落入 80 字节分配 size class,隔一个丢弃,最终存活约 800 MB - 重复次数:墙钟时间与 GC 次数取 5 次,峰值 RSS 取 3 次,均给区间
被测程序把「分配—丢弃」的节奏写死,只让 GC 参数变化,从而隔离出参数的净效应。
package main
import (
"fmt"
"os"
"runtime"
"strconv"
"time"
)
// node 是 72 字节(指针 8 + 数组 64),落在 80 字节 size class。
type node struct {
next *node
buf [64]byte
}
func main() {
n := 2000000
if len(os.Args) > 1 {
n, _ = strconv.Atoi(os.Args[1])
}
start := time.Now()
var head *node
for i := 0; i < n; i++ {
p := &node{}
p.buf[0] = byte(i)
p.next = head
head = p
if i%2 == 1 {
head = head.next // 丢掉刚分配的这个,产生垃圾
}
}
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
fmt.Printf("n=%d wall=%v numGC=%d pauseTotalMs=%.2f heapAllocMB=%.1f heapSysMB=%.1f totalAllocMB=%.1f\n",
n, time.Since(start).Round(time.Millisecond), ms.NumGC,
float64(ms.PauseTotalNs)/1e6, float64(ms.HeapAlloc)/1e6,
float64(ms.HeapSys)/1e6, float64(ms.TotalAlloc)/1e6)
runtime.KeepAlive(head) // 确保链表在 ReadMemStats 及输出期间仍然存活
}
编译与运行命令如下,六个配置逐个跑:
cd /tmp/gbrt3/gcmatrix
GOTOOLCHAIN=go1.27.0 go build -o gcmatrix .
for cfg in "GOGC=off" "GOGC=100" "GOGC=400" \
"GOGC=100 GOMEMLIMIT=1GiB" "GOGC=off GOMEMLIMIT=1GiB" "GOGC=400 GOMEMLIMIT=1GiB"; do
echo "== $cfg =="
for i in 1 2 3 4 5; do env $cfg ./gcmatrix 20000000; done
done
下面是原稿记录的三个代表性配置回显(本轮尚未按原规模复测)(完整六组见后面的汇总表):
== GOGC=off ==
n=20000000 wall=553ms numGC=0 pauseTotalMs=0.00 heapAllocMB=1600.2 heapSysMB=1660.7 totalAllocMB=1600.2
n=20000000 wall=403ms numGC=0 pauseTotalMs=0.00 heapAllocMB=1600.2 heapSysMB=1660.7 totalAllocMB=1600.2
n=20000000 wall=400ms numGC=0 pauseTotalMs=0.00 heapAllocMB=1600.2 heapSysMB=1660.6 totalAllocMB=1600.2
n=20000000 wall=404ms numGC=0 pauseTotalMs=0.00 heapAllocMB=1600.2 heapSysMB=1660.7 totalAllocMB=1600.2
n=20000000 wall=399ms numGC=0 pauseTotalMs=0.00 heapAllocMB=1600.2 heapSysMB=1660.7 totalAllocMB=1600.2
== GOGC=100 ==
n=20000000 wall=3.081s numGC=14 pauseTotalMs=0.83 heapAllocMB=937.9 heapSysMB=1102.7 totalAllocMB=1600.2
n=20000000 wall=2.619s numGC=13 pauseTotalMs=0.70 heapAllocMB=1062.0 heapSysMB=1220.1 totalAllocMB=1600.2
n=20000000 wall=2.758s numGC=14 pauseTotalMs=4.22 heapAllocMB=955.3 heapSysMB=1052.4 totalAllocMB=1600.2
n=20000000 wall=2.351s numGC=13 pauseTotalMs=0.53 heapAllocMB=944.5 heapSysMB=1228.6 totalAllocMB=1600.2
n=20000000 wall=2.661s numGC=14 pauseTotalMs=0.56 heapAllocMB=1049.3 heapSysMB=1090.2 totalAllocMB=1600.2
== GOGC=400 ==
n=20000000 wall=1.114s numGC=4 pauseTotalMs=0.17 heapAllocMB=1121.8 heapSysMB=1165.7 totalAllocMB=1600.2
n=20000000 wall=1.128s numGC=4 pauseTotalMs=0.20 heapAllocMB=1117.2 heapSysMB=1161.5 totalAllocMB=1600.2
n=20000000 wall=1.108s numGC=4 pauseTotalMs=0.25 heapAllocMB=1121.0 heapSysMB=1165.7 totalAllocMB=1600.2
n=20000000 wall=1.091s numGC=4 pauseTotalMs=0.24 heapAllocMB=1121.3 heapSysMB=1165.7 totalAllocMB=1600.2
n=20000000 wall=1.096s numGC=4 pauseTotalMs=0.21 heapAllocMB=1119.4 heapSysMB=1161.5 totalAllocMB=1600.2
峰值 RSS 用 /usr/bin/time -l 采集(macOS 的 maximum resident set size 字段):
for cfg in "GOGC=off" "GOGC=100" "GOGC=400" \
"GOGC=100 GOMEMLIMIT=1GiB" "GOGC=off GOMEMLIMIT=1GiB" "GOGC=400 GOMEMLIMIT=1GiB"; do
echo "== $cfg =="
for i in 1 2 3; do
env $cfg /usr/bin/time -l ./gcmatrix 20000000 2>&1 | grep "maximum resident" \
| awk '{printf "%.0f MB\n", $1/1048576}'
done
done
汇总成矩阵(墙钟 5 次、RSS 3 次):
| 配置 | 墙钟时间 | GC 次数 | 峰值 RSS |
|---|---|---|---|
GOGC=off | 0.40–0.55 s | 0 | 1625 MB |
GOGC=100(默认) | 2.35–3.08 s | 13–14 | 1045–1102 MB |
GOGC=400 | 1.09–1.13 s | 4 | 1134–1135 MB |
GOGC=100 GOMEMLIMIT=1GiB | 2.13–3.08 s | 13–14 | 1032–1114 MB |
GOGC=off GOMEMLIMIT=1GiB | 1.97–2.40 s | 2–3 | 998–1147 MB |
GOGC=400 GOMEMLIMIT=1GiB | 2.21–3.14 s | 6–7 | 1121–1163 MB |
三条结论直接从表里读出来:
GOGC在无内存上限时主导一切:off完全不 GC(0.4 s,但堆涨到 1.6 GB),400只 GC 4 次(1.1 s),默认100GC 13–14 次(2.4 s)。GC 次数与墙钟时间同向变化。GOMEMLIMIT一旦触顶就接管控制权:后三行的 GC 次数都比对应的无上限配置更高,连GOGC=off都被迫跑了 2–3 次 GC——上限把「完全不 GC」这条退路堵死了;但次数仍随GOGC变化(2–3 / 6–7 / 13–14),上限并没有把三组钉到同一个值。GOMEMLIMIT不是免费的:它把 GC 次数整体抬高(off从 0 抬到 2–3、400从 4 抬到 6–7),墙钟时间反而比GOGC=400单用更慢(2.2–3.1 s vs 1.1 s),峰值 RSS 却没降多少(1121–1163 MB vs 1134 MB),因为存活集本身就有 800 MB,上限压不到它下面。
7.1.2 源码:goal 是怎么算出来的
现象是「谁先触顶谁说了算」,实现就在 src/runtime/mgcpacer.go。先看 GOGC 一侧的 goal 公式,它在 gcControllerState.commit 里计算:
// src/runtime/mgcpacer.go:commit(节选)
// Compute the next GC goal, which is when the allocated heap
// has grown by GOGC/100 over where it started the last cycle,
// plus additional runway for non-heap sources of GC work.
gcPercentHeapGoal := ^uint64(0)
if gcPercent := c.gcPercent.Load(); gcPercent >= 0 {
gcPercentHeapGoal = c.heapMarked + (c.heapMarked+c.lastStackScan.Load()+c.globalsScan.Load())*uint64(gcPercent)/100
}
即 goal = 存活堆 × (1 + GOGC/100) + 非堆扫描量 × GOGC/100。这解释了为什么 GOGC=400 时目标被推得很远、GC 次数骤降。
再看两个 goal 谁说了算,在 heapGoalInternal 里就是一句 min:
// src/runtime/mgcpacer.go:heapGoalInternal(节选)
// Start with the goal calculated for gcPercent.
goal = c.gcPercentHeapGoal.Load()
// Check if the memory-limit-based goal is smaller, and if so, pick that.
if newGoal := c.memoryLimitHeapGoal(); newGoal < goal {
goal = newGoal
} else {
// 只有在不受内存上限约束时,才做 sweep distance、minRunway 等修正
...
}
这正是实验里「GOMEMLIMIT 接管」的源码证据:只要 memoryLimitHeapGoal() < gcPercentHeapGoal,就走内存上限分支,GOGC 算出来的目标被直接丢弃。
内存上限还会连带影响清扫(scavenger)的节奏——它决定「归还给操作系统的内存」跑多快。这个联动在 src/runtime/mgcscavenge.go 的 gcPaceScavenger:
// src/runtime/mgcscavenge.go:gcPaceScavenger(节选)
func gcPaceScavenger(memoryLimit int64, heapGoal, lastHeapGoal uint64) {
...
// Compute the amount of memory we're allowed to reclaim.
...
}
也就是说,GOMEMLIMIT 不是「超了就 OOM」的硬墙,而是一条软上限:运行时通过提前 GC 与更激进的清扫去逼近它,代价就是实验里看到的墙钟时间上升。真正的硬墙仍然由操作系统或容器的 cgroup 限制提供。
memoryLimitHeapGoal 从当前映射内存里扣掉「已经算好但还没用完的预留」,得到还能用的余量:
// src/runtime/mgcpacer.go:memoryLimitHeapGoal(节选)
func (c *gcControllerState) memoryLimitHeapGoal() uint64 {
// Start by pulling out some values we'll need. Be careful about overflow.
var heapFree, heapAlloc, mappedReady uint64
for {
heapFree = c.heapFree.load() // Free and unscavenged memory.
...
}
}
GC 的触发判断入口在 src/runtime/mgc.go 的 gcTrigger.test,它同时检查 GOGC 目标与内存上限目标:
// src/runtime/mgc.go:gcTrigger.test(节选)
func (t gcTrigger) test() bool {
...
case gcTriggerHeap:
// Non-atomic access to gcController.heapLive still happens during
// the mark phase. We are at a safepoint, so we know it's
// not going to change underneath us.
trigger, _ := gcController.trigger()
return gcController.heapLive.Load() >= trigger
...
}
}
读源码时记住一条:
gcController.trigger()内部会调用heapGoalInternal(),所以「谁说了算」这件事最终收敛到那一个min比较。
7.1.3 决策:矩阵怎么变成参数选择
由这张表可以直接推出一张选择表。核心判据是内存与延迟哪个是当前瓶颈:
| 场景 | 推荐 | 理由(来自实测) |
|---|---|---|
| 内存充足,追求吞吐(批处理、离线任务) | GOGC=off 或 400 | off 时 0 次 GC、墙钟最短;400 时 4 次 GC、仍比默认快一倍 |
| 线上服务,P99 延迟敏感 | GOGC=100 起调,配合 GOMEMLIMIT 兜底 | 默认 13 次 GC,暂停总量仍在亚毫秒级;不要为了省内存把上限压到存活集附近 |
| 容器有硬内存限额 | 设 GOMEMLIMIT=限额×0.9 | 先判断内存限制是否真的约束目标;未约束时 GOGC 仍有调优空间 |
| 存活集接近限额 | 先降存活集,再调参 | 实验中存活 800 MB、限额 1 GiB,压不出效果还拖慢 2 倍 |
三条检查清单:
- 本实验中内存限制计算的目标较紧时,
GOGC仍会影响 GC 次数(2–3 / 6–7 / 13–14);仅设置了GOMEMLIMIT并不意味着GOGC可以忽略,存活集接近上限时应把注意力放在降低存活集(见 7.3)而不是继续拧GOGC。 - 本实验的存活集约 800 MB,1 GiB 为约 1074 MB,限制与存活集的比值约 1.34;还需扣除其他 runtime 开销。不能把某个比值阈值当成普遍的 thrashing 判据,应同时观测 GC CPU、吞吐与实际存活量。
- 判断某个参数是否真的生效,用
GODEBUG=gctrace=1看 GC 次数与 goal 列,不要只看runtime.ReadMemStats的瞬时值。
最后补一句方法上的提醒:本节的结论「GOMEMLIMIT 接管」是在存活集占限额比例很高(约 0.8)时成立的。如果你的服务存活集只占限额的三成,GOMEMLIMIT 通常碰不到,GOGC 仍然是主控旋钮——这也是为什么必须先用本节的方法测一次,而不是照抄别人的参数。
阅读导航:上一节:6.3 Green Tea GC 实测对比 · 下一节:7.2 GC trace 与延迟分析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。