一次重构把循环里的显式调用改成了 defer,本意是「不管从哪条分支出去都能收尾」。上线后日志全乱了:一百条任务记录,打印出来的任务编号都是最后一个。
中间那九十九个编号一次都没出现。代码看着完全合理,defer 也确实执行了,只是它执行时用到的参数不是循环当前的那一份。
Go 官方博客对此有一句话讲得很清楚,只是写代码的时候很少有人会把它和「参数」两个字联系起来。
defer 到底在什么时候执行?
官方博客对 defer 的定义是:
A defer statement pushes a function call onto a list. The list of saved calls is executed after the surrounding function returns.
关键词有两个:push 和 list。defer 不是立刻执行,而是把这次调用压进一个列表,等外层函数返回之后再统一处理。
这个列表是按函数维度算的。同一个函数里写几个 defer,就攒几个调用。
正因为是压栈,取出来的时候自然是后进先出:
Deferred function calls are executed in Last In First Out order after the surrounding function returns.
后进先出不是实现细节,而是语言规定的语义。它带来的直接好处是资源释放顺序天然和申请顺序相反:先打开的后关闭,先加锁的后解锁。
go
for i := 0; i < 4; i++ {
defer fmt.Print(i)
}
按倒序执行,这段代码打印的是 3210,不是 0123。
| 规则 | 官方表述的落点 | 代码里看到的现象 |
|---|---|---|
| 执行时机 | 压入列表,函数返回后执行 | 函数没走到返回就不执行 |
| 执行顺序 | 后进先出 | 循环里的 defer 倒着跑 |
| 参数求值 | 在 defer 语句处求值 | 参数被固定在写下那一刻 |
| 命名返回值 | 可读也可赋值 | defer 能改掉最终返回值 |
参数为什么在写下的那一刻就固定?
这是几条规则里最容易踩的一条:
A deferred function's arguments are evaluated when the defer statement is evaluated.
注意主语是 arguments,不是函数体。被延后的是「调用这个动作」,不是「算出这些参数」。
defer 语句执行到的那一瞬间,参数表达式就要算出结果并存起来,之后无论外部变量怎么变,存下来的那份都不动了。
官方博客给的最小例子:
go
func trace() {
i := 0
defer fmt.Println(i)
i++
return
}
打印出来是 0。i++ 明明发生在返回之前,但 fmt.Println 拿到的实参在 defer 那一行就已经是 0 了。后面的自增只改了变量,改不了已经存好的实参。
回到开头那个循环。每写一次 defer fmt.Println(task.ID),压进列表的是「用当前 ID 调用」这个动作,但同时 task.ID 也被当场读出来存好了。
| 写法 | 实际输出 | 原因 |
|---|---|---|
defer fmt.Println(i) 之后再 i++ |
0 |
参数在 defer 行就求值 |
循环里 defer fmt.Print(i) |
3210 |
参数逐次固定,执行倒序 |
defer func() { i++ }() 配合命名返回值 |
返回值加一 | 闭包没传参,读到的是变量本身 |
循环里 defer func(id int) { ... }(task.ID) |
每条各自的编号 | 参数在 defer 行求值,取的是当前这一条 |
第三行是另一个方向:不传参时,闭包体内引用的是变量本身而不是值的副本,读到的就是执行时刻的最新值。参数求值和闭包捕获是两套不同的机制,别混着记。
第四行是循环里的正解。task.ID 仍然在 defer 那一行求值,但求出来的是当前这一条,不是最后一条。
这套「规则 / 官方原文 / 代码现象」的对照表是我用 AI墨衍 整理成模板的,几个仓库共用一份,加新条目时往表里补一行就行。
命名返回值是怎么被 defer 改掉的?
第三条规则解释为什么有些 defer 能改返回值,有些不能:
Deferred functions may read and assign to the returning function's named return values.
两个动词值得注意:read 和 assign。defer 里的函数不只能读到命名返回值,还能给它重新赋值。
而这个赋值发生在返回值真正交给调用方之前,所以调用方看到的就是改过的值。
go
func c() (i int) {
defer func() { i++ }()
return 1
}
返回值是 2 而不是 1。return 1 先把 1 赋给命名返回值 i,然后 defer 列表开始执行,i++ 把它变成 2,最后才真正返回。
这条规则生效的前提是返回值有名字。签名写成 (i int),i 就是一个普通变量,defer 改得动。签名写成光秃秃的 int,返回值没有名字,defer 里根本拿不到它。
recover 能生效也靠这条规则。捕获到 panic 之后想把函数改成正常返回,唯一的通道就是给命名返回值赋值。
panic 之后 recover 为什么只在 defer 里管用?
Recover is only useful inside deferred functions. During normal execution, a call to recover will return nil and have no other effect.
这句话把 recover 的适用范围限得很死:它必须处在 defer 调用的函数里,而且必须是在 panic 展开栈的过程中被执行。
普通执行路径上调它,返回值是 nil,除此之外什么都不会发生。
所以 recover 单独出现在业务函数第一行是没有意义的。它得包在 defer func() { ... }() 里,而且要保证这个 defer 在 panic 发生之前就已经压进了列表。
go
defer func() {
if r := recover(); r != nil {
// 只有压在这里,recover 才有机会拿到值
}
}()
这也解释了为什么 recover 经常和命名返回值一起出现:捕获之后要告诉调用方「这次没崩,但请当成错误处理」,通道只有命名返回值。
循环里还能放心用 defer 吗?
能,但要区分两种代价。第一种是参数被固定,那是语义问题,一定会发生;第二种是列表堆积,只在特定数据量下才有影响。
循环里每写一次 defer,就多一个待执行的调用压在列表里,直到函数返回才统一执行。如果循环跑十万次,那就是十万个待执行调用。
| 场景 | 推荐写法 | 理由 |
|---|---|---|
| 循环体内单个资源 | 抽成独立函数,函数内 defer | 每次调用结束即可释放 |
| 短循环、少量迭代 | 直接 defer,参数显式传入 | 代码最直观,读起来没负担 |
| 长循环、十万级迭代 | 显式调用关闭,或收集后统一处理 | 避免堆积大量待执行调用 |
| 需要在循环中捕获 panic | 闭包里 defer 加 recover | 范围收敛在单次迭代内 |
第一行和第二行的区别只在代码组织,语义完全一样。第三行才是真正需要警惕的位置,它不是参数求值的问题。
这两件事经常被混为一谈。看到「循环里别用 defer」这种说法,先问一句是哪个原因:是参数取错了,还是列表撑大了。两种情况对应两种改法。
小结
三件事:defer 把调用压进列表、参数在写下那一刻求值、命名返回值可以被改。
第一条决定什么时候执行,第二条决定循环里为什么打出来的都是同一个值,第三条决定 recover 和返回值改写能不能成立。
它们都不靠经验,而是靠语义。写 defer 的时候多看一眼括号里的参数,大部分坑都不会发生。