《游戏服务端编程实践》13.4 Benchmark 案例分析

用同一台 Apple M1 Pro、同一份输入和三次重复运行,对照锁竞争、切片预分配与字符串拼接三组实现,给出完整的 go test -bench . -benchmem -count=3 真实输出、分配次数解读和 benchstat 显著性判断方法,并附可复现的基准代码与正确性测试。

一、基准测试回答的是“哪个实现更值”

性能优化最容易犯的错误,是凭直觉改代码,再凭一次运行下结论。基准测试的价值不是给出一个漂亮的数字,而是把“哪个实现更值”变成可重复、可比较、可证伪的实验。游戏服务端尤其需要这种纪律:同一个战斗结算函数,在不同并发度、不同分配模式下,瓶颈可能完全不同。

案例朴素实现优化实现主要观测指标
锁竞争sync.Mutex 保护的计数器atomic.Int64 计数器ns/op、并行度
切片增长不预分配,反复 appendmake([]int, 0, n)ns/op、B/op、allocs/op
字符串拼接+= 逐段拼接strings.Builder 加 Growns/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/opallocs/op
CounterMutex131.6 / 136.3 / 136.0136.000
CounterAtomic74.42 / 72.00 / 72.4572.4500

在这台 10 核 M1 Pro 上,Atomic 的中位数约为 Mutex 的 53%,也就是快了约 1.88 倍。这个倍数只代表当前并发度和当前实现,不代表所有计数器都该换成原子操作;如果临界区里还有别的字段要一起更新,单独换计数器会破坏一致性。

4.2 切片预分配:增长策略的代价

appendNoPrealloc 从 nil 切片开始 append 256 个整数,appendPrealloc 先分配容量 256。前者会经历多次扩容,每次扩容都要分配新数组并复制旧元素;后者只分配一次。最终长度相同,但分配次数和总字节数不同。

实现三次 ns/op中位数B/opallocs/op
SliceNoPrealloc710.2 / 681.1 / 780.3710.240889
SlicePrealloc364.8 / 379.4 / 362.4364.820481

预分配版本中位数约快 1.95 倍,分配字节从 4088 降到 2048,分配次数从 9 降到 1。注意预分配并不总是更快:如果最终长度远小于容量,多出来的内存会被浪费;如果切片很快被丢弃,预分配反而增加单次分配大小。它适合“长度可预估且对象会存活一段时间”的场景。

4.3 字符串拼接:+= 的平方级复制

concatPlus 用 out += part 逐段拼接,concatBuilder 先用 Grow 预留总长度,再 WriteString。前者每拼一次都可能分配新字符串并复制已有内容,后者在容量足够时只做追加,最后 String() 不复制底层字节。

实现三次 ns/op中位数B/opallocs/op
StringPlus94.94 / 93.38 / 91.3793.38564
StringBuilder36.85 / 36.49 / 35.8336.49241

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 值代替业务判断,忽略效应量和容量成本。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist