G

[Golang] 一次errors的benchmark记录

RoLingG Golang 2026-03-28

一次errors的benchmark记录

本文代码由我和AI携手编写,如有问题全是我菜逼导致的(
package errorsbench

import (
    "errors"
    "fmt"
    "os"
    "testing"
)

// makeErrorChain 构造指定深度的错误链
// depth=1: 只有底层错误
// depth=3: 底层错误 + 2层包装
// depth=5: 底层错误 + 4层包装
func makeErrorChain(depth int) error {
    base := &os.PathError{
        Op:   "open",
        Path: "/tmp/test.txt",
        Err:  os.ErrNotExist,
    }

    err := error(base)
    for i := 0; i < depth-1; i++ {
        err = fmt.Errorf("layer %d: %w", i, err)
    }
    return err
}

// benchmarkOldAs 测试标准库 errors.As(反射版)
func benchmarkOldAs(b *testing.B, chainDepth int) {
    err := makeErrorChain(chainDepth)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var target *os.PathError
        if errors.As(err, &target) {
            _ = target.Path // 防止编译器优化掉
        }
    }
}

// benchmarkNewAsType 测试 errors.AsType(泛型版)
func benchmarkNewAsType(b *testing.B, chainDepth int) {
    err := makeErrorChain(chainDepth)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        if target, ok := errors.AsType[*os.PathError](err); ok {
            _ = target.Path // 防止编译器优化掉
        }
    }
}

// 不同深度的对比测试
func BenchmarkAs_Depth1(b *testing.B)     { benchmarkOldAs(b, 1) }
func BenchmarkAsType_Depth1(b *testing.B) { benchmarkNewAsType(b, 1) }

func BenchmarkAs_Depth3(b *testing.B)     { benchmarkOldAs(b, 3) }
func BenchmarkAsType_Depth3(b *testing.B) { benchmarkNewAsType(b, 3) }

func BenchmarkAs_Depth5(b *testing.B)     { benchmarkOldAs(b, 5) }
func BenchmarkAsType_Depth5(b *testing.B) { benchmarkNewAsType(b, 5) }

// 测试内联效果:小函数包装
func checkWithAs(err error) bool {
    var target *os.PathError
    return errors.As(err, &target)
}

func checkWithAsType(err error) bool {
    _, ok := errors.AsType[*os.PathError](err)
    return ok
}

func BenchmarkAs_Inlined(b *testing.B) {
    err := makeErrorChain(3)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = checkWithAs(err)
    }
}

func BenchmarkAsType_Inlined(b *testing.B) {
    err := makeErrorChain(3)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = checkWithAsType(err)
    }
}

// 正确性验证
func TestCorrectness(t *testing.T) {
    err := makeErrorChain(3)

    // 旧 API
    var oldTarget *os.PathError
    oldOk := errors.As(err, &oldTarget)

    // 新 API
    newTarget, newOk := errors.AsType[*os.PathError](err)

    if oldOk != newOk {
        t.Fatalf("ok mismatch: old=%v, new=%v", oldOk, newOk)
    }
    if oldOk && oldTarget.Path != newTarget.Path {
        t.Fatalf("path mismatch: old=%v, new=%v", oldTarget.Path, newTarget.Path)
    }
    t.Logf("Both APIs return: ok=%v, path=%s", oldOk, oldTarget.Path)
}
// 测试指令:go test -bench=Benchmark -benchmem -v 

结果(32核CPU)

PS D:\GoLand\errorsbench> go test -bench=Benchmark -benchmem -v        
=== RUN   TestCorrectness
    main_test.go:104: Both APIs return: ok=true, path=/tmp/test.txt
--- PASS: TestCorrectness (0.00s)
goos: windows
goarch: amd64
pkg: errorsbench
cpu: 13th Gen Intel(R) Core(TM) i9-13900HX
BenchmarkAs_Depth1
BenchmarkAs_Depth1-32           14721577                76.50 ns/op            8 B/op          1 allocs/op
BenchmarkAsType_Depth1
BenchmarkAsType_Depth1-32       451059481                2.728 ns/op           0 B/op          0 allocs/op
BenchmarkAs_Depth3
BenchmarkAs_Depth3-32           10217434               115.4 ns/op             8 B/op          1 allocs/op
BenchmarkAsType_Depth3
BenchmarkAsType_Depth3-32       107737664               11.46 ns/op            0 B/op          0 allocs/op
BenchmarkAs_Depth5
BenchmarkAs_Depth5-32            7936659               147.8 ns/op             8 B/op          1 allocs/op
BenchmarkAsType_Depth5
BenchmarkAsType_Depth5-32       70322662                17.79 ns/op            0 B/op          0 allocs/op
BenchmarkAs_Inlined
BenchmarkAs_Inlined-32          10316660               113.7 ns/op             8 B/op          1 allocs/op
BenchmarkAsType_Inlined
BenchmarkAsType_Inlined-32      100000000               11.07 ns/op            0 B/op          0 allocs/op
PASS
ok      errorsbench     12.265s
场景errors.Aserrors.AsType加速比内存差异
Depth176.50 ns2.728 ns28x8B vs 0B
Depth3115.4 ns11.46 ns10x8B vs 0B
Depth5147.8 ns17.79 ns8.3x8B vs 0B
Inlined113.7 ns11.07 ns10x8B vs 0B

核心机制:testing.B 的工作方式

func BenchmarkXxx(b *testing.B) {
    // 准备代码(不计时)
    data := prepare()
    
    b.ResetTimer()  // ← 重置计时器,从这里开始算时间
    
    for i := 0; i < b.N; i++ {  // ← b.N 是框架自动调整的
        // 被测代码(循环执行 b.N 次)
        operation(data)
    }
}

b.N 是什么?

  • 不是固定的,是 Go 测试框架自动调整的
  • 框架会先跑几次,估算时间,然后调整 b.N 让总运行时间 ≈ 1 秒
  • 你的结果里 b.N 就是实际执行次数(如 451059481 次)

逐函数拆解

数据准备:makeErrorChain

func makeErrorChain(depth int) error {
    base := &os.PathError{...}  // 底层错误
    err := error(base)
    for i := 0; i < depth-1; i++ {
        err = fmt.Errorf("layer %d: %w", i, err)  // 用 %w 包装
    }
    return err
}
  • depth=1: PathError(无包装)
  • depth=3: fmt.Errorffmt.ErrorfPathError(两层包装)
  • depth=5: 四层包装 + 底层

测旧 API:benchmarkOldAs

func benchmarkOldAs(b *testing.B, chainDepth int) {
    err := makeErrorChain(chainDepth)  // 准备数据(不计时)
    
    b.ResetTimer()  // ← 从这里开始计时
    
    for i := 0; i < b.N; i++ {
        var target *os.PathError
        if errors.As(err, &target) {  // ← 反射遍历错误链
            _ = target.Path  // ← 防止编译器优化掉整个 if
        }
    }
}

发生了什么:

  1. errors.As 内部用 reflect 遍历错误链
  2. 每层都查类型表、判断可赋值性
  3. 找到匹配后通过指针写回 target
  4. 每次都有反射对象分配(你的结果:8B/1allocs)

测新 API:benchmarkNewAsType

func benchmarkNewAsType(b *testing.B, chainDepth int) {
    err := makeErrorChain(chainDepth)
    
    b.ResetTimer()
    
    for i := 0; i < b.N; i++ {
        if target, ok := errors.AsType[*os.PathError](err); ok {    // ← 泛型错误链
            _ = target.Path
        }
    }
}

发生了什么:

  1. errors.AsType[*os.PathError] 编译期确定类型
  2. 内部直接 err.(*os.PathError) 类型断言
  3. 无反射、无动态遍历、无内存分配
  4. 可被内联(你的 Inlined 测试验证了这点)(内联:把函数调用直接替换成函数体代码,消除调用开销)
func checkWithAsType(err error) bool {
    _, ok := errors.AsType[*os.PathError](err)
    return ok
}

// _, ok := errors.AsType[*os.PathError](err)
// 等价于直接写
// _, ok := err.(*os.PathError)  // 直接展开,无函数调用
func checkWithAs(err error) bool {
    var target *os.PathError
    return errors.As(err, &target)  // 反射调用,要去判断类型,无法直接断言类型展开调用
}

内联测试:checkWithAs vs checkWithAsType

func checkWithAs(err error) bool {
    var target *os.PathError
    return errors.As(err, &target)
}

func BenchmarkAs_Inlined(b *testing.B) {
    err := makeErrorChain(3)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = checkWithAs(err)  // ← 测函数调用 + 内部逻辑
    }
}

设计意图:

  • 把小函数抽出来,看编译器能否内联
  • checkWithAsType 能被内联 → 性能 ≈ 直接写循环
  • checkWithAs 因反射无法内联 → 有函数调用开销

结果上看:

Benchmark直接调用 (Depth3)封装函数 (Inlined)差异
errors.As115.4 ns113.7 ns~1.5% (噪声范围内)
errors.AsType11.46 ns11.07 ns~3.4% (噪声范围内)

两者都几乎无差异,说明封装函数被内联了,没有额外开销。

  • 两个函数确实都被内联了。 从数据可以直接看出来——加了一层封装函数后,性能几乎没变(115.4 → 113.7,11.46 → 11.07)。如果没被内联,每次循环会多一次函数调用开销,数据应该明显变差。
  • errors.As 内联了但无所谓。 它的反射开销 100+ ns 远大于函数调用的 ~5 ns,所以即使不内联,整体耗时变化也微乎其微。打个比方:你开一辆车多绕了 5 米的路,总路程从 115 米变成 120 米,感知不大。
  • errors.AsType 内联了而且很关键。 它本身只要几纳秒,函数调用的 ~5 ns 开销和它同一个量级。如果不内联,封装一层就直接让性能翻倍。打个比方:你走 11 米的路,多绕 5 米就是 16 米,感知巨大。

正确性验证:TestCorrectness

func TestCorrectness(t *testing.T) {
    err := makeErrorChain(3)
    
    oldOk := errors.As(err, &oldTarget)
    newOk, newTarget := errors.AsType[*os.PathError](err)
    
    // 确保两个 API 返回相同结果
    if oldOk != newOk || oldTarget.Path != newTarget.Path {
        t.Fatal("mismatch")
    }
}

为什么需要:

  • Benchmark 只测性能,不测正确性
  • 防止"跑得快但结果是错的"情况

执行流程总结

go test -bench=Benchmark -benchmem -v
           ↓
    发现所有 BenchmarkXxx 函数
           ↓
    对每个函数:
        1. 先跑 1 次,估算时间(用于估算1s用时的次数)
        2. 调整 b.N(如从 1 调到 451059481,用时接近1s)
        3. 正式跑 b.N 次,计时
        4. 计算:总时间 / b.N = ns/op
        5. 统计内存分配(-benchmem)
           ↓
    输出结果行:
    BenchmarkAsType_Depth1-32  451059481  2.728 ns/op  0 B/op  0 allocs/op
              ↑                    ↑          ↑          ↑         ↑
           函数名-CPU数           执行次数   每次耗时   内存/次   分配次数/次

关键设计技巧

技巧代码体现目的
b.ResetTimer()准备数据后调用排除构造时间
_ = target.Path使用返回值防止编译器优化掉整个操作
b.N 循环for i := 0; i < b.N; i++让框架决定跑多少次
辅助函数benchmarkOldAs(b, depth)复用逻辑,参数化场景
小函数包装checkWithAs测试内联效果

结果分析

性能差异惊人

数据远超预期。errors.AsType[T] 在所有场景下都快了一个数量级:

  • Depth 1(无包装):76.5 ns → 2.7 ns,28 倍加速
  • Depth 3(2 层包装):115.4 ns → 11.5 ns,10 倍加速
  • Depth 5(4 层包装):147.8 ns → 17.8 ns,8.3 倍加速

为什么快这么多?

1. 零内存分配

errors.As 每次调用分配 8 字节(反射需要的临时对象),而 errors.AsType[T] 完全零分配。在高频调用路径上,这意味着 GC 压力大幅降低。

2. 反射 vs 类型断言

errors.As 内部通过 reflect.TypeOf + reflect.New 逐层匹配,涉及多次间接调用和内存分配。errors.AsType[T] 在编译期确定类型后,运行时只需一个简单的 type assertion——本质上是一次指针比较。

3. 内联优化

Inlined 测试组的结果很说明问题:errors.As 封装后 113.7 ns(与 Depth 3 的 115.4 ns 几乎相同),说明函数调用被内联了,但反射开销仍在。而 errors.AsType[T] 封装后 11.1 ns,与 Depth 3 的 11.5 ns 也基本一致——内联 + 零分配的组合使得封装函数几乎没有额外开销。

错误链深度的影响

两个 API 都随链深度增加而变慢,但速率不同:

  • errors.As:每增加一层约 +18 ns(反射的逐层检查开销)
  • errors.AsType[T]:每增加一层约 +3.5 ns(简单的 type assertion 逐层遍历)

这意味着在深层错误链(比如微服务层层包装的场景)中,errors.AsType[T] 的优势会更加明显。

正确性验证

两个 API 返回的结果必须完全一致。测试代码中通过 TestCorrectness 验证:

func TestCorrectness(t *testing.T) {
    err := makeErrorChain(3)

    var oldTarget *os.PathError
    oldOk := errors.As(err, &oldTarget)

    newTarget, newOk := errors.AsType[*os.PathError](err)

    if oldOk != newOk {
        t.Fatalf("ok mismatch: old=%v, new=%v", oldOk, newOk)
    }
    if oldOk && oldTarget.Path != newTarget.Path {
        t.Fatalf("path mismatch: old=%v, new=%v", oldTarget.Path, newTarget.Path)
    }
}

代码可读性对比

除了性能,新 API 在代码可读性上也有明显提升:

旧写法(3 行):

var target *os.PathError
if errors.As(err, &target) {
    // use target
}

新写法(1 行):

if target, ok := errors.AsType[*os.PathError](err); ok {
    // use target
}

新写法将变量声明和条件判断合为一行,消除了 var + & 的心智负担。

结论

errors.AsType[T]errors.As 的泛型升级版,在性能和可读性上都有显著改善:

  • 性能:最高 28 倍加速,零内存分配,彻底消除反射开销
  • 可读性:一行完成声明 + 判断,无需提前声明零值变量
  • 安全性:编译期类型检查,不存在传错指针类型的风险

如果你的项目已经使用 Go 1.26+,没有任何理由继续使用 errors.As——直接迁移到 errors.AsType[T]

PREV
[Golang] 1.26版本新GC回收——Green Tea 🍵 Garbage Collector
NEXT
[Golang] 通过errors.AsType的改动反映反射的优化方向

评论(0)

发布评论