写 Go 的人几乎每天都在用 defer:打开文件要 Close,加了锁要 Unlock,拿到 HTTP 响应要 Body.Close()。它看起来只是个「延迟执行」的关键字,但它的求值时机、执行顺序、以及和 return 的配合方式,都有一套非常明确的规则。不了解这些规则,很容易写出「资源迟迟不释放」「锁根本没加上」「拿到的永远是旧值」这类隐蔽 bug。

本文先把 defer 的三条核心规则讲清楚,再过一遍高频用法,最后按「坑」逐条排查,每一条都配上可直接运行的最小示例。

1. defer 是什么

defer 语句把一个函数调用推迟到当前函数返回之前执行。看一个最小示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"fmt"
"os"
)

func main() {
f, err := os.Open("demo.txt")
if err != nil {
fmt.Println("打开失败:", err)
return
}
// 注册:main 返回前自动执行 f.Close()
defer f.Close()

fmt.Println("正常处理文件...")
}

三点说明:

  • defer 只能写在函数体内部,写在包级别直接编译不过
  • 即使函数中途 panic,已经注册的 defer 依然会执行——这是 recover 能工作的前提
  • 一个函数里可以注册多个 defer

2. 三条核心规则

规则一:多个 defer,后进先出(LIFO)

defer 的执行顺序像栈:最后注册的最先执行。

1
2
3
4
5
6
func main() {
defer fmt.Println("1 号 defer")
defer fmt.Println("2 号 defer")
defer fmt.Println("3 号 defer")
fmt.Println("函数体跑完了,准备返回")
}

输出:

1
2
3
4
函数体跑完了,准备返回
3 号 defer
2 号 defer
1 号 defer

所以注册顺序要和释放顺序相反:先打开 A 再打开 B,就应该先写 defer A.Close(),再写 defer B.Close()——B 先关、A 后关,与获取顺序正好相反,符合依赖关系。

规则二:参数在 defer 语句执行的那一刻就求值

真实的调用被推迟了,但函数和参数的值在写 defer 那一行时就被计算并保存下来。

1
2
3
4
5
6
func main() {
i := 0
defer fmt.Println("defer 保存的快照 i =", i) // 此刻 i 是 0,快照就是 0
i = 100
fmt.Println("返回前的 i =", i)
}

输出:

1
2
返回前的 i = 100
defer 保存的快照 i = 0

如果想让延迟函数看到「最新值」,套一层闭包:

1
2
3
4
5
func main() {
i := 0
defer func() { fmt.Println("闭包看到的 i =", i) }() // 执行时才取 i
i = 100
}

输出 闭包看到的 i = 100。

一句话记住:参数是拍快照,闭包引用是看直播。 方法调用同理,值接收者在 defer 语句处就会被求值并拷贝一份。

规则三:在「函数返回前」执行,而不是「作用域结束时」

这一点和 C++ 的 RAII、Swift 的 defer 都不同:for 循环一次迭代结束并不会触发 defer,它必须等到整个函数返回。这是后面「坑 1」的根源,先记住这条规则。

3. return 的三步走:defer 和返回值谁先谁后

带 defer 的函数里,一条 return 语句实际分三步执行:

  1. 给返回值赋值
  2. 执行所有 defer
  3. 真正返回

对比两个经典例子:

1
2
3
4
5
6
7
8
9
10
func f() (result int) { // 命名返回值
defer func() { result++ }()
return 5
}

func g() int { // 匿名返回值
x := 5
defer func() { x++ }()
return x
}
  • f() 返回 6:return 5 先把 result 赋成 5,然后 defer 把它加一,最后返回
  • g() 返回 5:return x 已经把匿名返回值定成 5,defer 改的是局部变量 x,影响不到返回值

所以:想用 defer 改写返回值(统一给 error 补上下文、失败时清理半成品),必须使用命名返回值。一个实用片段:

1
2
3
4
5
6
7
8
func loadConfig(path string) (cfg Config, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("load config %s: %w", path, err)
}
}()
return cfg, readAll(path)
}

4. 常见用法

4.1 释放资源:紧跟在获取动作后面写

这是 defer 的头号用途。标准模板永远是「获取 → 判 err → defer 释放」:

1
2
3
4
5
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()

同一招适用于 mu.Unlock()、resp.Body.Close()、rows.Close()。

4.2 计时与打点

1
2
3
4
5
6
7
func handle() {
start := time.Now()
defer func() {
log.Printf("handle 耗时 %s", time.Since(start))
}()
// 业务逻辑...
}

好处是入口处一次注册,任何分支提前 return 都能记到耗时。

4.3 临时改动全局状态,用完恢复

测试里最常见:临时替换全局配置,测试结束恢复原样:

1
2
3
4
5
6
7
8
9
10
var config = struct {
Timeout time.Duration
}{Timeout: 30 * time.Second}

func TestPing(t *testing.T) {
old := config.Timeout
config.Timeout = time.Second
defer func() { config.Timeout = old }() // 保证恢复
// 测试逻辑...
}

4.4 配合 recover 兜底 panic

1
2
3
4
5
defer func() {
if r := recover(); r != nil {
log.Printf("捕获 panic: %v", r)
}
}()

注意 recover 必须放在 defer 直接调用的函数里才生效,也只能捕获同一个 goroutine 的 panic。这部分展开内容较多,之前单独写过一篇,细节见《Go 里的 recover:如何优雅地兜底 panic》。

5. 避坑指南

坑 1:循环里 defer,资源全堆到函数结束才释放

1
2
3
4
5
6
7
8
9
10
11
12
13
func process(files []string) error {
for _, name := range files {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // 坏!要等 process 返回才统一关闭
if err := handle(f); err != nil {
return err
}
}
return nil
}

10 万个文件意味着同时打开 10 万个句柄,很容易撞上 too many open files。正确做法是把「单个文件」的处理抽成函数,让 defer 在每次处理结束时立刻生效:

1
2
3
4
5
6
7
8
func processOne(name string) error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // processOne 一返回就关闭
return handle(f)
}

坑 2:err 还没判断,就先 defer f.Close()

1
2
3
4
5
f, err := os.Open(path)
defer f.Close() // 坏!
if err != nil {
return err // f 是 nil,返回前 nil.Close() 直接 panic
}

打开失败时 f 是 nil,defer 注册的 nil.Close() 会在 return 时触发,把一个普通错误放大成 panic。修复方式就是 4.1 的模板:先判 err,再写 defer。

坑 3:defer mu.Lock()——锁根本没加上

1
2
3
4
5
func bad() {
var mu sync.Mutex
defer mu.Lock() // 坏!锁要到函数返回时才加,临界区完全没被保护
// ... 这里其实是「无锁」执行的
}

defer 延迟的是 Lock 这个调用本身,于是整个函数体都在没锁的状态下跑,返回时才加锁,而且再也不会 Unlock。正确姿势:

1
2
3
4
5
6
func good() {
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
// ... 临界区
}

顺带一个变体:写了 defer mu.Unlock() 之后又在函数中途手动 mu.Unlock(),返回时 defer 再解锁一次,直接 panic: sync: unlock of unlocked mutex。同一个锁要么全程交给 defer 管,要么自己管,别混着来。

坑 4:以为拿到的是最新值,其实是快照

规则二在循环里的样子:

1
2
3
for i := 1; i <= 3; i++ {
defer fmt.Println("快照:", i)
}

每个 defer 保存的都是当轮 i 的快照(1、2、3),最后按 LIFO 输出 3 2 1。注意:即使 Go 1.22 之后循环变量每轮迭代都是新变量,这条也不变——那改变的是闭包捕获语义,defer 参数的求值时机始终是 defer 语句执行的那一刻。想拿到「函数退出那一刻的值」,套一层闭包即可。

坑 5:os.Exit / log.Fatal 会跳过所有 defer

1
2
3
4
5
6
func main() {
defer fmt.Println("清理资源") // 永远不会执行
if failed {
os.Exit(1)
}
}

os.Exit 直接终止进程,不执行任何 defer;log.Fatal 内部就是 os.Exit(1),同样跳过。外部 kill -9、断电就更不用说了。所以 main 里必须在退出前完成的清理(flush 缓冲、删临时文件),要在 os.Exit 之前显式调用,别指望 defer。

坑 6:defer 只属于注册它的那个 goroutine

1
2
3
4
5
6
7
func main() {
go func() {
defer fmt.Println("子 goroutine 的 defer")
// ...
}()
// main 很快返回,子 goroutine 连同它的 defer 一起被带走
}

main 返回(进程结束)时,其它 goroutine 会被直接终止,它们的 defer 没有机会执行;recover 也只能捕获同一个 goroutine 的 panic。跨 goroutine 的收尾要靠 sync.WaitGroup、context 取消这类机制来配合。

坑 7:为了性能不用 defer?大可不必

早期版本(Go 1.13 之前)defer 确实有额外开销,但 Go 1.14 引入 open-coded defer 之后,常见场景的开销已经接近一次普通函数调用。为了省这点开销在循环里手动 Close、漏掉异常路径,才是真正的 bug 来源。真正值得优化的还是坑 1 那种「defer 堆积导致资源延迟释放」,而不是 defer 本身。

6. 小结:一份 defer 检查清单

发布前过一遍:

  • 获取资源后下一行就写 defer,并且先判断 err(坑 2)
  • 循环里获取的资源,抽成独立函数再 defer(坑 1)
  • 延迟函数想看到最新值,用闭包(规则二、坑 4)
  • 锁的正确姿势是先 Lock 再 defer Unlock,永远不要 defer Lock(坑 3)
  • 想让 defer 改写返回值,必须用命名返回值(第 3 节)
  • os.Exit / log.Fatal 之前,重要清理要显式执行(坑 5)
  • recover 只能放在 defer 直接调用的函数里(用法 4.4)

把三条规则记牢——LIFO、参数拍快照、函数返回前才执行——上面所有的坑都能自己推导出来。

参考