5.3 defer

    在这一节中我们就会深入 Go 语言的源代码介绍 defer 关键字的实现原理,相信读者读完这一节会对 defer 的数据结构、实现以及调用过程有着更清晰的理解。

    作为一个编程语言中的关键字,defer 的实现一定是由编译器和运行时共同完成的,不过在深入源码分析它的实现之前我们还是需要了解 defer 关键字的常见使用场景以及使用时的注意事项。

    使用 defer 的最常见场景就是在函数调用结束后完成一些收尾工作,例如在 defer 中回滚数据库的事务:

    在使用数据库事务时,我们可以使用如上所示的代码在创建事务之后就立刻调用 Rollback 保证事务一定会回滚。哪怕事务真的执行成功了,那么调用 tx.Commit() 之后再执行 tx.Rollback() 也不会影响已经提交的事务。

    我们在 Go 语言中使用 defer 时会遇到两个比较常见的问题,这里会介绍具体的场景并分析这两个现象背后的设计原理:

    • defer 关键字的调用时机以及多次调用 defer 时执行顺序是如何确定的;
    • defer 关键字使用传值的方式传递参数时会进行预计算,导致不符合预期的结果;

    defer 关键字传入的函数会在函数返回之前运行。假设我们在 for 循环中多次调用 defer 关键字:

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

    运行上述代码会倒序执行所有向 defer 关键字中传入的表达式,最后一次 defer 调用传入了 fmt.Println(4),所以会这段代码会优先打印 4。我们可以通过下面这个简单例子强化对 defer 执行时机的理解:

    1. func main() {
    2. {
    3. defer fmt.Println("defer runs")
    4. fmt.Println("block ends")
    5. }
    6. fmt.Println("main ends")
    7. }
    8. $ go run main.go
    9. block ends
    10. main ends
    11. defer runs

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

    Go 语言中所有的函数调用都是传值的,defer 虽然是关键字,但是也继承了这个特性。假设我们想要计算 main 函数运行的时间,可能会写出以下的代码:

    1. func main() {
    2. startedAt := time.Now()
    3. defer fmt.Println(time.Since(startedAt))
    4. time.Sleep(time.Second)
    5. }
    6. $ go run main.go
    7. 0s

    然而上述代码的运行结果并不符合我们的预期,这个现象背后的原因是什么呢?经过分析,我们会发现调用 defer 关键字会立刻对函数中引用的外部参数进行拷贝,所以 time.Since(startedAt) 的结果不是在 main 函数退出之前计算的,而是在 defer 关键字调用时计算的,最终导致上述代码输出 0s。

    想要解决这个问题的方法非常简单,我们只需要向 defer 关键字传入匿名函数:

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

    1. type _defer struct {
    2. siz int32
    3. started bool
    4. sp uintptr
    5. pc uintptr
    6. fn *funcval
    7. _panic *_panic
    8. link *_defer
    9. }

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

    图 5-10 延迟调用链表

    我们简单介绍一下 结构体中的几个字段:

    • siz 是参数和结果的内存大小;
    • sp 和 分别代表栈指针和调用方的程序计数器;
    • _panic 是触发延迟调用的结构体,可能为空;

    除了上述的这些字段之外,runtime._defer 中还包含一些垃圾回收机制使用的字段,这里为了减少理解的成本就都省去了。

    中间代码生成阶段执行的被 函数会处理 defer 关键字。从下面截取的这段代码中,我们会发现编译器调用了 cmd/compile/internal/gc.state.call 函数,这表示 defer 在编译器看来也是函数调用:

    1. func (s *state) stmt(n *Node) {
    2. switch n.Op {
    3. case ODEFER:
    4. s.call(n.Left, callDefer)
    5. }
    6. }

    函数会负责了为所有函数和方法调用生成中间代码,它的工作包括以下内容:

    • 获取需要执行的函数名、闭包指针、代码指针和函数调用的接收方;
    • 获取栈地址并将函数或者方法的参数写入栈中;
    • 使用 cmd/compile/internal/gc.state.newValue1A 以及相关函数生成函数调用的中间代码;
    • 如果当前调用的函数是 defer,那么就会单独生成相关的结束代码块;
    • 获取函数的返回值地址并结束当前调用;
    1. func (s *state) call(n *Node, k callKind) *ssa.Value {
    2. ...
    3. var call *ssa.Value
    4. switch {
    5. case k == callDefer:
    6. call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, deferproc, s.mem())
    7. ...
    8. }
    9. call.AuxInt = stksize
    10. s.vars[&memVar] = call
    11. ...
    12. }

    从上述代码中我们能看到,defer 关键字在运行期间会调用 函数,这个函数接收了参数的大小和闭包所在的地址两个参数。

    编译器不仅将 defer 关键字都转换成 runtime.deferproc 函数,它还会通过以下三个步骤为所有调用 defer 的函数末尾插入 的函数调用:

    Go 语言的编译器不仅将 defer 转换成了 runtime.deferproc 的函数调用,还在所有调用 defer 的函数结尾插入了 ,接下来我们就需要了解这两个运行时方法的实现原理了。

    defer 关键字的运行时实现分成两个部分:

    • runtime.deferproc 函数负责创建新的延迟调用;
    • 函数负责在函数调用结束时执行所有的延迟调用;

    这两个函数是 defer 关键字运行时机制的入口,我们从它们开始分别介绍这两个函数的执行过程。

    1. func deferproc(siz int32, fn *funcval) {
    2. sp := getcallersp()
    3. argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
    4. callerpc := getcallerpc()
    5. d := newdefer(siz)
    6. if d._panic != nil {
    7. throw("deferproc: d.panic != nil after newdefer")
    8. }
    9. d.fn = fn
    10. d.pc = callerpc
    11. d.sp = sp
    12. switch siz {
    13. case 0:
    14. case sys.PtrSize:
    15. *(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
    16. default:
    17. memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
    18. }
    19. return0()

    最后调用的 runtime.return0 函数的作用是避免无限递归调用 ,它是唯一一个不会触发由延迟调用的函数了。

    runtime.deferproc 中 的作用就是想尽办法获得一个 runtime._defer 结构体,办法总共有三个:

    • 从调度器的延迟调用缓存池 sched.deferpool 中取出结构体并将该结构体追加到当前 Goroutine 的缓存池中;
    • 从 Goroutine 的延迟调用缓存池 pp.deferpool 中取出结构体;
    • 通过 创建一个新的结构体;
    1. func newdefer(siz int32) *_defer {
    2. var d *_defer
    3. gp := getg()
    4. if sc < uintptr(len(p{}.deferpool)) {
    5. pp := gp.m.p.ptr()
    6. if len(pp.deferpool[sc]) == 0 && sched.deferpool[sc] != nil {
    7. for len(pp.deferpool[sc]) < cap(pp.deferpool[sc])/2 && sched.deferpool[sc] != nil {
    8. d := sched.deferpool[sc]
    9. sched.deferpool[sc] = d.link
    10. pp.deferpool[sc] = append(pp.deferpool[sc], d)
    11. }
    12. }
    13. if n := len(pp.deferpool[sc]); n > 0 {
    14. d = pp.deferpool[sc][n-1]
    15. pp.deferpool[sc][n-1] = nil
    16. pp.deferpool[sc] = pp.deferpool[sc][:n-1]
    17. }
    18. }
    19. if d == nil {
    20. total := roundupsize(totaldefersize(uintptr(siz)))
    21. d = (*_defer)(mallocgc(total, deferType, true))
    22. }
    23. d.siz = siz
    24. d.link = gp._defer
    25. gp._defer = d
    26. return d
    27. }

    无论使用哪种方式获取 runtime._defer,它都会被追加到所在的 Goroutine _defer 链表的最前面。

    golang-new-defe

    图 5-11 追加新的延迟调用

    defer 关键字插入时是从后向前的,而 defer 关键字执行是从前向后的,而这就是后调用的 defer 会优先执行的原因。

    会从 Goroutine 的 _defer 链表中取出最前面的 runtime._defer 结构体并调用 函数传入需要执行的函数和参数:

    1. func deferreturn(arg0 uintptr) {
    2. gp := getg()
    3. d := gp._defer
    4. if d == nil {
    5. return
    6. }
    7. sp := getcallersp()
    8. switch d.siz {
    9. case 0:
    10. case sys.PtrSize:
    11. *(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
    12. default:
    13. memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
    14. }
    15. fn := d.fn
    16. gp._defer = d.link
    17. freedefer(d)
    18. jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
    19. }

    runtime.jmpdefer 是一个用汇编语言实现的运行时函数,它的工作就是跳转 defer 所在的代码段并在执行结束之后跳转回 。

    runtime.deferreturn 函数会多次判断当前 Goroutine 的 _defer 链表中是否有未执行的剩余结构,在所有的延迟函数调用都执行完成之后,该函数才会返回。

    defer 关键字的实现主要依靠编译器和运行时的协作,我们总结一下本节提到的内容:

    • 编译期;
      • defer 关键字被转换 ;
      • 在调用 defer 关键字的函数返回之前插入 runtime.deferreturn
    • 运行时:
      • 会将一个新的 runtime._defer 结构体追加到当前 Goroutine 的链表头;
      • 会从 Goroutine 的链表中取出 runtime._defer 结构并依次执行;

    我们在本节前面提到的两个现象在这里也可以解释清楚了:

    • 后调用的 defer 函数会先执行:
      • 后调用的 defer 函数会被追加到 Goroutine _defer 链表的最前面;
      • 运行 时是从前到后依次执行;
    • 函数的参数会被预先计算;
      • 调用 runtime.deferproc 函数创建新的延迟调用时就会立刻拷贝函数的参数,函数的参数不会等到真正执行时计算;