登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go benchmark 如何比较字符串拼接的分配次数

来源:17golang原创

时间:2026-09-12 15:26:31 426浏览 收藏

我在给日志格式化函数做优化时,最容易误判的不是字符串拼接本身,而是只看一次运行时间。+strings.Builderfmt.Sprintf 可能都能得到正确文本,但它们在每次操作里的堆分配次数并不一样。Go benchmark 要回答这个问题,关键是让实现共享输入、共享结果接收方式,再打开内存统计。

要点速览
  • b.ReportAllocs() 会让当前基准输出 B/opallocs/op
  • 结果必须写入包级 sink 或返回给调用方,避免优化器把拼接工作删掉。
  • testing.AllocsPerRun 适合单独确认平均分配次数,但不等于生产环境的完整内存成本。

先把字符串拼接放进公平的基准循环

先固定三段输入,并让所有实现都返回同一个结果类型。下面的代码使用 Go 1.24 及更高版本的 b.Loop();如果项目还要支持更早版本,把循环改成 for i := 0; i 即可。

package concat

import (
    "fmt"
    "strings"
    "testing"
)

var benchmarkSink string

func plus(a, b, c string) string { return a + b + c }

func builder(a, b, c string) string {
    var sb strings.Builder
    // 预留容量只用于减少扩容,不改变结果字符串的语义。
    sb.Grow(len(a) + len(b) + len(c))
    sb.WriteString(a)
    sb.WriteString(b)
    sb.WriteString(c)
    return sb.String()
}

func sprintf(a, b, c string) string {
    // 格式化路径更直观,但格式解析本身也属于测量对象。
    return fmt.Sprintf("%s%s%s", a, b, c)
}

func BenchmarkConcat(b *testing.B) {
    cases := []struct {
        name string
        fn   func(string, string, string) string
    }{
        {"plus", plus}, {"builder", builder}, {"sprintf", sprintf},
    }
    for _, tc := range cases {
        b.Run(tc.name, func(b *testing.B) {
            b.ReportAllocs()
            for b.Loop() {
                // 写入共享 sink,防止编译器消除没有观察者的返回值。
                benchmarkSink = tc.fn("go-", "bench-", "allocs")
            }
        })
    }
}

这里的 cases 和输入准备在循环外,ReportAllocs 放在每个子基准里,统计范围清楚。共享 sink 不是为了模拟业务存储,而是告诉编译器这个结果仍然可观察。

Go benchmark 字符串拼接基准中 testing.B、b.Loop、ReportAllocs、结果 sink 和三种拼接函数的静态关系图
图1:操作示意图,展示基准边界、结果 sink 与三种拼接函数的静态关系;图中不是实际运行截图。

ReportAllocs 输出的三个指标分别说明什么

执行命令可以只筛选这一组基准:

go test -run '^$' -bench '^BenchmarkConcat$' -benchmem -count=5
# -run '^$' 跳过普通测试;-count=5 用多次结果观察波动。
# -benchmem 打开内存统计;也可依赖基准函数里的 ReportAllocs。

输出中的 ns/op 是每次操作的平均耗时,B/op 是每次操作平均分配的字节数,allocs/op 是每次操作平均分配次数。比较时先看问题目标:如果是减少对象创建,优先观察 allocs/op;如果是降低临时内存峰值,还要看 B/op;只看 ns/op 可能把 CPU 优化和内存优化混在一起。

示例输出的数字只应来自你自己的机器,不能把某篇文章里的固定结果当作结论。更稳妥的记录方式是保存多次运行结果,再用 benchstat 做差异分析;单次 benchmark 只能说明一次环境下的观测。

用 AllocsPerRun 复核单次调用的分配次数

当问题只关心“调用一次拼接函数会分配几次”时,可以把基准计时和分配测量拆开:

func TestConcatAllocs(t *testing.T) {
    got := testing.AllocsPerRun(100, func() {
        // 保留返回值,避免测量函数被优化成没有效果的调用。
        benchmarkSink = builder("go-", "bench-", "allocs")
    })
    t.Logf("builder allocations per call: %.0f", got)
}
# AllocsPerRun 会先预热一次,再统计指定次数的平均分配。
# 测量期间 GOMAXPROCS 会临时设为 1,返回前恢复原值。

AllocsPerRun(100, ...) 返回类型虽然是 float64,官方文档说明它的结果应为整数值。它适合做回归测试或针对一个候选实现做确认,不适合替代完整 benchmark:它不提供耗时,也不会告诉你分配了多少字节。

Go testing.AllocsPerRun 的预热、单线程测量、字符串拼接调用与 allocs/op 和 B/op 指标静态关系图
图2:结果示意图,展示 AllocsPerRun 与预热、GOMAXPROCS=1、拼接调用及分配指标的静态关系;不是实测结果。

别把分配次数直接等同于线上性能

基准只在它定义的输入和环境里成立。短字符串可能被编译器优化,长字符串会暴露扩容和复制成本;把三段输入改成循环拼接,结论也可能改变。确认结果时至少固定输入长度、Go 版本、架构和编译标签,并检查是否启用了 race、asan 等会改变运行特征的工具。

对 Go 1.24 及更高版本,b.Loop() 能减少传统 b.N 循环中计时边界和编译器优化带来的误差;旧项目则继续使用 b.N,把初始化放在循环外,必要时用 b.ResetTimer() 明确计时起点。最终是否替换实现,应把 benchmark 趋势和真实请求中的字符串长度、调用频率、内存预算一起考虑。

常见问题

为什么 benchmark 没有显示 allocs/op?

确认基准函数调用了 b.ReportAllocs(),或命令带上 -benchmem,并确保你运行的是 go test -bench 而不是普通测试。

为什么结果接收方式会影响分配次数?

如果返回值没有被观察,编译器可能删除部分工作;共享 sink 只用于保持结果可观察,不能据此推断业务代码必须使用全局变量。

Builder 的 allocs/op 更少就一定更快吗?

不一定。输入长度、扩容、格式化需求和调用频率都会改变结果,应同时比较 ns/opB/opallocs/op,再用真实负载复测。

参考:Go 官方 testing 包文档与 Go Blog 的 testing.B.Loop 说明。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>