前两节讲了「怎么观测」和「去哪读源码」。这一节把地基打牢:固定一套环境与测量约定。本卷后面每一节的实验都会引用这里的基线;如果你的机器和这里不同,数字会不同,但测量方法可以复用,性能差异的方向也需要重新验证。运行时的性能数字高度依赖版本、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,带 4645 ns/op,约 6 倍。所以绝不能用 -race 是 3158-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%,这批数字只能标注为「原稿记录」,不能当作可比基线。
四条最容易犯的错,单独列出来:
- 把「空」当成「0」。 应直接读取
GOGC环境变量;未设置时默认 100。GOGC=0把按比例计算的额外增长空间降到 0,但仍受最小堆目标与 pacer 控制,不表示每次分配后都执行一次 GC。 - 把并行度当成绑核。
GOMAXPROCS=8不代表只占性能核。记录实际并行度、系统负载和电源状态,重复测量后再判断哪种设置适合该负载。 - 忘记标注
-race。 一个 6 倍的开销,如果不标注,读者会以为你的代码真的慢。 - 用
time.Now()手测代替go test -bench。 前者不处理热身、不固定迭代、受编译器优化影响,噪声远大于信号。
最后一句给读者:你在自己机器上复现时,不必追求和本节相同的数字,但要追求相同的「测量纪律」——同一工具链、同一程序参数、重复多次、给区间。本卷后面所有决策表的前提,都是这份纪律。
如果你要在自己的机器上复现本节,按这六步走:
export GOTOOLCHAIN=go1.27.0 && go version确认版本;go env GOVERSION GOOS GOARCH记录工具链与平台,并单独记录GOGC、GOMEMLIMIT环境变量与运行时实际值;sysctl -n machdep.cpu.brand_string、sysctl -n hw.ncpu、echo $(( $(sysctl -n hw.memsize) / 1073741824 ))GiB记下机器规格;- 把探针程序复制到本地,
go build一次; - 每个基准跑
-count=5,把五次数值记成区间; - 若要用
-race,单独跑一组并明确标注,不与无 race 的数字混用。
阅读导航:上一节:1.2 源码地图与阅读路线 · 下一节:2.1 G/M/P 结构与状态机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。