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

testing.B.Loop 中的初始化代码如何排除计时

来源:17golang原创

时间:2026-10-10 17:20:39 273浏览 收藏

如果基准测试需要先构造一份较大的输入数据,使用传统的 for range b.N 写法时,初始化可能随着基准函数反复执行而被带入测量过程。Go 1.24 引入的 testing.B.Loop 把计时边界交给框架管理:第一次调用 b.Loop() 时重置计时器,循环结束时停止计时,所以放在循环前的初始化不会计入结果。

官方资料:https://pkg.go.dev/testing

本文只讨论一个问题:怎样安排初始化、被测代码和清理,让基准结果更接近真正想比较的那段工作。

先看传统 b.N 写法的计时陷阱

旧式基准通常由 testing 包多次调用,逐步调整 b.N,直到一次测量达到足够时长。若初始化直接写在基准函数里,它可能伴随这些测量尝试重复执行。

func BenchmarkDecodeLegacy(b *testing.B) {
	// 这份输入只应准备一次,但 b.N 风格可能让整个函数重复执行。
	payload := makePayload(1 

b.ResetTimer() 能把计时器重新归零,但它是显式维护的边界。后续有人在它前面追加初始化、忘记重置,或者把清理逻辑放回循环外,基准语义就容易发生漂移。

用 b.Loop 把初始化放在计时边界外

testing.B.Loop 将初始化、循环体计时和清理分开的静态结构说明图
图1:testing.B.Loop 的计时边界说明图,初始化与清理位于循环体之外。

迁移后的结构是 初始化 -> for b.Loop() -> 清理。第一次进入循环时,testing 包重置计时器;当循环条件返回 false 时,计时停止。因此只要初始化位于第一次 b.Loop() 之前,清理位于循环结束之后,它们就不会计入基准耗时。

func BenchmarkDecode(b *testing.B) {
	// 在计时开始前准备固定输入,避免测量数据构造成本。
	payload := makePayload(1 

关键不是把 b.ResetTimer() 换成另一句 API,而是让“被测代码”成为 for b.Loop() { ... } 的唯一主体。初始化不需要猜测何时开始计时,清理也不需要再手动暂停计时。

Loop 还解决了哪些旧式基准问题

Loop 的价值不止是省掉一行 ResetTimer。官方文档还说明,循环体中的函数调用参数、返回值和赋值变量会被保持存活,避免编译器把没有可见副作用的被测调用整体优化掉。

func BenchmarkSum(b *testing.B) {
	// 输入放在计时边界外,并保持每次迭代使用同一份数据。
	values := []int{3, 5, 8, 13}

	for b.Loop() {
		// 不需要额外的 sink 变量来强行保活这次调用。
		sum(values)
	}
}

这里仍然要注意语义:Loop 的条件应当准确写成 b.Loop(),不要把它包进一个会改变语法形态的辅助函数或复杂条件。需要总迭代次数时,循环结束后可以读取 b.N,但不要再同时写一个从零到 b.N 的循环。

旧写法和新写法应该怎样对照

传统 b.N 与现代 b.Loop 基准计时边界对比说明图
图2:传统 b.N 与 b.Loop 的计时边界对比,展示初始化重复风险与新式边界。

可以把迁移前后的判断压缩成下面这张表:

关注点b.N 写法b.Loop 写法
计时开始通常需要手动调用 ResetTimer第一次 b.Loop() 自动重置
初始化可能随基准函数重复执行放在 Loop 前,不计入循环体计时
清理需要自己确认计时状态Loop 返回 false 后已停止计时
被测调用保活可能需要结果接收或 sinkLoop 循环体有专门的保活语义

这不是说所有旧基准都必须立刻改写。若项目仍需兼容 Go 1.23 或更早版本,b.Loop 不存在,应继续使用 b.N 和 b.ResetTimer,或者通过构建约束分别提供实现。

迁移时检查四个边界

第一,确认项目的最低 Go 版本至少为 1.24;否则新写法会在编译阶段失败。第二,把固定输入、解析模板和连接准备等只需做一次的工作放到第一次 b.Loop() 之前。第三,把每次迭代都必须发生的重置放回循环体,例如复用缓冲区前清空它。第四,检查是否仍然依赖当前迭代序号或总次数,因为 Loop 的目标是让基准函数不再围绕 b.N 编排业务逻辑。

func BenchmarkEncode(b *testing.B) {
	// 只准备一次的输入放在 Loop 之前,兼容边界由 go.mod 决定。
	input := newInput()
	buffer := make([]byte, 0, 1024)

	for b.Loop() {
		// 每轮必须清空的状态仍然属于被测循环的准备工作。
		buffer = buffer[:0]
		encodeInto(buffer, input)
	}
}

最后一个例子提醒我们:不计时不等于不执行。初始化和清理仍然会运行,只是不会进入 ns/op 的计时区域;如果初始化本身就是产品要比较的成本,就应该把它明确放进 b.Loop() 内,而不是为了让数字更小而移到外面。

常见问题

只要把初始化放到 Loop 前面,就一定只执行一次吗?

在一次基准测量中,Loop 让基准函数围绕循环运行一次,初始化位于第一次调用前,因此不会随着旧式 b.N 的测量尝试重复进入计时区域。但初始化是否被其他测试代码调用,仍取决于你的测试组织方式。

还需要调用 b.ResetTimer 吗?

使用标准的 for b.Loop() 结构时,通常不需要为排除循环前初始化而手动重置。只有在基准中存在额外的计时分段需求时,才应根据目标重新设计边界,而不是机械保留旧代码。

能否写成 for b.Loop() && condition?

不要这样写。为了保留 Loop 的计时和编译器保活语义,条件应保持为精确的 b.Loop();额外条件可以在循环体内用分支表达。

归纳起来,testing.B.Loop 的迁移重点是边界而不是语法替换:固定输入放在循环前,待测工作放进 Loop,清理放在循环后,并确认项目最低 Go 版本满足要求。这样基准结果的含义更清楚,也少了一处容易被后来修改破坏的手动计时控制。

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