Go 的 defer 是怎么实现的?为什么它会影响性能?

#Go#面试八股

Go 的 defer 是怎么实现的?为什么它会影响性能?

defer 常用,但它的实现方式在 Go 1.14 前后有本质变化:从"全部堆分配"变成"开放编码(open-coded)", 这也解释了为什么老代码里"defer 很慢"的结论现在不成立了。

1. 三种实现

版本/场景实现开销
Go ≤1.13堆分配 _defer 结构挂链表每次 defer 一次分配
Go 1.14+开放编码:defer 少且无循环时内联到函数里接近零成本
循环内/数量不定退回堆分配(deferprocStack 栈上分配)仍有成本

开放编码的条件(编译器判断):

  • defer 数量 ≤ 8;
  • 不在循环里;
  • 不是 defer 可变数量(如 defer f() 在循环中)。

满足时,编译器在函数里直接展开成"正常路径 + 异常路径"两段代码,不需要建链表。

2. 执行顺序与返回值陷阱

func f() (n int) {
    defer func() { n++ }()   // 能改返回值:因为 n 是具名返回值
    return 1                 // 先给 n 赋值 1,再执行 defer → 返回 2
}
func g() int {
    n := 0
    defer func() { n++ }()   // 改的是局部变量,返回值早已确定
    return n                 // 返回 0
}

关键:return 不是原子操作,分两步——给返回值赋值 → 执行 defer。所以具名返回值能被 defer 改。

3. defer + recover 的位置

func safe() {
    defer func() {
        if r := recover(); r != nil { log.Println("recovered:", r) }
    }()
    panic("boom")
}

recover 只在 defer 中直接调用才生效;包一层函数(defer helper() 里再 recover)不行。

4. 什么时候仍然要避免 defer

  1. 超热路径(每秒百万次):即使是开放编码,也有额外指令与栈帧开销,benchmark 见真章;
  2. 循环里 defer:会退回堆分配,且直到函数返回才执行——常见的"文件句柄泄漏"就是这样来的:
    for _, f := range files {
        defer f.Close()    // 全部攒到函数结束才关,可能耗尽 fd
    }
    
  3. defer 里做重活:它延后执行,容易掩盖耗时。

5. 一个 benchmark 结论

defer 无竞争、非循环、数量少 → 与直接调用差距在 1~3ns 级别(数量级相同)
循环内 defer / 老版本      → 每次多一次分配,差距可达 10 倍以上

所以实践建议:正常业务代码放心用 defer(释放锁、关文件、recover), 只有确认它是热点(pprof 里占比高)时再手动展开。

小结

defer 的性能问题基本是"历史印象":Go 1.14 的开放编码把常见场景的成本降到了接近零。 真正要注意的是语义——具名返回值会被 defer 改写、循环里 defer 会延迟到函数结束, 以及 recover 必须在 defer 中直接调用。

笔记加载中…