4、Benchmark基准测试:编写基准测试函数、benchmem与benchtime、避免编译器优化、对比不同实现的性能

性能优化这件事,最怕的就是「我觉得」。我觉得这段代码慢,我觉得那个函数快——但感觉靠不住。我入行第三年的时候,就因为「觉得」一个字符串拼接方式没问题,结果线上压测直接被打穿。从那以后,我养成了一个习惯:任何性能相关的决策,必须用基准测试说话

Go 语言自带的 testing 包提供了非常完善的基准测试工具。说白了,它就是一把尺子,能精确量出你的代码到底跑多快、吃多少内存。这一章,我就带你把这把尺子用明白。

4.1 编写基准测试函数

基准测试的写法,和单元测试很像。文件名必须是 _test.go 结尾,函数名以 Benchmark 开头,参数是 *testing.B

来看个最简单的例子。假设我要测试一个整数加法函数:

// add.go
func Add(a, b int) int {
    return a + b
}

// add_test.go
func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(1, 2)
    }
}

注意这个 b.N。它不是你自己设的固定值,而是框架自动调整的。框架会先跑一小段,估算出你的函数大概耗时,然后自动增加循环次数,直到总运行时间稳定在 1 秒左右。你想想看,这样测出来的结果才够准。

运行基准测试的命令是:

go test -bench=.

输出大概长这样:

BenchmarkAdd-8    1000000000    0.25 ns/op

解释一下:-8 表示用了 8 个 CPU 核心,1000000000 是循环次数,0.25 ns/op 是每次操作的平均耗时。嗯,0.25 纳秒,这基本就是 CPU 指令级别的速度了。

我的小习惯: 我一般会在 -bench 后面加个正则,比如 -bench=BenchmarkAdd$,只跑我关心的那个函数。不然整个包里的测试全跑一遍,浪费时间。

4.2 benchmem 与 benchtime

光看耗时还不够。很多时候,性能瓶颈不在 CPU,而在内存分配。每次分配内存都要触发 GC,GC 一跑,整个程序就卡一下。

-benchmem 参数,就能看到每次操作分配了多少内存、发生了多少次分配:

go test -bench=. -benchmem

输出会多两列:

BenchmarkAdd-8    1000000000    0.25 ns/op    0 B/op    0 allocs/op

0 B/op 表示每次操作分配 0 字节,0 allocs/op 表示 0 次内存分配。这当然是最理想的情况。但实际项目中,很多函数都会产生分配。

举个例子,字符串拼接:

func ConcatWithPlus(a, b string) string {
    return a + b
}

func ConcatWithBuilder(a, b string) string {
    var sb strings.Builder
    sb.WriteString(a)
    sb.WriteString(b)
    return sb.String()
}

跑一下基准测试:

BenchmarkConcatWithPlus-8       50000000    30.5 ns/op    16 B/op    2 allocs/op
BenchmarkConcatWithBuilder-8   100000000    12.3 ns/op     8 B/op    1 allocs/op

看到了吗?+ 操作符每次拼接都会产生新的字符串,分配了 2 次内存。而 strings.Builder 只分配了 1 次。这就是为什么我建议你在循环拼接字符串时,一定要用 Builder。

再说说 -benchtime。默认情况下,基准测试跑 1 秒。但有些函数特别慢,1 秒跑不了几次,结果可能不稳定。这时候可以手动指定时间:

go test -bench=. -benchtime=5s

这样框架会尽量跑满 5 秒,结果更可靠。我个人习惯是:对于毫秒级的函数,用 5 秒;对于微秒级的,用 2 秒就够了

我曾经踩过的坑: 有一次我测一个数据库查询函数,默认 1 秒只跑了 3 次。结果每次查询的耗时波动很大,根本看不出真实性能。后来把 -benchtime 调到 30 秒,才拿到稳定数据。所以,样本量太少时,别信那个平均值

4.3 避免编译器优化

这是新手最容易忽略的问题。Go 编译器很聪明,它发现你的函数结果没被使用,可能直接把它优化掉了。你测出来的「高性能」,其实是编译器帮你作弊。

看这个例子:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(1, 2)  // 结果没被使用
    }
}

编译器一看:嗯,这个 Add 的返回值没人要,那我还算它干嘛?直接跳过。结果你测出来 0.25 纳秒,其实什么都没跑。

正确的做法是把结果赋值给一个包级变量:

var result int

func BenchmarkAdd(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = Add(1, 2)
    }
    result = r
}

注意这里用了局部变量 r,最后才赋值给包级变量 result。为什么?因为如果直接在循环里写 result = Add(1, 2),编译器可能还是会优化——它觉得每次赋值都一样,没必要重复执行。

还有一种更稳妥的方式,用 testing.B 提供的 StopTimerStartTimer

func BenchmarkExpensive(b *testing.B) {
    data := prepareLargeData()  // 准备数据不计时
    b.ResetTimer()              // 重置计时器

    for i := 0; i < b.N; i++ {
        b.StopTimer()
        input := generateInput(data)  // 生成输入不计时
        b.StartTimer()

        result = process(input)
    }
}

嗯,这里要注意:ResetTimerStopTimer/StartTimer 是两套不同的机制。前者是重置整个计时,后者是暂停/恢复。我一般只在准备数据阶段用 ResetTimer,循环内部的辅助操作才用 StopTimer

核心原则: 基准测试必须测到「真实的工作量」,而不是「编译器优化后的假象」。把结果赋值给包级变量,是最简单有效的防优化手段。

4.4 对比不同实现的性能

基准测试最大的价值,就是用来做对比。同一个功能,不同实现方式,性能可能差一个数量级。

我拿一个实际案例来说。假设我们要判断一个字符串是否包含某个子串:

// 方式一:strings.Contains
func ContainsStandard(s, substr string) bool {
    return strings.Contains(s, substr)
}

// 方式二:自己写的循环
func ContainsCustom(s, substr string) bool {
    for i := 0; i <= len(s)-len(substr); i++ {
        if s[i:i+len(substr)] == substr {
            return true
        }
    }
    return false
}

写个基准测试对比一下:

var result bool

func BenchmarkContainsStandard(b *testing.B) {
    s := "hello world, this is a long string for testing"
    substr := "testing"
    var r bool
    for i := 0; i < b.N; i++ {
        r = ContainsStandard(s, substr)
    }
    result = r
}

func BenchmarkContainsCustom(b *testing.B) {
    s := "hello world, this is a long string for testing"
    substr := "testing"
    var r bool
    for i := 0; i < b.N; i++ {
        r = ContainsCustom(s, substr)
    }
    result = r
}

跑一下看看:

BenchmarkContainsStandard-8   100000000    12.5 ns/op    0 B/op    0 allocs/op
BenchmarkContainsCustom-8     50000000    35.2 ns/op    0 B/op    0 allocs/op

标准库快了将近 3 倍。为什么?因为 strings.Contains 底层用了 Rabin-Karp 算法,比简单的逐字符比较高效得多。这个案例告诉我们:标准库能搞定的,别自己造轮子

但有时候,标准库不一定是最优解。比如你要频繁地从一个 map 中读取数据,但 map 的并发读写不安全。你可以对比 sync.Map 和 map + sync.RWMutex:

实现方式 读操作 (ns/op) 写操作 (ns/op) 内存分配 (B/op)
sync.Map 45.2 120.5 32
map + RWMutex 38.7 95.3 0

你看,在这个场景下,map + RWMutex 反而更快,而且没有额外内存分配。sync.Map 适合的是「读多写少」且 key 分布均匀的场景。没有基准测试,你根本不知道哪个更适合你的业务。

我的对比方法论: 每次对比至少跑 3 轮,取中位数。第一轮结果往往偏慢(因为 CPU 缓存还没预热),第二轮开始才稳定。我一般会写个脚本,自动跑 5 轮,然后取后 3 轮的平均值。

4.5 总结

基准测试是性能优化的基石。没有它,你所有的优化都是盲人摸象。记住几个要点:

  • -benchmem 看内存分配,很多性能问题出在 GC 上
  • -benchtime 控制测试时长,样本量要够大
  • 用包级变量防止编译器优化,别让编译器「帮你作弊」
  • 对比不同实现时,控制变量,只改一个因素

最后说一句:别迷信直觉,让数据说话。你觉得自己写的算法很牛?跑个基准测试看看。你觉得标准库肯定最快?跑个基准测试看看。我见过太多「我觉得很快」的代码,一测就原形毕露。嗯,基准测试就是那面照妖镜。