golang defer原理

defer原理


文章目录

  • defer原理
    • defer1.12
      • defer延迟和倒序的机制
        • 延迟
        • 倒序
      • defer链表项
      • defer传参机制
      • defer+闭包
      • defer ( A ( B ( c ) ) )
      • defer嵌套
      • defer1.12性能问题
    • defer1.13
    • defer1.14


defer1.12

defer延迟和倒序的机制

延迟


在go语言中,我们可以很方便的用defer关闭一个打开的文件、释放一个Redis连接,或者解锁一个Mutex。而且Go语言在设计上保证,即使发生panic,所有的defer调用也能够被执行。不过多个defer函数是按照定义顺序倒序执行的。


我们通过一个例子来解释defer函数的延迟与倒序。


func f1() {
     
    defer A()
    // code to do something
}

这样一段代码,在Go1.12中编译以后的伪指令是这样的:


 func f1() {
     
    r := runtime.deferproc(0, A) // 经过recover返回时r为1,否则为0
    if r > 0 {
     
        goto ret
    }
    // code to do something
    runtime.deferreturn()
    return
ret:
    runtime.deferreturn()
}

其中与defer指令相关的有两个部分。第一部分是deferproc,它负责保存要执行的函数信息,我们称之为defer注册


func deferproc(siz int32, fn *funcval)

从函数原型来看,deferproc函数有两个参数,第一个是被注册的defer函数的参数加返回值共占多少字节;第二个参数是一个runtime.funcval结构体的指针,也就是一个Function Value。


与defer指令相关的第二部分就是deferreturn,它被编译器插入到函数返回以前调用,负责执行已经注册的defer函数。所以defer函数之所以能延迟到函数返回前执行,就是因为先注册,后调用。


倒序


再来看看defer函数为什么会倒序执行。
defer注册信息会保存到defer链表。每个goroutine在运行时都对应一个runtime.g结构体,其中有一个_defer字段,保存的就是defer链表的头指针。


golang defer原理_第1张图片

deferproc新注册的defr信息会添加到链表头。deferreturn执行时也从链表头开始,所以defer才会表现为倒序执行。


defer链表项


defer链表链起来的是一个一个_defer结构体。


type _defer struct {
     
    siz       int32
    started   bool
    sp        uintptr // sp at time of defer
    pc        uintptr
    fn        *funcval
    _panic    *_panic // panic that is running defer
    link      *_defer
 }

  • siz 由deferproc第一个参数传入,就是defer函数参数加返回值的总大小。
    这段空间会直接分配在_defer结构体后面,用于在注册时保存给defer函数传入的参数,并在执行时直接拷贝到defer函数的调用者栈上。

  • started 标识defer函数是否已经开始执行;

  • sp 就是注册defer函数的函数栈指针;

  • pc 是deferproc函数返回后要继续执行的指令地址;

  • fn 由deferproc的第二个参数传入,也就是被注册的defer函数;

  • _panic 是触发defer函数执行的panic指针,正常流程执行defer时它就是nil;

  • link 自然是链到之前注册的那个_defer结构体。


这一篇我们只关注正常流程下defer函数的执行,不考虑panic或runtime.Goexit()的情况。


defer传参机制


func A1(a int) {
     
    fmt.Println(a)
}
func A() {
     
    a, b := 1, 2
    defer A1(a)
    
    a = a + b
    fmt.Println(a, b)
}

这里函数A注册了一个defer函数A1,在A的函数栈帧中,局部变量区域存储a=1,b=2。


golang defer原理_第2张图片

到deferproc函数注册defer函数A1时。
第一个参数是A1的参数加返回值共占多少字节。A1没有返回值,64位下一个整型参数占用8字节。
第二个参数是函数A1。前面我们介绍过,没有捕获列表的Function Value,在编译阶段会做出优化,就是在只读数据段分配一个共用的funcval结构体。如下图中,函数A1的指令入口地址为addr1。在只读数据段分配的指向A1指令入口的funcval结构体地址为addr2,所以deferproc函数第二个参数就是addr2。


golang defer原理_第3张图片


额外要注意的是,deferproc函数调用时,编译器会在它自己的两个参数后面,开辟一段空间,用于存放defer函数A1的返回值和参数。这一段空间会在注册defer时,直接拷贝到_defer结构体的后面。A1只有一个参数a=1,放在deferproc函数自己的两个参数之后。注意deferproc函数的返回值空间并没有分配在调用者栈上,而是放到了寄存器中,这和recover有关,且先忽略。


golang defer原理_第4张图片

golang defer原理_第5张图片

然后这个_defer结构体就被添加到defer链表头,deferproc注册结束。


注意:
频繁的堆分配势必影响性能,所以Go语言会预分配不同规格的deferpool,执行时从空闲_defer中取一个出来用。没有空闲的或者没有大小合适的,再进行堆分配。用完以后,再放回空闲_defer池。这样可以避免频繁的堆分配与回收。


deferproc结束后,接下来会执行到a=a+b这一步,所以,局部变量a被置为3。接下来会输出:a=3,b=2。


//函数A编译后的伪指令
func A() {
     
    a, b := 1, 2
    r := runtime.deferproc(8, A1,1)
    if r > 0 {
     
        goto ret
    }
      
    a = a + b
    fmt.Println(a, b)//3,2

    runtime.deferreturn()//执行defer链表
    return
ret:
    runtime.deferreturn()
}

然后就到deferreturn执行defer链表这里了。从当前goroutine找到链表头上的这个_defer结构体,通过_defer.fn找到defer函数的funcval结构体,进而拿到函数A1的入口地址。接下来就可以调用A1了。


golang defer原理_第6张图片


调用A1时,会把_defer后面的参数与返回值整个拷贝到A1的调用者栈上。然后A1开始执行,输出参数值a=1。这个例子的关键,是defer函数的参数在注册时拷贝到堆上,执行时再拷贝到栈上。


defer+闭包


既然deferproc注册的是一个Function Value,下面就来看看有捕获列表时是什么情况。


func A() {
     
    a, b := 1, 2
    defer func(b int) {
     
        a = a+b
        fmt.Println(a, b)
    }(b)
    a = a + b
    fmt.Println(a, b)
}

这个例子中,defer函数不止要传递局部变量b做参数,还捕获了外层函数的局部变量a,形成闭包。匿名函数会由编译器按照A_func1这样的形式命名。如下图所示,假设这个闭包函数的指令入口地址为addr1


golang defer原理_第7张图片


上图中,由于捕获变量a除了初始化赋值外还被修改过,所以A的局部变量a改为堆分配,栈上只存它的地址。创建闭包对象时,会堆分配一个funcval结构体,funcval.fn指向闭包函数入口addr1,捕获列表中存储a在堆上的地址。而这个funcval结构体本身的地址addr2,就是deferproc执行时,_defer结构体中的fn的值。别忘了,传给defer函数的参数b=2,也要拷贝到_defer结构体后面。


golang defer原理_第8张图片


上图所示_defer结构体被添加到defer链表头以后,deferproc注册结束。继续执行后面的逻辑。到a=a+b这里,a被置为3。下一步输出a=3,b=2。


接下来,deferreturn执行注册的defer函数时,要把参数b拷贝到栈上的参数空间。还记得闭包函数执行时怎样找到对应的捕获列表吗?通过寄存器存储的funcval地址加上偏移,找到捕获变量a的地址。


//示例代码编译后的伪指令
func A() {
     
    a := new(int)
    *a = 1
    b := 2
    r := runtime.deferproc(8, A_func1,2)
    if r > 0 {
     
        goto ret
    }
      
    *a = *a + b
    fmt.Println(*a, b)//3,2

    runtime.deferreturn()//执行defer链表
return
ret:
    runtime.deferreturn()
}
func A_func1(b int){
     
    a := (int *)([DX]+8)
    *a = *a + b
    fmt.Println(*a,b)
}

要注意捕获变量a在堆上分配,闭包函数执行时,捕获变量a=3,参数b=2。


golang defer原理_第9张图片


所以,接下来在defer函数中,捕获变量a被置为5,最终输出a=5,b=2。这个例子中,最关键的是分清defer传参与闭包捕获变量的实现机制。


defer ( A ( B ( c ) ) )


func B(a int) int {
     
    a++
    return a
}
func A(a int) {
     
    a++
    fmt.Println(a)
}
func main() {
     
    a := 1
    defer A(B(a))
    a++
    fmt.Println(a)
}

这个例子中,main函数注册的defer函数是A,所以,defer链表项中_defer.fn存储的是A的funcval指针。但是deferproc执行时,需要保存A的参数到_defer结构体后面。这就需要在defer注册时拿到B的返回值。

既然B会在defer注册时执行,那么对B(a)求值时a=1。函数B的返回值就是2,也就是defer注册时保存的参数值为2,所以defer函数A执行时就会输出3。


defer嵌套


这一次,我们抛开各种细节,只关注defer链表随着defer函数的注册与执行究竟会如何变化。


func A(){
     
    //......
    defer A1()
    //......
    defer A2()
    //......
}
func A2(){
     
    //......
    defer B1()
    defer B2()
    //......
}
func A1(){
     
    //......
}
//所有defer函数都正常执行......

这个例子中函数A注册两个defer,我们用函数名标记为A1和A2。


golang defer原理_第10张图片

到函数A返回前执行deferreturn时,会判断defer链表头上的defer是不是A注册的。方法就是判断_defer结构体记录的sp是否等于A的栈指针.如果是A注册的,就保存defer函数调用的相关信息,然后把这一项从defer链表中移除,然后调用函数A2,A2执行时又注册两个defer,记为B1和B2。


golang defer原理_第11张图片

函数A2返回前同样去执行defer链表,同样判断是否是自己注册的defer函数。所以B2执行,之后B1执行。此时A2仍然不知道自己注册的defer函数已经执行完了,直到下一个_defer.sp不等于A2的栈指针,A2注册的defer执行完,A2就可以结束了。


golang defer原理_第12张图片

因为A1是函数A注册的defer函数,所以又回到A的defer执行流程。A1结束后,defer链表为空,函数A结束。
这个例子的关键是defer链表注册时添加链表项,执行时移除链表项的用法。


defer1.12性能问题


defer1.12的性能问题主要缘于两个方面:

  1. _defer结构体堆分配,即使有预分配的deferpool,也需要去堆上获取与释放。而且defer函数的参数还要在注册时从栈拷贝到堆,执行时又要从堆拷贝到栈。
  2. defer信息保存到链表,而链表操作比较慢。

defer1.13


Go1.13中defer性能的优化点,主要集中在减少defer结构体堆分配。我们通过一个例子,看看它是怎样做到的。


func A() {
     
    defer B(10)
    // code to do something
}
func B(i int) {
     
    //......
}

像这样一段代码,在Go1.13中编译后的伪指令是这样的:


func A() {
     
    var d struct {
     
        runtime._defer
        i int
    }
    d.siz = 0
    d.fn = B
    d.i = 10
    r := runtime.deferprocStack(&d._defer)
    if r > 0 {
     
        goto ret
    }
    // code to do something
    runtime.deferreturn()
    return
ret:
    runtime.deferreturn()
}

注意上面的结构体d,它由两部分组成,一个是runtime._defer结构体,一个是传给defer函数B的参数。它们被定义为函数A的局部变量,执行阶段会分配在函数栈帧的局部变量区域。接下来的runtime.deferprocStack则会把栈上分配的_defer结构体注册到defer链表。通过这样的方式避免在堆上分配_defer结构体。


golang defer原理_第13张图片


值得注意的是,1.13版本中并不是所有defer都能够在栈上分配。循环中的defer,无论是显示的for循环,还是goto形成的隐式循环,都只能使用1.12版本中的处理方式在堆上分配。即使只执行一次的for循环也是一样。


//显示循环
for i:=0; i< n; i++{
     
    defer B(i)
}
......

//隐式循环
again:
    defer B()
    if i<n {
     
        n++
        goto again
    }

所以Go1.13中,runtime._defer结构体增加了一个字段heap,用于标识是否为堆分配。


 type _defer struct {
     
    siz       int32
    started   bool
    heap      bool       //标识是否为堆分配
    sp        uintptr
    pc        uintptr
    fn        *funcval
    _panic    *_panic
    link      *_defer
}

defer函数的执行在1.13中没有变化,依然通过deferreturn实现,依然需要把_defer结构体后面的参数与返回值空间,拷贝到defer函数的调用者栈上。只不过不是从堆上拷贝到栈上,而是从栈上的局部变量空间拷贝到参数空间。


golang defer原理_第14张图片

1.13版本的defer减少了_defer结构体的堆分配,但是仍然要使用defer链表。官方提供的性能优化在30%左右。


defer1.14


减少_defer结构体的堆分配,也是1.14版本中defer性能优化要持续践行的策略。但是具体做法与1.13版本不同。


func A(i int) {
     
    defer A1(i, 2*i)
    if(i > 1){
     
        defer A2("Hello", "eggo")
    }
    // code to do something
    return
}
func A1(a,b int){
     
    //......
}
func A2(m,n string){
     
    //......
}

上面这个例子中,函数A注册两个defer函数A1和A2,不过函数A2要到执行阶段根据条件判断是否要执行。先看defer函数A1这部分编译后的伪指令,Go1.14中会把A1需要的参数定义为局部变量,并在函数返回前直接调用A1。


func A(i int){
     
    var a, b int = i, 2*i
    //......
        
    A1(a, b)
    return
    //......
}

通过这样的方式不仅不用构建_defer结构体,也用不到defer链表,但是到defer函数A2这里就行不通了。因为A2不一定要被执行,这要在执行阶段根据参数i的值来决定。

Go1.14通过增加一个标识变量df来解决这类问题。用df中的每一位对应标识当前函数中的一个defer函数是否要执行。


golang defer原理_第15张图片

例如,函数A1要被执行,所以就通过df |= 1把df第一位置为1;在函数返回前再通过df&1判断是否要调用函数A1。


 func A(i int){
     
    var df byte
    var a, b int = i, 2*i
    df |= 1      
    //......
    //code to do something

    if df&1 > 0 {
     
        df = df&^1
        A1(a, b)
    }
    return
    //......
}

所以像A2这样有条件执行的defer函数就可以像下面这样处理了。根据条件判断是否要把对应标识位置为1,函数返回前同样要根据标识符来判断是否要调用。


func A(i int){
     
    var df byte
    //A1的参数
    var a, b int = i, 2*i
    df |= 1

    //A2的参数
    var m,n string = "Hello", "eggo"
    if i > 1 {
     
        df |= 2
    }
    //code to do something
        
    //判断A2是否要调用
    if df&2 > 0 {
     
        df = df&^2
        A2(m, n)
    }
    //判断A1是否要调用
    if df&1 > 0 {
     
        df = df&^1
        A1(a, b)
    }
    return
    //省略部分与recover相关的逻辑
}

Go1.14把defer函数在当前函数内展开并直接调用,这种方式被称为open coded defer。这种方式不仅不用创建_defer结构体,也脱离了defer链表的束缚。不过这种方式依然不适用于循环中的defer,所以1.12版本defer的处理方式是一直保留的。


注意:


但是,必须要强调的是,我们一直在梳理的都是程序正常执行时defer的处理逻辑。一旦发生panic或者调用了runtime.Goexit函数,在这之后的正常逻辑就都不会执行了,而是直接去执行defer链表。那些使用open coded defer在函数内展开,因而没有被注册到链表的defer函数要通过栈扫描的方式来发现。


Go1.14中runtime._defer结构体又增加了几个字段:


type _defer struct {
     
    siz       int32
    started   bool
    heap      bool
    openDefer bool           //1
    sp        uintptr
    pc        uintptr
    fn        *funcval
    _panic    *_panic
    link      *_defer 
    fd        unsafe.Pointer //2
    varp      uintptr        //3
    framepc   uintptr  

借助这些信息,panic处理流程可以通过栈扫描的方式找到这些没有被注册到defer链表的defer函数,并按照正确的顺序执行。
所以,实际上Go1.14版本中defer的确变快了,但panic变得更慢了…


参考资料:

https://mp.weixin.qq.com/s/gaC2gmFhJezH-9-uxpz07w

你可能感兴趣的:(go,golang)