Go 语言中的 defer:常见用法与避坑指南
写 Go 的人几乎每天都在用 defer:打开文件要 Close,加了锁要 Unlock,拿到 HTTP 响应要 Body.Close()。它看起来只是个「延迟执行」的关键字,但它的求值时机、执行顺序、以及和 return 的配合方式,都有一套非常明确的规则。不了解这些规则,很容易写出「资源迟迟不释放」「锁根本没加上」「拿到的永远是旧值」这类隐蔽 bug。
本文先把 defer 的三条核心规则讲清楚,再过一遍高频用法,最后按「坑」逐条排查,每一条都配上可直接运行的最小示例。
1. defer 是什么
defer 语句把一个函数调用推迟到当前函数返回之前执行。看一个最小示例:
1 | package main |
三点说明:
defer只能写在函数体内部,写在包级别直接编译不过- 即使函数中途
panic,已经注册的defer依然会执行——这是recover能工作的前提 - 一个函数里可以注册多个
defer
2. 三条核心规则
规则一:多个 defer,后进先出(LIFO)
defer 的执行顺序像栈:最后注册的最先执行。
1 | func main() { |
输出:
1 | 函数体跑完了,准备返回 |
所以注册顺序要和释放顺序相反:先打开 A 再打开 B,就应该先写 defer A.Close(),再写 defer B.Close()——B 先关、A 后关,与获取顺序正好相反,符合依赖关系。
规则二:参数在 defer 语句执行的那一刻就求值
真实的调用被推迟了,但函数和参数的值在写 defer 那一行时就被计算并保存下来。
1 | func main() { |
输出:
1 | 返回前的 i = 100 |
如果想让延迟函数看到「最新值」,套一层闭包:
1 | func main() { |
输出 闭包看到的 i = 100。
一句话记住:参数是拍快照,闭包引用是看直播。 方法调用同理,值接收者在 defer 语句处就会被求值并拷贝一份。
规则三:在「函数返回前」执行,而不是「作用域结束时」
这一点和 C++ 的 RAII、Swift 的 defer 都不同:for 循环一次迭代结束并不会触发 defer,它必须等到整个函数返回。这是后面「坑 1」的根源,先记住这条规则。
3. return 的三步走:defer 和返回值谁先谁后
带 defer 的函数里,一条 return 语句实际分三步执行:
- 给返回值赋值
- 执行所有
defer - 真正返回
对比两个经典例子:
1 | func f() (result int) { // 命名返回值 |
f()返回 6:return 5先把result赋成 5,然后 defer 把它加一,最后返回g()返回 5:return x已经把匿名返回值定成 5,defer 改的是局部变量x,影响不到返回值
所以:想用 defer 改写返回值(统一给 error 补上下文、失败时清理半成品),必须使用命名返回值。一个实用片段:
1 | func loadConfig(path string) (cfg Config, err error) { |
4. 常见用法
4.1 释放资源:紧跟在获取动作后面写
这是 defer 的头号用途。标准模板永远是「获取 → 判 err → defer 释放」:
1 | f, err := os.Open(path) |
同一招适用于 mu.Unlock()、resp.Body.Close()、rows.Close()。
4.2 计时与打点
1 | func handle() { |
好处是入口处一次注册,任何分支提前 return 都能记到耗时。
4.3 临时改动全局状态,用完恢复
测试里最常见:临时替换全局配置,测试结束恢复原样:
1 | var config = struct { |
4.4 配合 recover 兜底 panic
1 | defer func() { |
注意 recover 必须放在 defer 直接调用的函数里才生效,也只能捕获同一个 goroutine 的 panic。这部分展开内容较多,之前单独写过一篇,细节见《Go 里的 recover:如何优雅地兜底 panic》。
5. 避坑指南
坑 1:循环里 defer,资源全堆到函数结束才释放
1 | func process(files []string) error { |
10 万个文件意味着同时打开 10 万个句柄,很容易撞上 too many open files。正确做法是把「单个文件」的处理抽成函数,让 defer 在每次处理结束时立刻生效:
1 | func processOne(name string) error { |
坑 2:err 还没判断,就先 defer f.Close()
1 | f, err := os.Open(path) |
打开失败时 f 是 nil,defer 注册的 nil.Close() 会在 return 时触发,把一个普通错误放大成 panic。修复方式就是 4.1 的模板:先判 err,再写 defer。
坑 3:defer mu.Lock()——锁根本没加上
1 | func bad() { |
defer 延迟的是 Lock 这个调用本身,于是整个函数体都在没锁的状态下跑,返回时才加锁,而且再也不会 Unlock。正确姿势:
1 | func good() { |
顺带一个变体:写了 defer mu.Unlock() 之后又在函数中途手动 mu.Unlock(),返回时 defer 再解锁一次,直接 panic: sync: unlock of unlocked mutex。同一个锁要么全程交给 defer 管,要么自己管,别混着来。
坑 4:以为拿到的是最新值,其实是快照
规则二在循环里的样子:
1 | for i := 1; i <= 3; i++ { |
每个 defer 保存的都是当轮 i 的快照(1、2、3),最后按 LIFO 输出 3 2 1。注意:即使 Go 1.22 之后循环变量每轮迭代都是新变量,这条也不变——那改变的是闭包捕获语义,defer 参数的求值时机始终是 defer 语句执行的那一刻。想拿到「函数退出那一刻的值」,套一层闭包即可。
坑 5:os.Exit / log.Fatal 会跳过所有 defer
1 | func main() { |
os.Exit 直接终止进程,不执行任何 defer;log.Fatal 内部就是 os.Exit(1),同样跳过。外部 kill -9、断电就更不用说了。所以 main 里必须在退出前完成的清理(flush 缓冲、删临时文件),要在 os.Exit 之前显式调用,别指望 defer。
坑 6:defer 只属于注册它的那个 goroutine
1 | func main() { |
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、参数拍快照、函数返回前才执行——上面所有的坑都能自己推导出来。
参考
- The Go Blog:Defer, Panic, and Recover
- Go 语言规范:Defer statements
- Effective Go:defer
- 本站:《Go 里的 recover:如何优雅地兜底 panic》