defer 源码分析

Javafinally类似,Go也提供了defer关键字用来做函数最后的收尾动作。虽然defer的用法看似简单,但是不理解其原理,依然会踩到很多坑。同时recover()也是需要通过和defer关键字配合使用。因此,这里简单分析一下defer的原理。知其然,也知其所以然。

defer 源码解析

关于defer的使用介绍,在官网有一篇详细的文章介绍,其中问题提出了defer的3点注意事项:

  1. A deferred function’s arguments are evaluated when the defer statement is evaluated.
  2. Deferred function calls are executed in Last In First Out order after the surrounding function returns.
  3. Deferred functions may read and assign to the returning function’s named return values.

简单翻译过来就是:

  • defer注册函数的参数的值在声明的时候就已经确定

  • 如果函数中包含多个defer,那么defer的执行顺序是后进先出

  • defer可能会读取或修改函数的命名返回值。

_defer

通过defer关键字注册延迟函数之后,会生成一个_defer结构体来记录defer相关的信息。_defer结构体主要包含以下字段:

type _defer struct {
  siz       int32   //func()需要分配的栈帧的大小
    started   bool
    openDefer bool    //是否符合使用开发编码的条件
    sp        uintptr //caller sp
    pc        uintptr //caller 程序计数器
    fn        *funcval//具体的函数的地址
    _panic    *_panic 
    link      *_defer //下一个defer节点
}

_defer结构体的内存分配包含两种:

  • 在栈上分配,通过runtime.deferprocStack()注册
  • 在堆上分配,通过runtime.deferproc()注册

一般来说,在栈上分配defer结构体比在堆上分配要快30%。

同时,默认来说,defer是在栈上分配。当deferfor循环中时,defer会在堆上分配,因此这里需要注意别再for循环中使用defer

接下来看第一个问题:defer注册函数的参数的值在声明的时候就已经确定

比如下面的程序:

func deferTest() (r int) {
    i := 9527
    defer func(n int) {
        fmt.Println(n)
    }(i)
    return
}

通过GOOS=darwin GOARCH=amd64 go tool compile -N -l -S main.go 编译得到汇编代码:

go version 1.16

TEXT    "".deferTest(SB), ABIInternal, 104-8
    MOVQ    (TLS), CX
    CMPQ    SP, 16(CX)
    PCDATA0, -2
    JLS     130
    PCDATA0, -1
    SUBQ104, SP
    MOVQ    BP, 96(SP)
    LEAQ    96(SP), BP
    MOVQ    0, "".r+112(SP) //初始化返回值r
    MOVQ9527, "".i+8(SP) //初始化局部变量 i
    MOVL    8, ""..autotmp_2+16(SP)   //设置defer结构体的varp值,func栈帧大小为8
    LEAQ    "".deferTest.func1·f(SB), AX    MOVQ    AX, ""..autotmp_2+40(SP)   //设置defer结构的fn的值,对应func在全局中的地址
    MOVQ    "".i+8(SP), AX             MOVQ    AX, ""..autotmp_2+88(SP)   //设置func的参数
    LEAQ    ""..autotmp_2+16(SP), AX
    MOVQ    AX, (SP)
    PCDATA1, $0
    CALL    runtime.deferprocStack(SB) //调用deferprocStack注册defer

    ....

通过上面的汇编代码可以看到。在调用runtime.deferprocStack()注册defer之前,func所需要的参数就已经保存在栈上。

可以理解为defer的原理分为两步:

  • 获取defer需要执行的指令、参数等数据,打包保存,通过deferproStack()defer结构体注册保存在goroutine
  • 在函数返回的时,遍历goroutine获取对应的defer,执行。

因此,defer对应的方法的参数,在注册时便已经确定。

主要注意的时候,由于defer一般会有闭包,而golang对应闭包捕获的变量都是变量的指针,因此需要注意区分defer保存的值类型和指针类型。两者产生的结果完全不一样。

这也是为什么说defer可能修改命名返回值的原因,因此defer捕获的是命名返回值的指针,因此defer对返回值的改变会影响到真正的返回值。

runtime.deferprocStack()

接下来看第二个注意点:如果函数中包含多个defer,那么defer的执行顺序是后进先出

查看runtime.deferprocStack()源码可以看到:

//go:nosplit
func deferprocStack(d *_defer) {
   gp := getg()
   if gp.m.curg != gp {
      // go code on the system stack can't defer
      throw("defer on system stack")
   }
   if goexperiment.RegabiDefer && d.siz != 0 {
      throw("defer with non-empty frame")
   }
   // 这里defer结构体的fn和siz已经在编译时通过已经赋值,通过上面的汇编可以验证。因此deferprocStack只需要初始化其他值即可。
   d.started = false
   d.heap = false
   d.openDefer = false
   d.sp = getcallersp()
   d.pc = getcallerpc()
   d.framepc = 0
   d.varp = 0

  // 以下代码相当于
   //   d.panic = nil
   //   d.fd = nil
   //   d.link = gp._defer
   //   gp._defer = d
   *(*uintptr)(unsafe.Pointer(&d._panic)) = 0
   *(*uintptr)(unsafe.Pointer(&d.fd)) = 0
   *(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
   *(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))

   //return0和return的区别在于return0不会触发defer的调用,避免无限递归
   return0()

}

最需要关注的是以上两行代码:

d.link = gp._defer
gp._defer = d

假如在执行这两行代码之前,goroutine已经注册了一个defer

image-20220714175332307

执行之后会变成:

image-20220714175439816

也就是说每个defer在注册的时候都会把自己设置的和goroutine对接的那一个。

这里也可以注意到defergoroutine级别的。也就是所有的defer都只能在当前goroutine执行。这也是为什么recover()捕获不了子gouroutinepanic的原因。

runtime.deferreturn()

接下来再看defer的执行:从上面的汇编代码可以看到defer的执行是通过runtime.deferreturn完成。

func deferreturn() {
    gp := getg()
    d := gp._defer
  //如果没有defer结构体,则直接返回
    if d == nil {
        return
    }
  //获取调用deferreturn的函数的sp地址
    sp := getcallersp()
  //如果defer结构体的sp和调用deferreturn的sp地址不同,则说明此defer不属于当前函数,则直接返回
    if d.sp != sp {
        return
    }
  //如果是用开放链接,则直接调用runOpenDeferFrame运行
    if d.openDefer {
        done := runOpenDeferFrame(gp, d)
        if !done {
            throw("unfinished open-coded defers in deferreturn")
        }
        gp._defer = d.link
        freedefer(d)
        return
    }

  //获取caller当前栈帧的arg0的位置
    argp := getcallersp() + sys.MinFrameSize
    switch d.siz {
    case 0:
        // Do nothing.
    case sys.PtrSize:
        *(*uintptr)(unsafe.Pointer(argp)) = *(*uintptr)(deferArgs(d))
    default:
    //将栈上的参数拷贝到func参数地址上
        memmove(unsafe.Pointer(argp), deferArgs(d), uintptr(d.siz))
    }
  //释放defer
    fn := d.fn
    d.fn = nil
  //将defer连接的下一个defer赋值给goroutine
    gp._defer = d.link
  //释放defer,主要是归还从堆上分配的defer结构体
    freedefer(d)
    _ = fn.fn
  //通过jmpdefer执行func语句。
    jmpdefer(fn, argp)
}

可以看到deferreturn主要是执行一些过滤和准备工作。

deferreturn的过滤可以看出来,虽然defer是挂接在goroutine上,但是goroutine通过栈结构保存defer结构体,在每次调用deferreturn时,都会从当前goroutine开始顺序遍历defer结构体并执行,deferprocStackdeferreturn都是配对执行,deferprocStack对应defer结构体入栈,deferreturn结构体对应defer结构体出栈。

通过defer结构体的入栈和出栈,可以对应到defer的第二个注意点:如果函数中包含多个defer,那么defer的执行顺序是后进先出。

jmpdefer

这里简单分析一下jmpdefer,前面说到每次执行deferreturn时,都会遍历gouroutinedefer列表,可是看代码可以发现这里并没有for循环,那defer是如何遍历的呢?答案在jmpdefer,找到jmpdefer的声明和实现:

//第一个参数:需要执行的func()的全局地址
//第二个参数:
func jmpdefer(fv *funcval, argp uintptr)

其实现是直接由汇编实现:

TEXT runtime·jmpdefer(SB), NOSPLIT, 0-16
   MOVQ   fv+0(FP), DX   //将fn的全局地址保存在DX寄存器中
   MOVQ   argp+8(FP), BX //将caller sp的地址保存在BX寄存器中
   LEAQ   -8(BX), SP     //获取caller sp寄存器中 保存的地址上对于的值-8 保存在sp寄存器中   MOVQ   -8(SP), BP     //保存caller BP
   SUBQ5, (SP)       //将SP寄存器中的值减5
   MOVQ   0(DX), BX      //将fn的地址赋值给BX
   JMP    BX             //跳转到fn的地址执行fn

这里的奥秘就在于func()不是通过CALL指令调用,而是通过JMP调用。

回忆Go汇编(一)提到过,CALL指令的作用主要是将下一条需要执行的指令的地址POP到栈上作为return address,然后修改IP寄存器实现CPU跳转。RET指令主要是通过读取栈上的return address实现函数的返回。这里使用JMP是因为return address是需要程序自己设置,只要将return address的地址设置为deferreturn函数的地址,便形成了一个递归。

image-20220714200711589

继续说,那么deferreturn的地址是如何获取的呢?前面说过,CALL指令保存了CALL deferreturn()指令的下一条地址。那么我们只需要获取CALL指令保存的地址,再将地址减去CALL指令的指令长度,即可获取CALL deferreturn的地址。

jmpdefer的第二个参数的设置在deferreturn():

//获取caller当前栈帧的sp寄存器的值+一个最小栈帧的大小
//这里的sp都是硬件sp
argp := getcallersp() + sys.MinFrameSize

此时的函数调用栈如图所示:

image-20220714201644487

因此,caller sp减去一个帧的大小,此时argp指向arg0的位置。通过下面几条指令的计算:

    MOVQ   argp+8(FP), BX 
    LEAQ   -8(BX), SP     //获取return address
    SUBQ   $5, (SP)       //将return address - 5 的值设置为func()的return address

可以看到,经过系列的计算,从而实现了deferreturn递归执行的效果。

defer和返回值

func testA()(r int){
   defer func() {
      r++
   }()
   return 
}

func testB() int {
   i := 1
   defer func() {
      i++
   }()
   return i
}

观察testA()testB()的返回结果。这里不再进行原理分析,而是分析一下其执行流程。想要理解其差别,只需要记住一点即可:返回值的会提前在栈上分配内存。因此,对于testA(),流程为:

  • 分配返回值内存r,并将其初始化为0 //这里其实并不严谨,因为栈的内存都是一次性分配完毕的,这里为了方便理解。下同
  • 执行defer
  • defer捕获r的地址
  • defer修改r的值,执行r=r+1
  • 返回r

因为这里是闭包捕获,因此deferr的值的修改会影响到真正的返回结果。

对于testB(),其流程为:

  • 分配返回值内存r,并将其初始化为0
  • 分配局部变量i的内存,并将其初始化为1
  • 执行r=i
  • 执行defer
  • 返回r

因此,defer捕获的仅仅是i的地址,i++完全不会影响返回值r,其等价的结果如下:

func testB()(r int) {
   i := 1
   defer func() {
      i++
   }()
   r = i
   return r
}

参考文章:

Golang 中 defer 机制分析

3.4 延迟语句

通过汇编和源码两大神器探究 —— Go语言defer

5.3 defer