《Go 语言运行时原理》7.1 GOGC/GOMEMLIMIT 实验矩阵

用同一段分配负载,在 GOGC=off/100/400 与 GOMEMLIMIT=1GiB 的六组组合下实测墙钟时间、GC 次数与峰值 RSS,得到「谁先触顶谁说了算」的结论,并把 gcPercentHeapGoal 与 memoryLimitHeapGoal 的公式定位到 src/runtime/mgcpacer.go,最后给出选参决策表。

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=off0.40–0.55 s01625 MB
GOGC=100(默认)2.35–3.08 s13–141045–1102 MB
GOGC=4001.09–1.13 s41134–1135 MB
GOGC=100 GOMEMLIMIT=1GiB2.13–3.08 s13–141032–1114 MB
GOGC=off GOMEMLIMIT=1GiB1.97–2.40 s2–3998–1147 MB
GOGC=400 GOMEMLIMIT=1GiB2.21–3.14 s6–71121–1163 MB

三条结论直接从表里读出来:

  1. GOGC 在无内存上限时主导一切:off 完全不 GC(0.4 s,但堆涨到 1.6 GB),400 只 GC 4 次(1.1 s),默认 100 GC 13–14 次(2.4 s)。GC 次数与墙钟时间同向变化。
  2. GOMEMLIMIT 一旦触顶就接管控制权:后三行的 GC 次数都比对应的无上限配置更高,连 GOGC=off 都被迫跑了 2–3 次 GC——上限把「完全不 GC」这条退路堵死了;但次数仍随 GOGC 变化(2–3 / 6–7 / 13–14),上限并没有把三组钉到同一个值。
  3. 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 或 400off 时 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 与延迟分析 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练