[golang] defer

144 阅读3分钟

现象

作用域

func main() {
    for i := 0; i < 5; i++ {
        defer fmt.Println(i)
    }
}
$ go run main.go
4
3
2
1
0

运行上述代码会倒序执行传入 defer 关键字的所有表达式,因为最后一次调用 defer 时传入了 fmt.Println(4),所以这段代码会优先打印 4。

func main() {
    {
        defer fmt.Println("defer runs")
        fmt.Println("block ends")
    }
    
    fmt.Println("main ends")
}
$ go run main.go
block ends
main ends
defer runs

defer 传入的函数不是在退出代码块的作用域时执行的,它只会在当前函数和方法返回之前被调用。

预计算参数

Go 语言中所有的函数调用都是传值的,虽然 defer 是关键字,但是也继承了这个特性。

func main() {
    startedAt := time.Now()
    defer fmt.Println(time.Since(startedAt))

    time.Sleep(time.Second)
}
$ go run main.go
164ns

调用 defer 关键字会立刻拷贝函数中引用的外部参数,所以 time.Since(startedAt) 的结果不是在 main 函数退出之前计算的,而是在 defer 关键字调用时计算的,最终导致上述代码输出不为 1s。

func main() {
    startedAt := time.Now()
    defer func() { fmt.Println(time.Since(startedAt)) }()

    time.Sleep(time.Second)
}
$ go run main.go
1.003950198s

虽然调用 defer 关键字时也使用值传递,但是因为拷贝的是函数指针,所以 time.Since(startedAt) 会在 main 函数返回前调用并打印出符合预期的结果。

数据结构

在介绍 defer 函数的执行过程与实现原理之前,首先来了解一下 defer 关键字在 Go 语言源代码中对应的数据结构。

type _defer struct {
    siz       int32
    started   bool
    openDefer bool
    sp        uintptr
    pc        uintptr
    fn        *funcval
    _panic    *_panic
    link      *_defer
}

runtime._defer 结构体是延迟调用链表上的一个元素,所有的结构体都会通过 link 字段串联成链表。
image.png
runtime._defer 结构体中的几个字段:

  • siz 是参数和结果的内存大小;
  • sp 和 pc 分别代表栈指针和调用方的程序计数器;
  • fn 是 defer 关键字中传入的函数;
  • _panic 是触发延迟调用的结构体,可能为空;
  • openDefer 表示当前 defer 是否经过开放编码的优化; 除了上述的这些字段之外,runtime._defer 中还包含一些垃圾回收机制使用的字段,这里为了减少理解的成本就都省去了。

执行机制

中间代码生成阶段的 cmd/compile/internal/gc.state.stmt 会负责处理程序中的 defer,该函数会根据条件的不同,使用三种不同的机制处理该关键字。

func (s *state) stmt(n *Node) {
    ...
    switch n.Op {
    case ODEFER:
        if s.hasOpenDefers {
            s.openDeferRecord(n.Left) // 开放编码
        } else {
            d := callDefer // 堆分配
            if n.Esc == EscNever {
                d = callDeferStack // 栈分配
            }
            s.callResult(n.Left, d)
        }
    }
}

堆分配、栈分配和开放编码是处理 defer 关键字的三种方法,早期的 Go 语言会在堆上分配 runtime._defer 结构体,不过该实现的性能较差,Go 语言在 1.13 中引入栈上分配的结构体,减少了 30% 的额外开销,并在 1.14 中引入了基于开放编码的 defer,使得该关键字的额外开销可以忽略不计。

小结

defer 关键字的实现主要依靠编译器和运行时的协作:

  • 堆上分配 · 1.1 ~ 1.12
    • 编译期将 defer 关键字转换成 runtime.deferproc 并在调用 defer 关键字的函数返回之前插入 runtime.deferreturn
    • 运行时调用 runtime.deferproc 会将一个新的 runtime._defer 结构体追加到当前 Goroutine 的链表头;
    • 运行时调用 runtime.deferreturn 会从 Goroutine 的链表中取出 runtime._defer结构并依次执行;
  • 栈上分配 · 1.13
    • 当该关键字在函数体中最多执行一次时,编译期间的 cmd/compile/internal/gc.state.call 会将结构体分配到栈上并调用 runtime.deferprocStack
  • 开放编码 · 1.14 ~ 现在
    • 编译期间判断 defer 关键字、return 语句的个数确定是否开启开放编码优化;
    • 通过 deferBits 和 cmd/compile/internal/gc.openDeferInfo 存储 defer 关键字的相关信息;
    • 如果 defer 关键字的执行可以在编译期间确定,会在函数返回前直接插入相应的代码,否则会由运行时的 runtime.deferreturn 处理; 在本节前面提到的两个现象在这里也可以解释清楚了:
  • 后调用的 defer 函数会先执行:
    • 后调用的 defer 函数会被追加到 Goroutine _defer 链表的最前面;
    • 运行 runtime._defer 时是从前到后依次执行;
  • 函数的参数会被预先计算;
    • 调用 runtime.deferproc 函数创建新的延迟调用时就会立刻拷贝函数的参数,函数的参数不会等到真正执行时计算;

参阅