1. 起因

defer 是 Go 里用得最顺手的关键字之一,写 defer file.Close() 几乎成了肌肉记忆。但用久了发现它的执行时机和参数求值规则跟直觉不完全一致,记录几个真实踩过的坑。

2. defer 的参数是立即求值的,不是延迟求值

1
2
3
4
5
6
7
8
9
10
func process() {
i := 0
defer fmt.Println("i =", i) // 这里的 i 在 defer 语句执行时就已经被求值了

i = 10
fmt.Println("done")
}
// 输出:
// done
// i = 0

很多人第一次看到这个输出会愣一下,以为 defer 里的表达式是等到函数返回前才求值的,其实不是——defer 语句执行的那一刻,函数的参数就已经被计算并且拷贝下来了,只有函数体本身的执行被延迟。如果想要拿到最新的值,得用闭包:

1
2
3
defer func() {
fmt.Println("i =", i) // 闭包引用的是变量本身,不是拷贝的值
}()

3. 循环里的 defer,资源不会及时释放

1
2
3
4
5
6
7
8
9
10
11
func processFiles(paths []string) error {
for _, path := range paths {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // 不会在每次循环结束时执行,而是等到 processFiles 整个函数返回
// ... 处理文件
}
return nil
}

defer 是绑定到所在函数的,不是绑定到 for 循环的某一次迭代。如果 paths 有一千个文件,这一千个文件句柄会一直开着,直到 processFiles 整个函数执行完才会统一关闭。文件数量少还好,但如果处理的是几千上万个文件,很容易触发”too many open files”。

正确写法是把单次处理逻辑拆到一个独立函数里,让 defer 在每次循环内就能触发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func processFiles(paths []string) error {
for _, path := range paths {
if err := processOne(path); err != nil {
return err
}
}
return nil
}

func processOne(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // 每次调用 processOne 返回时就会执行
// ... 处理文件
return nil
}

4. defer 修改命名返回值,容易掩盖错误

1
2
3
4
5
6
7
8
9
func doSomething() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r) // 修改的是命名返回值 err
}
}()

return riskyOperation()
}

这个用法本身没问题,是 Go 里用 defer + recover 兜底 panic 的标准写法。但坑在于如果团队里有人不知道这个规则,看到 return riskyOperation() 会以为函数一定返回 riskyOperation() 的结果,而实际上如果 riskyOperation panic 了,最终返回值会被 defer 里的逻辑悄悄改写。调试的时候现象是”日志里明明打印了 panic,但上层调用方拿到的错误信息完全对不上”,容易让人摸不着头脑。养成的习惯是:只要函数里有 defer 操作命名返回值,一定要在函数开头写注释说明。

5. defer 的执行顺序是后进先出(LIFO)

1
2
3
4
5
6
func demo() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
}
// 输出:3 2 1

这个规则本身不难记,容易翻车的地方是”加锁解锁”这类需要严格顺序的场景,多个 defer Unlock() 叠在一起时,如果锁的粒度设计得不对,LIFO 顺序可能跟你以为的加锁顺序对不上,导致死锁排查起来更绕。多层嵌套加锁的场景,宁可显式调用 Unlock(),也不要指望 defer 的顺序帮你兜底逻辑正确性。

6. 小结

defer 用起来简单,但它的参数求值时机、绑定的作用域范围、执行顺序这三点如果没吃透,很容易写出”编译能过、跑起来也不报错,但行为跟预期不一致”的代码。这类问题在测试环境数据量小的时候基本不会暴露,一旦上了量(大量文件、大量循环)才会现形,排查成本比一般的 bug 高不少。