GoLang之defer底层(Go1.14)
注:本文以Go SDK v1.14进行讲解
1.open coded defer放大招
减少_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函数是否要执行。
例如,函数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的处理方式是一直保留的。
2.性能测试
接下来,我们使用如下代码进行性能测试:
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:
goos: windows
goarch: amd64
pkg: fengyoulin.com/research/defer_bench
BenchmarkDefer-8 30000000 41.1 ns/op
PASS
go1.13,deferprocStack:
goos: windows
goarch: amd64
pkg: fengyoulin.com/research/defer_bench
BenchmarkDefer-8 38968154 30.2 ns/op
PASS
go1.14,open coded defer:
goos: windows
goarch: amd64
pkg: fengyoulin.com/research/defer_bench
BenchmarkDefer-8 243550725 4.62 ns/op
PASS
从deferproc到deferprocStack,约有25%的性能提升,而open coded 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 //4
}
借助这些信息,panic处理流程可以通过栈扫描的方式找到这些没有被注册到defer链表的defer函数,并按照正确的顺序执行。
所以,实际上Go1.14版本中defer的确变快了,但panic变得更慢了…
3.视频讲解
1.14版本,有什么不一样的优化策略呢?这一次一部分一部分的看。这里是函数A编译后的伪指令,我们略去一部分recover相关的内容。函数A有两个defer,我们先看deferA1,这里把函数A1需要的参数定义为局部变量,然后在函数返回前直接调用defer函数A1,用这样的方式,省去了构造defer链表项,并注册到链表的过程,也同样实现了defer函数延迟执行的效果。
不过A2就不能这样简单处理了,它要到执行阶段才能确定是否需要被调用。Go语言用一个表示变量df来解决这个问题 。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版本的处理方式是一直保留的。通过性能测试三个版本的表现如上
可以看到1.13版本性能提升了25%左右,与官方提供的数据出入不大,而1.14版本的性能几乎提升了一个数量级,但是这并非没有代价,我们一直在梳理的,都是程序正常执行的流程,如果发生panic或者调用runtime.Goexit()函数, 后面这些代码根本执行不到,就要去执行defer链表了;
而这些open coded方式实现的defer,并没有注册到链表,需要额外通过栈扫描的方式来发现,所以1.14版本中的_defer结构体,在1.13版本的基础上,又增加了几个字段,借助这些信息,可以找到未注册到链表的defer函数。并按照正确的顺序执行 ;
所以实际上1.14版本中,defer的确变快了,但panic变得更慢了,但是Go语言做出这样的优化,一定是综合考量了整体性能,毕竟panic发生的几率要比defer低