脱胎换骨的defer

Go语言的defer是一个很方便的机制,能够让我们推迟执行某些函数调用,推迟到当前函数返回前才实际执行。语言在设计上保证,即使发生panic,所有的defer调用也能够被执行。defer与panic和recover结合,形成了Go语言风格的异常与捕获机制。

因为defer真的很方便,所以大家都已经习惯了随手使用它。比如关闭一个打开的文件、释放一个Redis连接,或者解锁一个Mutex。但是受之前defer的实现机制所限,与一般的函数调用比起来,defer调用会造成较大的额外开销,尤其是在锁释放这种场景。因此经常被一些库设计者所诟病,甚至有些项目的注释中写明了不用defer能节省多少多少纳秒。

作为一个关键的语言特性,却被人弃之如敝屣,这怎么可以。好在Go语言的团队一直在积极地进行优化,比如在最近发布的1.14版中就多了一种新的实现,即所谓的Open Coded Defer。在研究新的实现之前,还是一如既往,先回顾一下之前的实现。

为了统一称谓,后面将“需要defer调用的函数”称为“defer函数”,将“使用defer机制的当前函数”称为“当前函数”,将“当前函数通过defer关键字来推后执行某defer函数”这一动作,称为“注册”。

🚲 deferproc

一直到Go1.12版本,代码中的defer都会被编译器转化为对runtime.deferproc函数的调用。Go语言中,每个goroutine都有自己的一个defer链表,而runtime.deferproc函数做的事情就是把defer函数及其参数添加到链表中,即我们所谓的注册。然后编译器还会在当前函数结尾处插入runtime.deferreturn的调用代码,后者会按照LIFO的顺序调用当前函数注册的所有defer函数。如果当前goroutine发生了panic,或者调用了runtime.Goexit,runtime会按照LIFO的顺序遍历整个defer链表逐一执行defer函数,直到某个defer函数完成了recover,或者最后程序退出。

runtime.deferproc函数原型如下:

1
func deferproc(siz int32, fn *funcval)

fn指向一个runtime.funcval结构,里面有defer函数的地址,有疑惑的话请参考这篇文章

siz表示defer函数参数占用空间的大小,这部分参数被编译器追加在fn的后面,注意是在栈上的fn后面,而不是其指向的funcval后面。这种机制已经超出了一般函数调用的范畴,可以说是编译器或者说语言设计者的特权。

假设有下面的函数:

1
2
3
4
5
6
func f1() {
defer func(i, j int) {
println(i + j)
}(10, 20)
// code to do something
}

在amd64平台,编译器会将其转化为类似如下伪代码的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func f1() {
r := runtime.deferproc(16, f1_func1, 10, 20) // 经过recover返回时r为1,否则为0
if r > 0 {
goto ret
}
// code to do something
runtime.deferreturn()
return
ret:
runtime.deferreturn()
}

// f1的defer函数
func f1_func1(i, j int) {
println(i + j)
}

实际上编译器在最后插入了两次runtime.deferreturn调用代码,我并不觉得这两个分支有何不同。根据我的统计,如果函数代码中出现了N次defer,编译器就会插入N+1次runtime.deferreturn。但是从deferreturn的实现来看,是可以通过一次调用遍历完当前函数的所有defer函数的,所以在最后插入一次runtime.deferreturn应该就可以。因此暂时认为是受编译器的实现所限,没有优化掉。

上面代码中,刚刚进入deferproc函数时,栈上的参数布局和栈指针位置如下图所示:
Args for deferproc

deferproc函数需要把这些信息保存到一个struct中,然后添加到链表里。这个struct就是runtime._defer,具体定义如下:

1
2
3
4
5
6
7
8
9
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表示defer参数占用的空间大小,与deferproc的第一个参数一样;

  • started表示有个panic或者runtime.Goexit已经开始执行该defer函数;

  • sp即当前函数栈指针SP,用来判断该defer是不是由当前函数注册,还有就是执行recover的时候用作栈指针恢复位置;

  • pc主要是在执行recover的时候用作指令指针恢复位置;

  • fn指向defer函数的funcval结构,与deferproc的第二个参数一样;

  • _panic是在当前goroutine发生panic后,runtime在执行defer函数时,会将该指针指向当前的_panic结构,以便发生嵌套panic时,能够找到上一个_panic结构;

  • link指针用来指向下一个_defer结构,从而形成链表。

但是问题来了,没有发现用来存储defer函数参数的空间,上图中的参数i和j应该被存储到哪里?实际上runtime用了和编译器一样的手段,在分配_defer结构的时候,后面额外追加了siz大小的空间,从而deferproc可以把defer函数的参数拷贝到那里进行保存。

所以deferproc的主要工作就是以下几点:

  1. 分配一个_defer结构d,为siz字段赋值,并将d添加到当前goroutine的defer链表中;
  2. 填充d的sp、pc、fn。通过getcallerpc和getcallersp来获取pc和sp的值,指向的是CALL runtime.deferproc的下一条指令。后面如果发生了panic,又通过该defer函数成功recover,那么指令指针和栈指针就会恢复到这里设置的pc、sp处,看起来就像刚从runtime.deferproc返回。不过recover后的返回值为1,后面的if语句会据此来跳过函数体,仅执行末尾的deferreturn;
  3. 拷贝defer函数的参数,如果有的话;
  4. 返回0,表示是注册defer函数。

其中第一步实际上是在runtime.newdefer函数中完成的,该函数针对不同大小的siz提供了per-P pool,以实现无锁分配。如果per-P pool耗尽,会尝试从全局sched.deferpool拿一部分,为的是减少堆分配以提升性能。

🚗 deferreturn

在正常情况下,注册过的defer函数是由runtime.deferreturn负责执行的,这个“正常情况”指的就是没有panic或runtime.Goexit,当前函数完成执行并正常返回时。

deferreturn函数原型如下:

1
func deferreturn(arg0 uintptr)

arg0参数没有任何含义,实际上编译器并不会传递这个参数,deferreturn内部通过取它的地址来得到栈上参数的起始地址,得以向栈上拷贝defer函数所需参数。

deferreturn所做的工作:

  1. 若defer链表为空,则直接返回,否则拿到第一个_defer的指针d,但并不从链表中移除;
  2. 判断d.sp是否等于当前栈指针SP,即d是否由当前函数注册,不相等则直接返回;
  3. 如果defer函数有参数,d.siz会大于0,就将参数拷贝到栈上&arg0处;
  4. 将d从defer链表移除,链表头指向d.link,通过runtime.freedefer释放d;
  5. 通过runtime.jmpdefer跳转到defer函数去执行。

runtime.freedefer释放d时会放到per-P pool中,因为pool是通过slice实现的,当pool存满后为了避免因扩容带来的开销,freedefer会把一半内容放到全局的sched.deferpool中,后者是个链表。

runtime.jmpdefer是用汇编语言实现的,这个函数特别有意思,它会先调整返回地址,例如在amd64平台会将返回地址减5,即一条CALL指令的大小,然后才会跳转到defer函数去执行。这样一来,等到defer函数执行完毕返回的时候,刚好会返回到编译器插入的runtime.deferreturn之前,从而实现无循环、无递归的重复调用deferreturn。直到当前函数的所有defer都执行完毕,deferreturn会在第1、2步判断时返回,不经过jmpdefer调整地址,从而结束重复调用。

从开始调用deferreturn函数,到执行完当前函数的所有defer,用一个流程图表示如下:

✈️ deferprocStack

到了Go1.13版本,运行时中多了一个deferprocStack函数,就像它的名字一样,是用来将一个位于栈上的_defer结构添加到当前goroutine的defer链表中。如此一来就不用再通过newdefer进行分配了,从而带来一定的性能提升。

所以同样是这段代码:

1
2
3
4
5
6
7
func f1() {
defer func(i, j int) {
println(i + j)
}(10, 20)
// code to do something
// ...
}

就会被实现成这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func f1() {
var d struct {
runtime._defer
i, j int
}
d.siz = 16
d.fn = f1_func1
d.i = 10
d.j = 20
r := runtime.deferprocStack(&d._defer)
if r > 0 {
goto ret
}
// code to do something
// ...
runtime.deferreturn()
return
ret:
runtime.deferreturn()
}

runtime._defer结构中也新增了一个bool类型的字段heap,用来表示是否为堆分配。对于这种栈上分配的,heap为false,所以deferreturn就不会用freedefer函数来进行释放了。

这种分配在栈上的优化虽然适用于很多场景,但是也有一定的局限性,无法支持循环中的defer,这种情况仍然需要deferproc来进行分配。而且这种优化只是节省了_defer结构的分配、释放时间,仍然需要将defer函数添加到链表中,在调用的时候也还要拷贝栈上参数,整体提升比较有限。

🚀 open coded defer

主角终于出场了,这就是Go1.14中实现的open coded defer。在本文的标题中,我似乎有为open coded defer吹嘘的嫌疑,到底是不是在吹嘘呢?先使用如下代码进行性能测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func BenchmarkDefer(b *testing.B) {
for i := 0; i < b.N; i++ {
Defer(i)
}
}

func Defer(i int) (r int) {
defer func() {
r -= 1
r |= r>>1
r |= r>>2
r |= r>>4
r |= r>>8
r |= r>>16
r |= r>>32
r += 1
}()
r = i * i
return
}

最近三个版本的测试结果如下所示:

go1.12,deferproc:

1
2
3
4
5
goos: windows
goarch: amd64
pkg: fengyoulin.com/research/defer_bench
BenchmarkDefer-8 30000000 41.1 ns/op
PASS

go1.13,deferprocStack:

1
2
3
4
5
goos: windows
goarch: amd64
pkg: fengyoulin.com/research/defer_bench
BenchmarkDefer-8 38968154 30.2 ns/op
PASS

go1.14,open coded defer:

1
2
3
4
5
goos: windows
goarch: amd64
pkg: fengyoulin.com/research/defer_bench
BenchmarkDefer-8 243550725 4.62 ns/op
PASS

从deferproc到deferprocStack,约有25%的性能提升,而open coded defer几乎提升了一个数量级。

下面就来看看是如何做到的。假如有以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func f2(i int) {
// code to do something
// ...

defer func(a, b int) {
println(a + b)
}(i, 2*i)

// code to do something
// ...

if i > 0 {
defer func(m, n string) {
println(m, n)
}("hello", "defer")
}

// code to do something
// ...
return
}

会被编译器转换为类似如下伪代码:

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
42
43
44
45
46
47
48
49
50
51
52
53
func f2(i int) (r int) {
// 以下5行声明的局部变量,还在panic时会被runtime扫描,以执行defer
var df byte // 控制defer执行的位标识
var d1 func(a, b int)
var d2 func(m, n string)
var a, b int
var m, n string

// code to do something
// ...

df |= 1
d1 = f2_func1
a = i
b = 2 * i

// code to do something
// ...

if i > 0 {
df |= 2
d2 = f2_func2
m = "hello"
n = "defer"
}

// code to do something
// ...

// 正常情况下,根据位标识直接调用函数,不再添加到链表
if df&2 > 0 {
df = df &^ 2
f2_func2(m, n)
}
if df&1 > 0 {
df = df &^ 1
f2_func1(a, b)
}
return

deferreturn:
runtime.deferreturn() // recover恢复到这里,处理剩余未执行的defer
return
}

// f2的两个defer函数
func f2_func1(a, b int) {
println(a + b)
}

func f2_func2(m, n string) {
println(m, n)
}

这里暂时不关心发生panic或者runtime.Goexit的情况,届时需要用到funcInfo信息扫描栈上的open coded defer。

在正常情况下,defer函数是通过编译器插入的代码直接调用的,无需链表和栈上参数拷贝,性能大幅提升。这种机制很像C++编译器插入的析构函数,不过并不是块级作用域。

这里虽然通过位标识解决了defer函数的条件执行问题,但是仍不能支持循环中的defer,还是要靠deferproc来实现。这已经优化了绝大部分场景,比如说前文提到的锁释放,就可以很方便的使用defer来处理了。

本文到此结束,至于panic和recover,如果能够找到一种好的方式来讲清楚,就在以后的文章中梳理一下。