ELI5 · 说人话 · GO 底层

return 是三步,
不是一步

编译器把这一行拆开 return x 1 抄进返回槽 值在这一刻定死 2 逆序跑 defer 后写的先跑 3 RET 交卷 调用方拿到槽里的值 defer 只能在第 ② 格下手

defer 能不能改结果,只有一个判据:
它改的那个变量,是不是返回槽本身

先赋槽,再跑 defer,最后才返回

怪现象全从这里推出来

先记住三条

1

参数当场就求值写下 defer 那一刻就把值抄走了,只有函数体被推迟

2

后写的先跑同一个函数里的 defer 是一摞盘子,LIFO

3

函数返回时才跑不是 for 或 if 这个块结束时

这三条 + 上面那张三格图,defer 就没有别的秘密了

最核心的一张图

看返回值有没有名字

没名字 func f1() int x = 1 拷贝 返回槽 锁死为 1 defer x++ 返回 1 有名字 func f2() (x int) x 就是槽 只有一个盒子 defer x++ 返回 2

匿名返回值那边有两个盒子:defer 够得着左边那个,调用方读的是右边那个。具名返回值只有一个盒子,所以怎么改都算数。

四道题,都是同一条规则

猜猜返回什么

匿名→ 1
func f1() int {
    x := 1
    defer func(){ x++ }()
    // 改的是局部变量
    return x
    // 槽早抄成 1 了
}
具名→ 2
func f2() (x int) {
    x = 1
    defer func(){ x++ }()
    // x 就是返回槽
    return x
    // 槽=1 → x++ → 2
}
具名 + 字面量→ 6
func f4() (x int) {
    defer func(){ x++ }()
    return 5
    // 展开成:
    // x = 5; defer; ret
}
匿名 + 切片→ [100 2 3]
func f5() []int {
    s := []int{1, 2, 3}
    defer func(){ s[0] = 100 }()
    return s
    // header 拷走了,
    // 但数组是同一块
}

第四道最容易记反。对照这两行就懂了:
s = append(s, 4) ✗ 改的是变量,header 已经拷走了;
s[0] = 100 ✓ 改的是内容,两边指着同一块内存。
口诀:改内容有效,改变量无效。

五种情况,查表即可

调用方看得见吗

匿名 + 值类型defer 动的是局部变量

具名返回值defer 动的就是返回槽本身

匿名,但改的是内容*p = … · s[i] = … · m[k] = …,底层数据是共享的

匿名,重新赋值那个变量s = … · p = …,只动到局部的那份

具名,但在 defer 里写 return那个 return 只结束匿名函数,什么都没覆盖

最后一行值得单独记:defer 里没有「返回一个新值」这种语法,只有「赋值给具名返回槽」这一条路。

最高频的坑

什么时候读这个值

defer fmt.Println(x) 在 ① 就读完 → 1 defer func(){ …x… }() 拖到 ③ 才读 → 2 ① 注册 defer ② x = 2 ③ 返回,跑 defer 同一个 x,区别只在「什么时候读」
坏 · 计时器永远 0~0s
start := time.Now()

defer log.Printf("cost=%v",
    time.Since(start))
// 这里当场就算完了
好 · 整个表达式延迟真实耗时
start := time.Now()

defer func() {
    log.Printf("cost=%v",
        time.Since(start))
}()

方法接收者同理:defer obj.Close() 里的 obj 当场求值,值接收者还会当场拷贝一份。想延迟到执行时再读,就包一层 defer func(){ obj.Close() }()

作用域比你想的大

它等的是函数返回

循环里裸 defer 5 个句柄同时开着 函数返回才一起关 拆出一层小函数 handle() 一返回就关 同时最多开一个
坏 · 全堆到末尾句柄爆掉
for _, p := range paths {
    f, err := os.Open(p)
    if err != nil { return err }
    defer f.Close()
    use(f)
}
return nil
好 · 小作用域进出成对
func handle(p string) error {
    f, err := os.Open(p)
    if err != nil { return err }
    defer f.Close()
    return use(f)
}
// 循环里只调 handle(p)

同一个道理适用于 defer mu.Unlock()锁的持有时间 = 函数剩下的全部执行时间。函数一长就该拆临界区,而不是提前手写 mu.Unlock()——手写会在 panic 路径漏解锁。

recover 挑食

必须被 defer 直接调用

有效接住了
defer func() {
    if r := recover(); r != nil {
        // 就在 defer 的第一层
    }
}()
无效 · 两种返回 nil
defer func() {
    func(){ recover() }()
    // 隔了一层,接不住
}()

if r := recover(); …
// 不在 defer 里,永远 nil

panic 发生时,三格图里的第 ① 步根本没跑(没人给返回槽赋值),但 defer 照样跑。所以想把 panic 变成一个正常的 error,返回值必须具名——这是唯一的通道。

func do() (err error) { // ← 必须具名 defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic recovered: %v", r) } }() panic("boom") }

别赌

什么时候它不跑

照跑

  • 正常 return
  • panic不管有没有 recover
  • runtime.Goexit()

直接跳过

  • os.Exit(n)当场终止进程
  • log.Fatal / log.Fatalf内部就是 os.Exit(1)
  • 进程被 SIGKILL
  • goroutine 永久阻塞函数压根没返回

log.Fatal 会跳过所有 defer,这是线上丢数据的经典原因——缓冲没 flush、事务没回滚,日志倒是打出来了。要收尾,就把 error 一路返回到 main 再退出。

只有这四类值得用

defer 动返回值的正经场合

  1. 1

    panic 转 error上一节那段。没有第二种办法能把 panic 变成正常返回

  2. 2

    事务收尾panic 就回滚再原样抛回去,出错就回滚,都没有才 commit——而 commit 自己的错误也要写回返回值

    defer func() { if p := recover(); p != nil { _ = t.Rollback() panic(p) // 回滚后原样抛回去 } if err != nil { _ = t.Rollback(); return } err = t.Commit() // commit 的错误别吞 }()
  3. 3

    出口统一装饰函数有 8 个 return 分支时,这一招免去 8 次重复包装

    defer func() { if err != nil { err = fmt.Errorf("handle(%s): %w", id, err) metrics.Errors.Inc() } }()
  4. 4

    别吞掉 close 的错写操作的 Close() 才是真正 flush 落盘的地方,吞掉它 = 吞掉写失败。只读可以忽略,只写 / 读写必须处理。

    defer func() { if cerr := f.Close(); cerr != nil && err == nil { err = cerr // 只在没有更早的错时才覆盖 } }()

除了这四类,别用 defer 偷偷改业务返回值——调用方读函数签名和 return 那一行,完全预判不到。

别再为性能躲它

defer 早就不慢了

  1. ≤1.12

    最慢的年代defer 记录走堆分配,链表挂在 g 上。「defer 很慢」这个印象就是这时候留下的。

  2. 1.13

    改栈分配defer 记录挪到栈上,明显变快。

  3. 1.14+

    open-coded defer调用直接内联到函数出口,配一个 bitmap 标记哪些该跑,开销接近手写调用。

open-coded 的启用条件:函数内 defer 不超过 8 个不在循环体里return 数 × defer 数 不过大、编译器优化没被关掉(-gcflags="-N -l" 调试时会退化)。
所以「循环里的 defer」是双重反模式:既有正确性问题,又让优化失效。

Code review 直接打回

五条红线

循环里裸 defer

句柄堆到函数末尾才释放,还顺手关掉了编译器优化。拆出一个小函数

写文件 / 连接 defer x.Close() 不看错误

flush 失败就这么没了。具名返回值 + 在 defer 里回写 err

指望 log.Fatal 之后还能收尾

它就是 os.Exit(1),一个 defer 都不跑。把 error 返回到 main 再退出

recover() 隔了一层函数调用

返回 nil,panic 继续往上抛,自己还以为接住了。必须写在 defer 的第一层

defer 里的收尾代码自己 panic 了

新 panic 接管,旧的只剩 traceback 里一行 [recovered]Close() 和指针解引用格外小心

写 defer 时问三句:① 参数现在就求值了吗?② 它的作用域是整个函数,太长了吗?③ 它自己会不会 panic、会不会吞错误?

return 交的是卷,
defer 只能改署了名的那张。