一、基准测试回答的是“哪个实现更值”
性能优化最容易犯的错误,是凭直觉改代码,再凭一次运行下结论。基准测试的价值不是给出一个漂亮的数字,而是把“哪个实现更值”变成可重复、可比较、可证伪的实验。游戏服务端尤其需要这种纪律:同一个战斗结算函数,在不同并发度、不同分配模式下,瓶颈可能完全不同。
| 案例 | 朴素实现 | 优化实现 | 主要观测指标 |
|---|---|---|---|
| 锁竞争 | sync.Mutex 保护的计数器 | atomic.Int64 计数器 | ns/op、并行度 |
| 切片增长 | 不预分配,反复 append | make([]int, 0, n) | ns/op、B/op、allocs/op |
| 字符串拼接 | += 逐段拼接 | strings.Builder 加 Grow | ns/op、B/op、allocs/op |
三个案例分别代表同步原语、内存分配和字符串处理。它们足够小,可以在几十行内复现;又足够典型,能覆盖游戏服务端最常见的性能改写。
二、基准函数的正确写法
一个可靠的基准函数要满足四件事:只测目标逻辑、固定输入、防止编译器把结果优化掉、在需要时报告分配。b.N 由测试框架动态调整,不能假设它是某个固定值;循环体里不要做与被测逻辑无关的初始化。
b.ResetTimer():在准备完输入后调用,把初始化时间排除在计时之外。b.ReportAllocs():显式要求报告分配;命令行加-benchmem也能达到同样效果。b.RunParallel():并行基准必须用它,让多个 goroutine 共同消耗b.N。- 全局 sink:把结果赋给包级变量,防止编译器判定结果未被使用而删掉整个调用。
b.N只读:不要自己改写b.N,也不要假设它等于并发数。- 子基准:用
b.Run命名不同输入规模,便于 benchstat 按名称对齐。
b.RunParallel 的闭包里每次调用 pb.Next() 领取一次迭代。计数器案例如果不使用并行执行,锁竞争根本不会出现,测出来的只是无竞争路径。切片和字符串案例则是单线程基准,重点在分配次数,不需要并行。
三、对照实验怎么设计
对照实验要控制变量:同一份输入、同一台机器、同一个 Go 版本、同样的后台负载。每次只改一个实现,其他条件保持不变。至少运行三次,观察离散程度;只跑一次的数字无法区分真实差异和运行时噪声。
cd .local/checks/gs-practice-lab
go test ./ch13/bench/ -bench . -benchmem -count=3 | tee ch13/bench/bench.txt
go install golang.org/x/perf/cmd/benchstat@latest
benchstat ch13/bench/bench.txt
benchstat 会给出每组的中位数、最小最大值、变化百分比和 p 值。p 值很小说明差异不太可能由噪声造成,但它不证明差异在业务上有意义;还要看效应量和分配次数。比较两个版本时,把旧实现和新实现的输出分别保存为 old.txt、new.txt,再执行 benchstat old.txt new.txt,比在终端里肉眼比对可靠得多。
机器负载也要记录。后台跑着构建、容器或视频会议时,ns/op 的绝对值会整体抬高;这时比较同一文件内部两组实现的相对差异仍然有意义,但不能拿这次数字去和另一台机器上的历史数字比较。
四、三个案例的结果
4.1 锁竞争:Mutex 与 Atomic
mutexCounter 用 sync.Mutex 保护一个 int64,atomicCounter 用 atomic.Int64。两者都通过 b.RunParallel 接受并发递增。Mutex 版本在竞争激烈时会让 goroutine 排队、睡眠和唤醒;Atomic 版本由硬件原子指令完成,失败时自旋重试。Atomic 并不是“无竞争”,只是把排队从操作系统调度器挪到了 CPU 缓存一致性协议里。
| 实现 | 三次 ns/op | 中位数 | B/op | allocs/op |
|---|---|---|---|---|
| CounterMutex | 131.6 / 136.3 / 136.0 | 136.0 | 0 | 0 |
| CounterAtomic | 74.42 / 72.00 / 72.45 | 72.45 | 0 | 0 |
在这台 10 核 M1 Pro 上,Atomic 的中位数约为 Mutex 的 53%,也就是快了约 1.88 倍。这个倍数只代表当前并发度和当前实现,不代表所有计数器都该换成原子操作;如果临界区里还有别的字段要一起更新,单独换计数器会破坏一致性。
4.2 切片预分配:增长策略的代价
appendNoPrealloc 从 nil 切片开始 append 256 个整数,appendPrealloc 先分配容量 256。前者会经历多次扩容,每次扩容都要分配新数组并复制旧元素;后者只分配一次。最终长度相同,但分配次数和总字节数不同。
| 实现 | 三次 ns/op | 中位数 | B/op | allocs/op |
|---|---|---|---|---|
| SliceNoPrealloc | 710.2 / 681.1 / 780.3 | 710.2 | 4088 | 9 |
| SlicePrealloc | 364.8 / 379.4 / 362.4 | 364.8 | 2048 | 1 |
预分配版本中位数约快 1.95 倍,分配字节从 4088 降到 2048,分配次数从 9 降到 1。注意预分配并不总是更快:如果最终长度远小于容量,多出来的内存会被浪费;如果切片很快被丢弃,预分配反而增加单次分配大小。它适合“长度可预估且对象会存活一段时间”的场景。
4.3 字符串拼接:+= 的平方级复制
concatPlus 用 out += part 逐段拼接,concatBuilder 先用 Grow 预留总长度,再 WriteString。前者每拼一次都可能分配新字符串并复制已有内容,后者在容量足够时只做追加,最后 String() 不复制底层字节。
| 实现 | 三次 ns/op | 中位数 | B/op | allocs/op |
|---|---|---|---|---|
| StringPlus | 94.94 / 93.38 / 91.37 | 93.38 | 56 | 4 |
| StringBuilder | 36.85 / 36.49 / 35.83 | 36.49 | 24 | 1 |
Builder 版本中位数约快 2.56 倍,分配从 4 次降到 1 次。这个案例只有 5 段、总长 17 字节,差距已经很明显;段数越多,+= 的复制量增长越快。游戏服务端拼接日志、协议键、房间标识时,应优先使用 Builder 或字节缓冲区。
五、真实运行环境与完整输出
以下数字来自本机一次真实运行,不是示例值。环境为 Apple M1 Pro、10 核、32 GiB 内存、macOS Darwin 25.3.0;Go 版本为 go1.26.0 darwin/arm64,基准后缀 -10 表示 GOMAXPROCS=10。运行期间没有故意制造额外负载,但也没有做 CPU 隔离或绑核。
命令:
go test ./ch13/bench/ -bench . -benchmem -count=3
完整输出:
goos: darwin
goarch: arm64
pkg: gspractice/ch13/bench
cpu: Apple M1 Pro
BenchmarkCounterMutex-10 9361278 131.6 ns/op 0 B/op 0 allocs/op
BenchmarkCounterMutex-10 8986657 136.3 ns/op 0 B/op 0 allocs/op
BenchmarkCounterMutex-10 8653531 136.0 ns/op 0 B/op 0 allocs/op
BenchmarkCounterAtomic-10 16943727 74.42 ns/op 0 B/op 0 allocs/op
BenchmarkCounterAtomic-10 16250848 72.00 ns/op 0 B/op 0 allocs/op
BenchmarkCounterAtomic-10 16125392 72.45 ns/op 0 B/op 0 allocs/op
BenchmarkSliceNoPrealloc-10 1500114 710.2 ns/op 4088 B/op 9 allocs/op
BenchmarkSliceNoPrealloc-10 1742433 681.1 ns/op 4088 B/op 9 allocs/op
BenchmarkSliceNoPrealloc-10 1583337 780.3 ns/op 4088 B/op 9 allocs/op
BenchmarkSlicePrealloc-10 3251721 364.8 ns/op 2048 B/op 1 allocs/op
BenchmarkSlicePrealloc-10 3517474 379.4 ns/op 2048 B/op 1 allocs/op
BenchmarkSlicePrealloc-10 3354786 362.4 ns/op 2048 B/op 1 allocs/op
BenchmarkStringPlus-10 12664606 94.94 ns/op 56 B/op 4 allocs/op
BenchmarkStringPlus-10 12718320 93.38 ns/op 56 B/op 4 allocs/op
BenchmarkStringPlus-10 12968270 91.37 ns/op 56 B/op 4 allocs/op
BenchmarkStringBuilder-10 33075049 36.85 ns/op 24 B/op 1 allocs/op
BenchmarkStringBuilder-10 33112924 36.49 ns/op 24 B/op 1 allocs/op
BenchmarkStringBuilder-10 33027256 35.83 ns/op 24 B/op 1 allocs/op
PASS
ok gspractice/ch13/bench 26.279s
三次运行的离散程度不大,但并非完全一致:SliceNoPrealloc 最低 681.1、最高 780.3,相差约 14.6%。这种波动足以让“只跑一次”的比较得出错误结论。基准测试报告的是统计结果,不是物理常数。
5.1 一次失败场景:单次运行掩盖了真实差异
某团队把日志拼接从 += 改成 strings.Builder,在 CI 上跑了一次 go test -bench . -benchmem,看到只快了约 10%,于是认为收益不足,没有合入。两周后另一位同学用 -count=3 复跑,发现 += 的三次结果波动接近 20%,其中一次恰好落在低点,而 Builder 的三次结果很稳定;用 benchstat 比较后,差异的 p 值远小于 0.05,实际中位数差距超过两倍。
复盘发现,CI 机器同时跑着镜像构建,第一次运行刚好遇到 GC 和 CPU 抢占。单次数字把噪声当成了实现差异,导致一个明确有效的优化被搁置。修正做法很简单:固定机器、串行执行、至少三次、用 benchstat 比较,并记录运行时的后台负载。
六、数字怎么读
先看分配次数,再看耗时。分配次数是确定性的,几乎不受调度噪声影响;9 allocs/op 和 1 allocs/op 的差异可以直接指导代码修改。耗时则要结合中位数和离散程度看,不能只比较最小值。
| 观察 | 含义 | 决策 |
|---|---|---|
| ns/op 下降,allocs/op 不变 | 计算路径变快 | 可合入,关注可读性 |
| ns/op 不变,allocs/op 下降 | GC 压力降低 | 值得合入,观察长稳指标 |
| ns/op 下降,allocs/op 上升 | 用内存换时间 | 需要容量评估 |
| 三次波动超过 10% | 噪声偏大 | 增加次数、隔离机器 |
| 两组区间重叠 | 差异可能不显著 | 用 benchstat 判断 |
基准测试也不能替代线上观测。它证明的是“在这个输入、这台机器、这个并发度下,A 比 B 快”,不是“线上一定会更快”。把基准结果和 pprof、P99、GC 次数放在一起看,才能形成完整判断。
七、可复现的基准代码
ch13/bench/bench_test.go 同时包含三组实现、正确性测试和六个基准函数。正确性测试保证优化实现与朴素实现结果一致,基准函数只负责测量;这样就不会出现“基准很快但结果算错”的情况。
// file: ch13/bench/bench_test.go
// tier: L1
// verify: go test ./ch13/bench/
package bench
import (
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
)
// ---- 案例一:锁竞争 ----
type mutexCounter struct {
mu sync.Mutex
n int64
}
func (c *mutexCounter) Add(delta int64) {
c.mu.Lock()
c.n += delta
c.mu.Unlock()
}
func (c *mutexCounter) Load() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
type atomicCounter struct {
n atomic.Int64
}
func (c *atomicCounter) Add(delta int64) { c.n.Add(delta) }
func (c *atomicCounter) Load() int64 { return c.n.Load() }
// ---- 案例二:切片预分配 ----
func appendNoPrealloc(n int) []int {
var out []int
for i := 0; i < n; i++ {
out = append(out, i)
}
return out
}
func appendPrealloc(n int) []int {
out := make([]int, 0, n)
for i := 0; i < n; i++ {
out = append(out, i)
}
return out
}
// ---- 案例三:字符串拼接 ----
func concatPlus(parts []string) string {
var out string
for _, part := range parts {
out += part
}
return out
}
func concatBuilder(parts []string) string {
var b strings.Builder
size := 0
for _, part := range parts {
size += len(part)
}
b.Grow(size)
for _, part := range parts {
b.WriteString(part)
}
return b.String()
}
var (
sliceSink []int
stringSink string
)
func TestCountersAgree(t *testing.T) {
var wg sync.WaitGroup
mc := &mutexCounter{}
ac := &atomicCounter{}
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
mc.Add(1)
ac.Add(1)
}
}()
}
wg.Wait()
if mc.Load() != ac.Load() || mc.Load() != 8000 {
t.Fatalf("mutex=%d atomic=%d", mc.Load(), ac.Load())
}
}
func TestSliceBuildersAgree(t *testing.T) {
for _, n := range []int{0, 1, 7, 64, 1024} {
naive := appendNoPrealloc(n)
prealloc := appendPrealloc(n)
if len(naive) != n || len(prealloc) != n {
t.Fatalf("n=%d: lens %d/%d", n, len(naive), len(prealloc))
}
for i := range naive {
if naive[i] != i || prealloc[i] != i {
t.Fatalf("n=%d index %d: %d/%d", n, i, naive[i], prealloc[i])
}
}
}
}
func TestStringBuildersAgree(t *testing.T) {
parts := []string{"战斗", "-", "room-7", ":", strconv.Itoa(42)}
if got, want := concatPlus(parts), concatBuilder(parts); got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func BenchmarkCounterMutex(b *testing.B) {
var c mutexCounter
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.Add(1)
}
})
if c.Load() != int64(b.N) {
b.Fatalf("count=%d want %d", c.Load(), b.N)
}
}
func BenchmarkCounterAtomic(b *testing.B) {
var c atomicCounter
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.Add(1)
}
})
if c.Load() != int64(b.N) {
b.Fatalf("count=%d want %d", c.Load(), b.N)
}
}
func BenchmarkSliceNoPrealloc(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
sliceSink = appendNoPrealloc(256)
}
}
func BenchmarkSlicePrealloc(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
sliceSink = appendPrealloc(256)
}
}
func BenchmarkStringPlus(b *testing.B) {
parts := []string{"room", "-", "alpha", ":", "123456"}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
stringSink = concatPlus(parts)
}
}
func BenchmarkStringBuilder(b *testing.B) {
parts := []string{"room", "-", "alpha", ":", "123456"}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
stringSink = concatBuilder(parts)
}
}
代码里的 sliceSink 和 stringSink 不是为了业务,而是为了阻止编译器删除“结果未被使用”的调用。并行基准末尾用 b.Fatalf 校验计数,避免基准因为逻辑错误而测到一个更快但错误的实现。
八、运行验证
cd .local/checks/gs-practice-lab
gofmt -l ch13/bench && go vet ./ch13/bench/ && go test ./ch13/bench/ -count=1
PASS
ok gspractice/ch13/bench 0.251s
gofmt -l 没有输出、go vet 没有告警、测试 PASS,才说明基准代码本身是可信的。基准结果只应作为决策证据,不应作为唯一目标;为了跑分而牺牲可读性、正确性或容量安全,通常会带来更大的维护成本。
小结
基准测试的核心不是“跑出一个数字”,而是把实现比较变成可重复实验:固定输入、排除初始化、防止优化、并行用 RunParallel、至少三次、用 benchstat 判断显著性。锁竞争、切片预分配和字符串拼接三组案例说明,分配次数往往比耗时更稳定,也更能直接指导修改。真实数字只在特定机器和负载下成立,写进文档时必须同时记录环境、命令和完整输出。
常见错误与边界
- 基准循环里包含数据初始化,却不调用
b.ResetTimer。 - 只跑一次就下结论,把 GC 或调度噪声当成实现差异。
- 使用
b.RunParallel却在线程数很小的环境里测锁竞争。 - 不把结果写入 sink,编译器把整个被测调用优化掉。
- 只看 ns/op,不看 B/op 和 allocs/op,忽略 GC 压力;预分配容量远超实际长度也会浪费内存。
- 把不同机器、不同 Go 版本、不同后台负载的数字直接比较。
- 用 benchstat 的 p 值代替业务判断,忽略效应量和容量成本。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。