【新人系列】Golang 入门(九):defer 详解 - 下
✍ 个人博客:https://blog.csdn.net/Newin2020?type=blog
📝 专栏地址:https://blog.csdn.net/newin2020/category_12898955.html
📣 专栏定位:为 0 基础刚入门 Golang 的小伙伴提供详细的讲解,也欢迎大佬们一起交流~
📚 专栏简介:在这个专栏,我将带着大家从 0 开始入门 Golang 的学习。在这个 Golang 的新人系列专栏下,将会总结 Golang 入门基础的一些知识点,并由浅入深的学习这些知识点,方便大家快速入门学习~
❤️ 如果有收获的话,欢迎点赞 👍 收藏 📁 关注,您的支持就是我创作的最大动力 💪
1. 函数嵌套
我们再来看一个形如 defer(A(B©) 这样的例子,看看 defer 执行时参数 a 到底等于多少。
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 函数注册时 A 传入的参数,即需要此刻计算出 B(1) 的返回值作为参数传入。
所以 A 最终会得到 B(1) = 2 的参数进行传入,又因为该参数不存在捕获的条件即不会形成闭包,也就等于传入的这个参数固定下来了,后面再去修改参数 a 也不会影响到 defer 函数最终的结果。因此,最终 defer A(B(a)) 将会输出 3。
2. defer 嵌套
最后再来看一个 defer 嵌套的例子,这次抛开所有细节只看 defer 链表随着 A 的执行会怎样变化。
func A() {
//......
defer A1()
//......
defer A2()
//......
}
func A2() {
//......
defer B1()
defer B2()
//......
}
func A1() {
//......
}
//所有defer函数都正常执行......
首先函数 A 注册了两个 defer,这里途中我们用 A1 和 A2 函数名进行标记。
在函数 A 返回前执行 deferreturn 时,会判断 defer 链表头上的 defer 是不是 A 注册的。而这个方法就是判断 defer 结构体记录的 sp 是否等于 A 的栈指针,即判断 A2.sp == SP of A 是否成立。
如果是 A 注册的,就会保存函数的相关信息,就会把它从 defer 链表中移除并执行函数 A2,此时又会注册两个 defer,并记为 B1 和 B2。
在 A2 返回前同样会去执行 defer 链表,同样判断是否是自己注册的 defer 函数,即判断 B2.sp == SP of A2 是否成立。然后 B2 执行,同样的流程 B1 执行。此时 A2 仍不知道自己注册的 defer 已经执行完了,直到下一个 _defer.sp 不等于自己的栈指针,然后 A2 就可以结束了。
再次回到 A 的 defer 执行流程,然后执行 A1,当 A1 执行结束后 defer 链表为空,函数 A 结束。这个例子的关键是 defer 链表注册时添加链表项,执行时移除链表项的用法。
至此 go 1.12 版本的 defer 基本设计思路就梳理完了,这个版本的 defer 一个明显的问题就是慢。
- 其中一个原因是 _defer 结构体堆分配,即使有预分配的 deferpool,也需要去堆上获取与释放,而且参数还要在堆栈之间来回拷贝。
- 第二个原因是使用链表注册 defer 信息,而链表本身操作就比较慢。
所以 go 在 1.13 和 1.14 中分别作了不同的优化。
3. defer 1.13
go 1.13 中 defer 性能的优化点,主要集中在减少 defer 结构体堆分配。我们来看一个例子,看看具体是如何做到的。
func A() {
defer B(10)
// code to do something
}
func B(i int) {
//......
}
在 go 1.12 版本中,编译后的伪指令如下。
func A() {
r := runtime.deferproc(0, B, 10)
if r > 0 {
goto ret
}
// code to do something
runtime.deferreturn()
return
ret:
runtime.deferreturn()
}
而在 go 1.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()
}
同样,我们先忽略掉错误处理的部分,看看 go 1.13 做出了怎样的优化。
func A() {
var d struct {
runtime._defer
i int
}
d.siz = 0
d.fn = B
d.i = 10
r := runtime.deferprocStack(&d._defer)
// code to do something
runtime.deferreturn()
return
}
在 1.12 中是通过 deferproc 注册 defer 函数信息,并将 _defer 结构体分配到堆上。
而在 1.13 中,通过在编译阶段增加上面结构体 d 这样的局部变量,把 defer 信息保存到当前函数栈帧的局部变量区域,然后再通过 deferprocstack,把栈上这个 _defer 结构体注册到 defer 链表中。
defer 1.13 的优化点主要就在于减少 defer 信息的堆分配。之所以说减少,是因为像下面这样的显示循环或隐式循环中的 defer 依然需要使用 1.12 版本的处理方式,只能在堆上分配,即使只执行一次 for 循环也是一样。
//显示循环
for i:=0; i< n; i++ {
defer B(i)
}
//隐式循环
again:
defer B()
if i<n {
n++
goto again
}
为此 _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 函数的调用者栈上。只不过不是从堆上拷贝到栈上,而是从栈上的局部变量空间拷贝到参数空间。
1.13 的 defer 官方提供的性能提升是 30%,我们来看看 1.14 又有什么样的优化策略。
4. defer 1.14
为了能更清楚的理解 1.14 的优化策略,我们这次案例一部分一部分来看。
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) {
//......
}
先来看看 A1 的伪指令,去掉错误处理的相关操作,这里会把 defer 函数 A1 需要的参数定义为局部变量,然后在函数返回前正常调用 defer 函数 A1。用这样的方式,可以省去构造 defer 链表项并注册到链表的过程,也同样实现了 defer 函数延迟执行的效果。
func A(i int){
var a, b int = i, 2*i
// code to do something
A1(a, b)
return
}
不过 A2 就不能这样处理了,它要到执行阶段才能确定是否需要被调用。go 语言会用一个标识变量 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 // 执行前把df对应的标识位置为0,避免重复执行
A1(a, b)
}
return
}
同样的方式到 A2 这里,在程序执行阶段就会根据具体条件,判断 df 第二个标识位是否要被置为 1。对应的函数返回前,也要依据第二个标识位来决定是否要调用函数 A2。
func A(i int){
var df byte
var a, b int = i, 2*i
// code to do something
var m, n string = "Hello", "eggo"
df |= 1
if i > 1 {
df |= 2
}
if df & 2 > 0 {
df = df &^ 2 // 执行前把df对应的标识位置为0,避免重复执行
A2(m, n)
}
if df & 1 > 0 {
df = df &^ 1 // 执行前把df对应的标识位置为0,避免重复执行
A1(a, b)
}
return
}
go 1.14 的 defer 就是通过在编译阶段插入代码,把 defer 函数的执行逻辑展开在所属函数内,从而免于创建 _defer 结构体,而且也不需要注册到 defer 链表。
go 语言称这种方式为 open coded defer,但是跟 1.13 一样,它依然不适用于循环中的 defer。所以在这两个版本中,1.12 版本的处理方式是一直保留的。
5. 性能测试
我们可以通过性能测试来看看三个版本的表现如何,假设用下面这段代码进行性能测试。
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
}
这三个版本的测试结果如下所示:
- go 1.12,deferproc:
goos: windows
goarch: amd64
pkg: Desktop/project/test
BenchmarkDefer-8 30000000 41.1 ns/op
PASS
- go 1.13,deferprocStack:
goos: windows
goarch: amd64
pkg: Desktop/project/test
BenchmarkDefer-8 38968154 30.2 ns/op
PASS
- go 1.14,open coded defer:
goos: windows
goarch: amd64
pkg: Desktop/project/test
BenchmarkDefer-8 243550725 4.62 ns/op
PASS
可以发现 1.13 版本的性能提升了 25% 左右,而 1.14 版本的性能直接几乎提升了一个数量级。
但是这也需要付出一些代价,我们一直在梳理的都是程序正常执行的流程,如果在代码中发生了 panic 或者调用 runtime.Goexit() 函数,其后的代码逻辑就压根执行不到就去执行 defer 链表。而这些 open coded 方式实现的 defer 并没有注册到链表,需要额外通过栈扫描的方式来发现。
所以 1.14 _defer 结构体在 1.13 版本的基础上,又增加了几个字段,借助这些信息就可以找到未注册到链表的 defer 函数,并按照正确的顺序执行。
type _defer struct {
siz int32
started bool
heap bool
openDefer bool
sp uintptr
pc uintptr
fn *funcval
_panic *_panic
link *_defer
fd unsafe.Pointer
varp uintptr
framepc uintptr
}
因此在 1.14 版本中,defer 虽然变快了,但 panic 反而变得更慢了。不过 go 语言选择做出这样的优化,肯定是综合考量了整体的性能,毕竟 panic 发生的几率要比 defer 低。