1. 起因
defer 是 Go 里用得最顺手的关键字之一,写 defer file.Close() 几乎成了肌肉记忆。但用久了发现它的执行时机和参数求值规则跟直觉不完全一致,记录几个真实踩过的坑。
2. defer 的参数是立即求值的,不是延迟求值
1 | func process() { |
很多人第一次看到这个输出会愣一下,以为 defer 里的表达式是等到函数返回前才求值的,其实不是——defer 语句执行的那一刻,函数的参数就已经被计算并且拷贝下来了,只有函数体本身的执行被延迟。如果想要拿到最新的值,得用闭包:
1 | defer func() { |
3. 循环里的 defer,资源不会及时释放
1 | func processFiles(paths []string) error { |
defer 是绑定到所在函数的,不是绑定到 for 循环的某一次迭代。如果 paths 有一千个文件,这一千个文件句柄会一直开着,直到 processFiles 整个函数执行完才会统一关闭。文件数量少还好,但如果处理的是几千上万个文件,很容易触发”too many open files”。
正确写法是把单次处理逻辑拆到一个独立函数里,让 defer 在每次循环内就能触发:
1 | func processFiles(paths []string) error { |
4. defer 修改命名返回值,容易掩盖错误
1 | func doSomething() (err error) { |
这个用法本身没问题,是 Go 里用 defer + recover 兜底 panic 的标准写法。但坑在于如果团队里有人不知道这个规则,看到 return riskyOperation() 会以为函数一定返回 riskyOperation() 的结果,而实际上如果 riskyOperation panic 了,最终返回值会被 defer 里的逻辑悄悄改写。调试的时候现象是”日志里明明打印了 panic,但上层调用方拿到的错误信息完全对不上”,容易让人摸不着头脑。养成的习惯是:只要函数里有 defer 操作命名返回值,一定要在函数开头写注释说明。
5. defer 的执行顺序是后进先出(LIFO)
1 | func demo() { |
这个规则本身不难记,容易翻车的地方是”加锁解锁”这类需要严格顺序的场景,多个 defer Unlock() 叠在一起时,如果锁的粒度设计得不对,LIFO 顺序可能跟你以为的加锁顺序对不上,导致死锁排查起来更绕。多层嵌套加锁的场景,宁可显式调用 Unlock(),也不要指望 defer 的顺序帮你兜底逻辑正确性。
6. 小结
defer 用起来简单,但它的参数求值时机、绑定的作用域范围、执行顺序这三点如果没吃透,很容易写出”编译能过、跑起来也不报错,但行为跟预期不一致”的代码。这类问题在测试环境数据量小的时候基本不会暴露,一旦上了量(大量文件、大量循环)才会现形,排查成本比一般的 bug 高不少。