bigywater.tech
Go · 32

defer 的成本:open-coded defer 与堆上 defer 差多少

“defer 很慢”是一个过时的说法。Go 1.14 引入了 open-coded defer,把大多数 defer 编译成函数出口处的直接调用。这篇测出它到底快多少,什么时候会退化,并用编译器自己的诊断与源码里的限制条件对照。

1.98 ns
一个 defer inc() 的耗时,与直接调用 inc() 的 1.97 ns 相同
8 → 9
8 个 defer 15.27 ns,9 个 defer 58.47 ns:超过 8 个就不再是 open-coded
13.23 ns
关闭 open-coded 后一个 defer 的耗时(默认 1.99 ns)

一个 defer 几乎免费

实测 · 本机执行输出
  noDefer:直接调用 inc()                   中位 1.97 ns/op(最小 1.96 / 最大 2.01)
  oneDefer:defer inc()                 中位 1.98 ns/op(最小 1.96 / 最大 2.10)
  lockDirect:Lock + 直接 Unlock          中位 7.64 ns/op(最小 7.59 / 最大 7.75)
  lockDefer:Lock + defer Unlock        中位 11.79 ns/op(最小 11.74 / 最大 11.84)
  eightDefers:8 个 defer(最多可以内联展开的数量)   中位 15.27 ns/op(最小 15.10 / 最大 16.82)
  nineDefers:9 个 defer                 中位 58.47 ns/op(最小 58.19 / 最大 60.25)
  loopDefer:循环里的 defer(循环 1 次)         中位 17.95 ns/op(最小 17.85 / 最大 18.13)
  manyReturns:4 个 defer × 4 个 return   中位 29.63 ns/op(最小 29.52 / 最大 31.80)
  withRecover:正常返回(defer 里有 recover)   中位 3.32 ns/op(最小 3.30 / 最大 3.33)
  withPanic:panic 后被 recover 接住        中位 144.53 ns/op(最小 142.54 / 最大 147.44)
  AllocsPerRun:oneDefer 0、eightDefers 0、nineDefers 0、loopDefer 0、manyReturns 0、withPanic 0

open-coded defer 把 defer f() 在函数的每个出口直接展开成对 f() 的调用(再加一个栈上的位图记录哪些 defer 要执行),不需要运行时在堆或栈上登记。所以 oneDefer 与直接调用没有可测的差别。加锁加 defer 解锁比直接解锁多约 4 ns,这是被 defer 的 Unlock 无法内联造成的,不是 defer 本身的成本(这一点是我的解释,没有进一步验证)。

三种 defer

实测 · 本机执行输出
  oneDefer     open-coded defer × 1
  lockDefer    open-coded defer × 1
  eightDefers  open-coded defer × 8
  nineDefers   stack-allocated defer × 9
  loopDefer    heap-allocated defer × 1
  manyReturns  stack-allocated defer × 4
  withRecover  open-coded defer × 1
  withPanic    open-coded defer × 1
  各函数汇编里的 defer 相关调用(去重):
    noDefer      (没有调用 runtime 的 defer 函数)
    oneDefer     runtime.deferreturn
    lockDefer    runtime.deferreturn
    eightDefers  runtime.deferreturn
    nineDefers   runtime.deferprocStack, runtime.deferreturn
    loopDefer    runtime.deferproc, runtime.deferreturn
    manyReturns  runtime.deferprocStack, runtime.deferreturn
    withPanic    runtime.deferreturn

-d=defer=1 把每个 defer 分成 open-coded、stack-allocated、heap-allocated 三类。第 9 个 defer 起或出口太多就变成 stack-allocated(调用 deferprocStack,在栈上建记录),在循环里的 defer 才是 heap-allocated(调用 deferproc,需要运行时管理记录)。AllocsPerRun 在三种情形下都是 0——说明运行时有记录的复用,我没有去读它的实现。

关掉看看

实测 · 本机执行输出
  默认:
    noDefer                              中位 1.98 ns/op(最小 1.98 / 最大 2.07)
    oneDefer                             中位 1.99 ns/op(最小 1.95 / 最大 2.04)
    lockDefer                            中位 12.30 ns/op(最小 12.18 / 最大 16.23)
    eightDefers                          中位 15.63 ns/op(最小 15.55 / 最大 16.09)
    loopDefer                            中位 18.11 ns/op(最小 18.01 / 最大 19.04)
  -gcflags=-d=noopendefer=1(关闭 open-coded defer):
    noDefer                              中位 1.98 ns/op(最小 1.96 / 最大 1.99)
    oneDefer                             中位 13.23 ns/op(最小 13.15 / 最大 13.30)
    lockDefer                            中位 19.90 ns/op(最小 19.78 / 最大 20.40)
    eightDefers                          中位 54.05 ns/op(最小 53.57 / 最大 57.76)
    loopDefer                            中位 18.17 ns/op(最小 17.98 / 最大 19.13)

用 -d=noopendefer=1 关掉这项优化,同一份代码里每个 defer 多花约 11 ns,而循环里的 defer 不变,证明它本来就走另一条路径。9 个 defer 与 8 个 defer 之间那个 3.8 倍的跳变(15.27 → 58.47 ns)就是跨过 open-coded 限制的代价。

限制来自哪里

实测 · 本机执行输出
  walk/stmt.go:109-119
    		if ir.CurFunc.NumDefers > maxOpenDefers || n.DeferAt != nil {
    			// Don't allow open-coded defers if there are more than
    			// 8 defers in the function, since we use a single
    			// byte to record active defers.
    			// Also don't allow if we need to use deferprocat.
    			ir.CurFunc.SetOpenCodedDeferDisallowed(true)
    		}
    		if n.Esc() != ir.EscNever {
    			// If n.Esc is not EscNever, then this defer occurs in a loop,
    			// so open-coded defers cannot be used in this function.
    			ir.CurFunc.SetOpenCodedDeferDisallowed(true)
  ssagen/ssa.go:424-432
    	if s.hasOpenDefers &&
    		s.curfn.NumReturns*s.curfn.NumDefers > 15 {
    		// Since we are generating defer calls at every exit for
    		// open-coded defers, skip doing open-coded defers if there are
    		// too many returns (especially if there are multiple defers).
    		// Open-coded defers are most important for improving performance
    		// for smaller functions (which don't have many returns).
    		s.hasOpenDefers = false
    	}

编译器里这几条限制写得很清楚:defer 数用一个字节的位图记录,所以最多 8 个;循环里的 defer 次数运行时才知道,不能展开;出口数乘 defer 数超过 15 时展开的代码太多就放弃。实用的结论是:函数里少放 defer、不要在热循环里 defer;但没必要为了“省 defer”而去掉一个 defer mu.Unlock()。

分步演示

defer 的成本
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。每个函数 //go:noinline,基准测试 7 轮取中位数;分类来自 go build -gcflags=-d=defer=1,汇编来自 -S;读过的源码:cmd/compile/internal/walk/stmt.go、ssagen/ssa.go(限制条件)
    • 没有验证:amd64 与其它版本的数字、defer 记录复用的运行时实现(deferpool 一类)、recover 路径为什么需要约 145 ns、带闭包捕获变量的 defer 的额外开销、loop 里 defer 的 N 次迭代成本随 N 的变化
    京ICP备2026067502号-1京公网安备11010502063487号