深入理解Go defer(上):基本使用与行为解析

0 阅读6分钟

defer是Go中最常用、也最容易被“误用”的关键字之一 会用 ≠ 真正理解,很多看似“诡异”的行为,其实背后都有非常确定、严谨的执行方式

本文基于Go1.25.5源码与行为表现,从使用结果出发,自顶向下、由浅入深地拆解defer

  • defer是什么
  • defer的执行时机与顺序
  • defer与闭包、return、panic的关系
  • defer常见但容易踩坑的行为

说明:

  • 本文是defer系列的第一篇,聚焦使用与行为结果
  • 原理与runtime源码分析将在下一篇单独展开

1.defer是什么

官方语义一句话

defer用来将一个函数调用延迟到当前函数返回之前执行, 并且无论函数是正常 return,还是发生 panic,都会执行

几个关键词非常重要:

  • 函数返回之前(不是函数结束之后)
  • 一定会执行(panic 场景也一样)
  • 延迟的是一次函数调用,而不是一段代码

2.defer的基本使用

在深入runtime之前,我们先通过几个非常经典的例子,建立defer的基本认知

2.1例一(defer的参数求值时机)

func main() {
    var whatever [3]struct{}
    for i := range whatever {
       defer func() {
          fmt.Println(i)
       }()
    }
}

这个案例的结果在golang不同的版本会有不同的结构,在golang1.22之前打印的结果是:

2
2
2

golang1.22及之后做了优化,但是在这里并不是讨论这个问题,在这里想告诉大家的是defer关键字后面跟的函数在执行的时候,函数调用的参数会被保存起来,也就是复制了一份。真正执行的时候,实际上用到的是这个复制的变量,因此如果此变量是一个“值”,那么就和定义的时候是一致的。如果此变量是一个“引用”,那就可能和定义的时候不一致(很显然这里存在闭包的问题) 关键结论:

  • defer后面的函数调用在注册时就完成了参数绑定
  • 真正执行defer时,使用的是当时保存下来的那份“参数/环境”
  • 如果捕获的是值,结果固定
  • 如果捕获的是引用,则可能随着后续变化而变化(闭包问题)

在这里只是展示了defer其中的一个特性,接下来会用其他的例子来展示defer其他的特性

2.2例二(defer的执行顺序:后进先出)

type number int

func (n number) print() {
    fmt.Println(n)
}

func (n *number) pprint() {
    fmt.Println(*n)
}

func main() {
    var n number
    defer n.print()
    defer n.pprint()
    defer func() {
       n.print()
    }()
    defer func() {
       n.pprint()
    }()
    n = 3
}

先说结论打印结果是:

3
3
3
0

根据这个执行结果,可以知道defer的第二个特性:defer关键字执行的顺序是先进后出(LIFO),具体打印值的原因:

  1. 第一个3打印:第四个defer先执行,因为闭包的原因所以打印3
  2. 第二个3打印:第三个defer执行,原因同上
  3. 第三个3打印:第二个defer执行,因为这里的n是一个指针,简单来说拷贝了一个引用进去指向了同一块内存地址,所以打印3
  4. 0打印:第一个defer执行,这里在直接拷贝了n的值,因为在最开始的时候n的值是0,所以打印的也是0

接下来再来看看defer的第三个特性

2.3例三(return之后的defer不会执行)

func main() {
    defer func() {
       fmt.Println("1")
    }()
    if true {
       fmt.Println("2")
       return
    }
    defer func() {
       fmt.Println("3")
    }()
}

上面代码的执行结果:

2
1

结论:

  • defer必须在return之前注册
  • return后的defer语句根本没有机会被注册

2.4例四(return的三步模型)

func main() {
    fmt.Println(f())
}
func f() int {
    defer fmt.Println("defer")
    return 1
}

这里的打印结果:

defer
1

这也说明了return的一个特性就是在一个函数的返回过程当中其实分了三步:

  1. 计算return表达式
  2. 执行所有defer(LIFO)
  3. 函数真正返回(RET)

基于这个特性,那么defer在实际使用中就会有一些骚操作,defer可以修改返回值,下面有一个命名返回值的示例

func main() {
    fmt.Println(f())
}
func f() (x int) {
    defer func() {
       x++
    }()
    return 1
}

上面代码块中的打印结果:

2

让我们来拆解一下返回的过程 4. 进入函数:x=0 5. 执行return 1,这里只是赋值:x=1 6. 执行defer:x++ 7. RET:x->2

return 对命名返回值来说,只是一次赋值 defer 有机会在真正返回前“最后修改”

相反我们来看另外一个无名返回值例子

func main() {
    fmt.Println(f())
}
func f() int {
    x := 1
    defer func() {
       x++
    }()
    return x
}

上面代码块执行结果:

1

这次在defer中却没有修改x的值,来给大家分析一下原因:

  1. 进入函数x:=1
  2. 执行return temp = x
  3. 执行defer:x++
  4. RET:返回temp

说明:

  • defer 并没有修改返回值
  • 修改的只是一个已经不再参与返回的局部变量

这一点和defer本身关系不大,而是Go返回值模型的特性

3. defer的进阶使用示例

在真实工程中,defer 很少只是用来 Println,更多时候它承担的是资源治理、异常兜底、状态修复等职责,下面补充几个非常经典、也非常“工程化”的使用示例

3.1例一(defer + panic / recover)

func safeExecute() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recover panic:", r)
        }
    }()

    panic("something wrong")
}

执行结果:

recover panic: something wrong

关键结论:

  • recover 只能在defer函数中生效
  • recover只能捕获当前 goroutine、当前调用链上的panic
  • panic 并不是异常,而是一种强制的栈展开机制

在工程中,recover 往往用于:

  • HTTP / RPC 框架的统一兜底
  • goroutine 入口的防御性保护
  • 防止单个请求击穿整个进程

为什么recover必须和defer搭配使用

func badRecover() {
    if r := recover(); r != nil {
        fmt.Println(r)
    }
}

上面代码永远无法生效,原因是:

  • recover的生效窗口只存在于defer调用过程中
  • runtime在panic栈展开时,只会检查defer链

这一点在 runtime 的_panic状态机中有非常明确的实现, 会在下篇中结合源码详细展开

3.3例二(defer + 资源释放)

func readFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()
    return nil
}
  • 资源获取与释放语义上强绑定
  • 避免多return分支遗漏释放逻辑
  • 即使后续panic,资源也能被正确回收 这也是 Go 官方在设计defer时,最核心的使用场景之一

3.4例三(defer + 锁)

func example(mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock()
    // do something
}

注意事项

  • defer解锁非常安全
  • 但在高频、极短函数中可能有性能成本
  • runtime在Go1.14+对defer做了大量优化,但并非零成本

4. 小结

在进入runtime之前,我们先总结defer的几个非常重要的行为特征:

  • defer延迟的是函数调用,不是表达式
  • defer的参数在注册时就已经确定
  • defer按LIFO顺序执行
  • defer在panic场景下一定会执行
  • return分三步执行,defer位于中间
  • 命名返回值可以被defer修改

到这里为止,我们只是站在语言行为的角度理解 defer。 下一篇文章中,我们将从runtime源码的角度,深入defer的创建、保存、执行与优化过程,包括:

  • _defer结构体的真实作用
  • deferreturn / panic / open-coded defer
  • 为什么recover必须在同一调用栈生效

至此,defer(上)完 👉 下一篇文章:深入理解 Go defer(下):编译器与runtime视角的实现原理