《Go 语言运行时原理》1.3 复现基线:环境与基准约定

性能数字脱离环境就没有意义。本节先采集本机基线(Go 版本、CPU 型号与核数、内存、默认 GOMAXPROCS/GOGC),再做三个对照实验:-race 的代价、GOMAXPROCS 环境变量、GOGC/GOMEMLIMIT 对 GC 次数的影响,最后固化成本卷所有实验都要遵守的复现约定。

前两节讲了「怎么观测」和「去哪读源码」。这一节把地基打牢:固定一套环境与测量约定。本卷后面每一节的实验都会引用这里的基线;如果你的机器和这里不同,数字会不同,但测量方法可以复用,性能差异的方向也需要重新验证。运行时的性能数字高度依赖版本、CPU、内存和参数,所以「先说清楚在什么机器上、用什么参数、重复几次」不是仪式,是结论成立的前提。

本节要回答:本卷的数字是在什么环境下、按什么约定测出来的? 结论分三块:本机基线是 Go 1.27.0 / M1 Pro 10 核 / 32 GiB;三个最容易踩的参数是 -race(约 6 倍开销)、GOMAXPROCS(受逻辑 CPU、亲和性及 Linux cgroup 配额影响)、GOGC/GOMEMLIMIT(直接决定 GC 次数);所有性能结论给区间不给单点。

1.3.1 实验:采集本机基线

版本与构建环境。 先固定工具链。本卷主线是 Go 1.27,所有命令都带 GOTOOLCHAIN=go1.27.0:

export GOTOOLCHAIN=go1.27.0
go version
go env GOVERSION GOOS GOARCH CGO_ENABLED
printf 'GOGC=%s GOMEMLIMIT=%s\n' "${GOGC:-未设置}" "${GOMEMLIMIT:-未设置}"
go version go1.27.0 darwin/arm64
go1.27.0
darwin
arm64
1
GOGC=未设置 GOMEMLIMIT=未设置

GOGC 与 GOMEMLIMIT 是运行时读取的环境变量,不应靠 go env 的空输出判断。未设置时,默认 GC 百分比是 100,软内存限制是 math.MaxInt64;这不是硬性保证进程可以无限用内存。程序也可能调用 debug API 修改这些值。

机器规格。 CPU 型号、逻辑核数、内存,三项缺一不可:

sysctl -n machdep.cpu.brand_string
sysctl -n hw.ncpu
sysctl -n hw.perflevel0.physicalcpu
sysctl -n hw.perflevel1.physicalcpu
echo "memsize=$(sysctl -n hw.memsize)"
Apple M1 Pro
10
8
2
memsize=34359738368

hw.ncpu=10 是逻辑核数,但它是非对称的:8 个性能核 + 2 个能效核。这一点在后面的实验里很关键——GOMAXPROCS 限制同时执行 Go 代码的并行度,不负责把 goroutine 固定到性能核或能效核;具体 CPU 调度由操作系统参与决定。比较 8 与 10 应在同一负载下重复测量,不能从核数直接推导快慢。

基准区间。 性能数字必须重复测。下面这个基准每次分配 64 个对象,-count=5:

go test -bench=BenchmarkAlloc -benchmem -count=5 .
BenchmarkAlloc-10    	 1839837	       724.4 ns/op	    1536 B/op	      65 allocs/op
BenchmarkAlloc-10    	 2120726	       598.6 ns/op	    1536 B/op	      65 allocs/op
BenchmarkAlloc-10    	 2132306	       573.1 ns/op	    1536 B/op	      65 allocs/op
BenchmarkAlloc-10    	 2088022	       713.0 ns/op	    1536 B/op	      65 allocs/op
BenchmarkAlloc-10    	 1807624	       667.3 ns/op	    1536 B/op	      65 allocs/op

写进结论时应是「573~724 ns/op」这个区间,而不是取第一次的 724.4。单点数字在 M1 这种有能效核、有热节流的机器上会骗人。

对照一个零分配的基准,可以看到区间的相对宽度并不一样:

go test -bench=BenchmarkSlice -benchmem -count=5 .
BenchmarkSlice-10    	 3020644	       389.1 ns/op	       0 B/op	       0 allocs/op
BenchmarkSlice-10    	 2788082	       455.0 ns/op	       0 B/op	       0 allocs/op
BenchmarkSlice-10    	 2649994	       409.3 ns/op	       0 B/op	       0 allocs/op
BenchmarkSlice-10    	 3045036	       401.9 ns/op	       0 B/op	       0 allocs/op
BenchmarkSlice-10    	 3096963	       396.9 ns/op	       0 B/op	       0 allocs/op

区间是 389~455 ns/op(相对宽度约 15%),比 BenchmarkAlloc 的 573~724(约 26%)更窄——可能与该基准的分配和 GC 压力较小有关;单凭这些数据不能排除调度、温度及系统负载的影响。这提示一条经验:分配越密集的基准,重复次数越要多,因为 GC 是否恰好落在测量窗口里会造成抖动。

-race 的代价。 竞态检测器会给每次内存访问插桩,代价必须量化:

go test -bench=BenchmarkAlloc -benchtime=300000x -count=3 .
go test -race -bench=BenchmarkAlloc -benchtime=300000x -count=3 .
BenchmarkAlloc-10    	  300000	       593.8 ns/op
BenchmarkAlloc-10    	  300000	       648.9 ns/op
BenchmarkAlloc-10    	  300000	       656.0 ns/op
BenchmarkAlloc-10    	  300000	      4390 ns/op
BenchmarkAlloc-10    	  300000	      4645 ns/op
BenchmarkAlloc-10    	  300000	      3158 ns/op

同一程序,无 -race 是 594656 ns/op,带 -race 是 31584645 ns/op,约 6 倍。所以绝不能用 -race 的数字做性能结论——它只用来找数据竞争。

三个参数的对照。 最后一个实验固定探针程序,只改一个环境变量:

GOMAXPROCS=4 ./demo | head -1
for g in 100 200 off; do
  n=$(GOGC=$g GODEBUG=gctrace=1 ./demo 2>&1 | grep -c '^gc ')
  echo "GOGC=$g -> gc cycles = $n"
done
GOMAXPROCS = 4 NumCPU = 10
GOGC=100 -> gc cycles = 3
GOGC=200 -> gc cycles = 1
GOGC=off -> gc cycles = 0

GOMAXPROCS=4 生效(打印 4);GOGC 从默认 100 提到 200,同样的程序 GC 次数从 3 降到 1;GOGC=off 关闭按增长百分比触发的自动 GC;有限的 GOMEMLIMIT 仍可触发 GC,显式 runtime.GC() 也仍然有效。这三个旋钮就是本卷后面调优章节的全部变量。

复现基线(本节全部实验):Go 1.27.0 darwin/arm64;Apple M1 Pro,8 性能核 + 2 能效核,10 逻辑核,32 GiB 内存;GOMAXPROCS=10(默认),GOGC=100(默认),GOMEMLIMIT 未设;基准程序关键参数见各段说明;性能数字重复 -count=3~5,给区间。

1.3.2 源码:默认值从哪来

默认值不是魔法,三个关键默认值各有明确出处。

NumCPU 的默认值。 runtime.NumCPU() 只是返回启动时缓存的一个变量:

// src/runtime/debug.go:154
func NumCPU() int {
	return int(numCPUStartup)
}

numCPUStartup 在 osinit 阶段赋值。在 macOS 上它来自 src/runtime/os_darwin.go:147 的 numCPUStartup = getCPUCount(),而 getCPUCount(同文件 :183)用 sysctl 取 hw.ncpu:

// src/runtime/os_darwin.go:183
func getCPUCount() int32 {
	// Use sysctl to fetch hw.ncpu.
	mib := [2]uint32{_CTL_HW, _HW_NCPU}
	out := uint32(0)
	nout := unsafe.Sizeof(out)
	ret := sysctl(&mib[0], 2, (*byte)(unsafe.Pointer(&out)), &nout, nil, 0)
	if ret >= 0 && int32(out) > 0 {
		return int32(out)
	}
	return 1
}

Linux 上则是 os_linux.go:101 的 getCPUCount,用 sched_getaffinity 取可用 CPU 掩码——这也是为什么容器里 NumCPU 可能大于 cgroup 限额,第 3.1 节会展开。

GOMAXPROCS 的默认值。 启动时的初始化在 src/runtime/proc.go:930 附近:

// src/runtime/proc.go:930(片段)
defaultGOMAXPROCSInit()

lock(&sched.lock)
sched.lastpoll.Store(nanotime())
var procs int32
if n, err := strconv.ParseInt(gogetenv("GOMAXPROCS"), 10, 32); err == nil && n > 0 {
	procs = int32(n)
	sched.customGOMAXPROCS = true
} else {
	procs = defaultGOMAXPROCS(numCPUStartup)
}

读法:显式设了 GOMAXPROCS 就用它并标记 customGOMAXPROCS;没设就走 defaultGOMAXPROCS。 customGOMAXPROCS 标记很关键——一旦设过,runtime 的自动调整逻辑就不会再动它(第 3.1、3.3 节会讲自动调整)。

GOGC 的默认值。 解析在 src/runtime/mgcpacer.go:1368:

// src/runtime/mgcpacer.go:1368
func readGOGC() int32 {
	p := gogetenv("GOGC")
	if p == "off" {
		return -1
	}
	if n, err := strconv.ParseInt(p, 10, 32); err == nil {
		return int32(n)
	}
	return 100
}

所以 GOGC 未设时返回 100;off 返回 -1(关闭按百分比触发的自动 GC,保留内存限制与显式 GC)。heapMinimum 的默认值在 mgcpacer.go:58(defaultHeapMinimum),GOGC==100 时的小堆下限就是它。这也解释了 1.3.1 里 GOGC=off 时 gctrace 一行都没有。

还有个小陷阱:runtime.GOMAXPROCS(0) 是查询(不改值),返回当前值;runtime.GOMAXPROCS(n)(n>0)是设置,返回旧值。探针程序里用 GOMAXPROCS(0) 打印,不会干扰测量。

1.3.3 决策:本卷的复现约定

把上面的实验固化成一份约定,本卷后续每一节都遵守:

约定项取值/做法为什么
工具链所有命令带 GOTOOLCHAIN=go1.27.0保证是 1.27,不是机器上的 local
机器信息每次实验标注 CPU 型号、逻辑核数、内存数字脱离环境无意义
性能数字-count≥5 或手动 3 次,给区间单点在 M1 上不可信
-race只用于找竞争,不做性能结论约 6 倍开销
GOMAXPROCS显式记录实际值与 GODEBUG/语言版本;Linux 还要记录 cgroup 配额默认值并不总等于逻辑核数
GOGC/GOMEMLIMIT显式标注环境变量与 debug API 调整;未设=100/MaxInt64影响 GC 目标,不保证固定 GC 次数
基准程序参数元素数、并发数、迭代数写清楚便于他人复现
实验产物ssa.html/trace.out/*.pprof 即产即清不能留在仓库

还有一条约定值得单列:测量前先确认机器空载。同一台 M1 Pro 上,只要还有别的重负载在跑,调度类事件计数(如 trace 里的 GoStart/GoStop)会成倍变化,基准的 ns/op 上界也会被抬高。判断办法是对比同一组 -count=5 的首尾数值:若极差超过中位数的 20%,这批数字只能标注为「原稿记录」,不能当作可比基线。

四条最容易犯的错,单独列出来:

  1. 把「空」当成「0」。 应直接读取 GOGC 环境变量;未设置时默认 100。GOGC=0 把按比例计算的额外增长空间降到 0,但仍受最小堆目标与 pacer 控制,不表示每次分配后都执行一次 GC。
  2. 把并行度当成绑核。 GOMAXPROCS=8 不代表只占性能核。记录实际并行度、系统负载和电源状态,重复测量后再判断哪种设置适合该负载。
  3. 忘记标注 -race。 一个 6 倍的开销,如果不标注,读者会以为你的代码真的慢。
  4. 用 time.Now() 手测代替 go test -bench。 前者不处理热身、不固定迭代、受编译器优化影响,噪声远大于信号。

最后一句给读者:你在自己机器上复现时,不必追求和本节相同的数字,但要追求相同的「测量纪律」——同一工具链、同一程序参数、重复多次、给区间。本卷后面所有决策表的前提,都是这份纪律。

如果你要在自己的机器上复现本节,按这六步走:

  1. export GOTOOLCHAIN=go1.27.0 && go version 确认版本;
  2. go env GOVERSION GOOS GOARCH 记录工具链与平台,并单独记录 GOGC、GOMEMLIMIT 环境变量与运行时实际值;
  3. sysctl -n machdep.cpu.brand_string、sysctl -n hw.ncpu、echo $(( $(sysctl -n hw.memsize) / 1073741824 ))GiB 记下机器规格;
  4. 把探针程序复制到本地,go build 一次;
  5. 每个基准跑 -count=5,把五次数值记成区间;
  6. 若要用 -race,单独跑一组并明确标注,不与无 race 的数字混用。

阅读导航:上一节:1.2 源码地图与阅读路线 · 下一节:2.1 G/M/P 结构与状态机 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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