Go语言中的defer

golang defer

Posted by alovn on February 7, 2021

关于defer我们知道它会在函数返回之前,倒序执行。

1
2
3
4
func A() {
    defer B()
    //code to to something
}

像这样的一段代码,编译后的伪指令是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func A() {
    //第一个参数是defer函数的参数加返回值共占用多大空间
    //第二个参数是一个funcval
    r := deferproc(8, B) //1. 注册
    if r > 0 {
        goto ret
    }
    //code to do something
    
    runtime.deferreturn() //2. 执行
    return
  ret:
    runtime.deferreturn
}

defer 指令对应到两部分内容,deferproc 负责把要执行的函数信息保存起来,我们称之为defer注册, deferproc 函数会返回0。if r > 0 { goto ret } 这个分支和 panic 和 recover 有关,我们先忽略不看。这样程序执行的逻辑更清晰了,defer 注册完成后继续执行后面的逻辑,知道返回之前通过 deferreturn 执行注册的 defer 函数。正是因为先注册后调用才实现了defer延迟执行的效果。

defer 信息会注册到一个链表,而当前执行的goroutine持有这个链表的头指针。每个 goroutine 在运行时都有一个对应的结构体 g,其中有一个字段指向defer链表头,defer链表链起来的是一个个的 _defer 结构体。新注册的defer会添加到链表头,执行时也是从头开始,执行后移除链表项。所以defer才会表现为倒序执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//src/runtime/runtime2.go
type g struct {
    ...
    _defer *_defer
    ...
}

type _defer struct {
    siz     int32 // 参数和返回值共占多少字节
    started bool //标记defer是否已经执行
    heap    bool //是否为堆分配
    openDefer bool //go1.14增加
    sp        uintptr //记录注册这个defer函数栈指针,通过它函数可以判断自己注册的defer是否已经执行完了
    pc        uintptr  //deferproc的返回地址
    fn        *funcval //要注册的函数 function value
    _panic    *_panic  // panic that is running defer
    link      *_defer //链接到前一个注册的_defer结构体
    fd   unsafe.Pointer //go1.14增加
    varp uintptr //go1.14增加

    framepc uintptr//go1.14增加
}

看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
func A1(a int) {
    fmt.Println(a) //1
}
func main() {
    a1, b1 := 1, 2
    defer A1(a1)
    a1 = a1 + b1
    fmt.Println(a1, b1) //3 2
}
//Output:
//3 2
//1

deferproc函数调用时,编译器会在它自己的两个参数后面开辟一段空间,用于存放defer函数的返回值和参数,这一段空间会被直接拷贝到_defer结构体的后面。A1只有一个参数,放在deferproc函数自己的两个参数后面。

deferproc函数执行时,需要堆分配一段空间,用于存放_defer结构体,以及siz大小的参数与返回值。

然后代码执行到deferreturn执行defer链表,从当前goroutine拿到链表头上的_defer结构体,通过fn找到funcval,拿到函数入口地址。

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

既然deferproc注册的是一个funcval,再来看看有捕获列表时是什么情况。

1
2
3
4
5
6
7
8
9
10
11
12
func A() {
    a, b := 1, 2
    defer func(b int) {
        a = a + b
        fmt.Println(a, b) //5 2
    }(b)
    a = a + b
    fmt.Println(a, b) //3 2
}
//Output:
//3 2
//

这个例子中defer函数不止要传递局部变量b做参数,还捕获了外层函数局部变量a,形成闭包。

执行阶段会创建闭包对象:由于捕获局部变量a,除了初始化赋值外还被修改过,所以局部变量a改为堆分配。栈上存储它的地址还有局部变量b=2。然后创建闭包对象,堆分配一个funcval结构体,捕获列表中存储a的地址。deferproc执行时_defer结构体中fn保存的就是这个funcval结构体的起始地址。

这里最关键是分清楚defer传参与闭包捕获变量的实现机制。

以上是Go1.12版本defer的基本设计思路,这一版本的defer比较明显的问题就是慢。 第一个原因是_defer结构体堆分配,即使有预分配的deferpool也需要去堆上获取与释放,而且参数还要在堆栈间来回拷贝。 第二个原因是使用链表注册defer信息,而链表本身操作比较慢,所以Go1.13中和Go1.14中分别做了不同的优化。

优化

Go1.13和Go1.14中如何改进,在来看下这段代码在1.12和1.13中编译后的伪指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
func A() {
    defer B(10)
    //code to do something
}
func B(i int){
}

//Go1.13
func A() {
    r := runtime.deferproc(0, B, 10)
    if r > 0 {
        goto ret
    }
    //code to do something
    runtime.deferreturn()
    return
  ret:
    runtime.deferreturn
}

//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
}

Go1.13

1.12中通过deferproc注册defer函数信息, _defer结构体分配在堆上。 1.13中通过在编译阶段增加局部变量,把_defer信息保存到当前函数栈帧的局部变量区域,再通过deferprocStack把栈上这个_defer结构体注册到defer链表中。

defer 在 1.13的优化点主要在减少defer信息的堆分配。之所以说是减少是因为在显示或隐示循环中的defer依然要使用Go1.12版本的处理方式在堆上分配。为此_defer结构体增加了一个 heap 字段,用于标识是否为堆分配。defer函数执行时依然是通过deferreturn实现的,也同样要在defer函数是执行拷贝参数,不过不是在堆栈间,而是从栈上的局部变量空间拷贝到参数空间。

Go1.14

Go1.13的defer官方提供的性能提升是30%,那Go1.14版本又有什么不一样的优化策略呢?再来看下编译后的伪指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
func A(i int) {
    defer A1(i, 2*i)
    //code to do something
    if i > 1 {
        defer A2("hello", "world")
    }
    //code to do something
    return
}
func A1(a, b int) {
    ...
}
func A2(m, n string) {
    ...
}

//Go1.14
func A(i int) {
    var df byte //df里每一位对应标识一个defer是否要被执行
    var a, b int = i, 2 * i
    //code to do something

    var m, n string = "hello", "world"
    df |= 1
    if i > 1 {
        df |= 2
    }
    //code to do something
    if df & 2 > 0 {
        df  = df &^ 2
        A2(a, b)
    }

    if df & 1 {
        df = df &^ 1 //执行前第一位置为0,避免重复执行
        A1(a, b)
    }
    
    return
    ... //省略panic recover相关
}

这里有两个defer,先看A1,这里把defer函数A1需要的参数,定义伪局部变量,然后再在函数返回前直接调用defer函数A1,用这样的方式省去了构造defer链表项并注册到链表的过程,也同样实现了defer函数延迟执行的效果。

不过A2就不能这样简单处理了,它要到执行阶段才能确定是否要被调用。Go语言用一个标识变量df来解决这个问题。

1
var df byte

df里每一位对应标识一个defer是否要被执行。这个例子里第一位对应defer函数A1,A1需要执行,所以通过『或』运算把df第一位置为1。

defer函数调用这里,先判断defer标识为是否为1,执行前还要把df对应标识位置为0,避免重复执行。然后直接调用A1就好。

同样的方式到defer A2这里,程序执行阶段会根据具体条件判断df第二个标志位是否要被置为1。对应的函数返回前也要依据第二个标志位来觉得是否要调用函数A2。

Go1.14的 defer 就是通过在编译阶段插入代码,把defer函数的执行逻辑展开在所属函数内,从而免于创建_defer结构体,而且不需要注册到defer链表。

Go语言称这种方式为 open coded defer。但是同1.13中的一样依然不适用于循环中的defer。所以1.12版本中的处理方式目前是一直保留的。

panic异常

一般情况下在循环中使用defer的情况应该比较少,Go1.14版本的defer几乎提升了一个数量级。但是这并非没有代价,我们上面提到的都是程序正常执行的流程,如果我们的代码中发生panic或者调用runtime.Goexit()函数,后面的代码基本就执行不到,就要去执行defer链表了。而这些 open coded 方式实现的defer并没有注册到链表,需要额外通过栈扫描的方式来发现。

所以Go1.14版本中的_defer结构体在Go1.13版本的基础上又增加了几个字段:

1
2
3
4
5
6
7
8
type _defer struct {
    ...
    openDefer bool
    fd   unsafe.Pointer
    varp uintptr

    framepc uintptr
}

借助这些信息,可以找到未注册链表的defer函数,并按照正确的顺序执行。

所以Go1.14版本中defer的确变快了,但是panic变的更慢了。不过Go语言选择做出这样的优化定然是综合考量了整体性能,毕竟panic发生的几率要比defer低。