defer
从本篇文章开始记录defer相关的知识点,defer相关的内容包括三部分,分别是defer注册、defer执行和defer优化策略。
1. defer注册
defer会在函数返回之前倒序执行,下面是一段go代码,及其在编译后的伪指令描述。
func A() {
defer B()
// code to do something
}
func A() {
r = deferproc(8, B)
if r > 0 {
goto ret
}
// code to do something
runtime.deferreturn()
ret:
runtime.deferreturn()
}
defer指令对应到两部分内容,deferproc负责把要执行的函数信息保存起来,称为defer注册。defer注册完成后,继续执行后面的逻辑,直到返回前通过deferreturn执行注册的defer函数。正是由于先注册,后调用的机制,才实现了defer延迟 执行的效果。
defer信息会注册到一个链表,当前执行的goroutine持有这个链表头指针,将一个个_defer结构体进行链接,新注册的defer会添加到链表头,执行时也是从头开始,这也是defer表现为倒序执行的原因。
func deferproc(siz int32, fn *funcval)
deferproc函数原型只有两个参数,siz指defer函数的参数和返回值共占用的内存空间大小,fn 是一个function value。没有捕获列表的function value编译器会做出优化,即在只读数据段分配一个共用的funcval结构体。
type _defer struct {
siz int32 // 参数和返回值大小(字节),注册时保存参数,执行时拷贝到调用者参数和返回值空间
started bool // defer是否已经执行
sp uintptr // 注册这个defer的函数栈指针,通过它函数可以判断自己注册的defer是否已经执行完
pc uintptr // deferproc的返回地址
fn *funcval // 注册的函数
_panic *_panic
link *_defer // 前一个注册的_defer结构体
}
deferproc函数调用时,编译器会在它自己的两个参数后面开辟一段空间,用于存放defer函数的参数和返回值。deferproc执行时,需要对分配一段空间,用于存放_defer结构体以及参数和返回值。实际上,go语言会预分配不同规格的deferpool,执行时从空闲defer中取出一个来用,如果没有合适大小的_defer,再进行堆分配。
2. defer执行
执行defer函数时,从当前goroutine拿到链表头上的这个_defer结构体,通过fn找到funcval,拿到函数入口地址,调用defer函数时,会把_defer结构体后面的参数和返回值整个拷贝到A1的调用者栈上。要注意,defer函数的参数,在注册时拷贝到堆上,执行时又拷贝到栈上。
func A1(a int) {
fmt.Println(a) // 1
}
func A() {
a, b := 1, 2
defer A1(a)
a = a + b
fmt.Println(a,b) // 3, 2
}
到这里已经很明了,deferproc的目的是注册一个function value结构体。那么,有捕获列表的情况下,会是一个怎样的过程呢?闭包函数会在执行阶段,根据代码段的闭包指令,创建闭包对象。如果捕获变量除了初始化赋值还被修改过,会进行局部变量的堆分配,栈上只保存变量在堆上的地址。deferproc执行时,_defer结构体中的fn,保存闭包函数funcval结构体的起始地址,另外还要拷贝参数到_defer结构体后面,把_defer结构体添加到defer链表头。函数执行完毕,到deferreturn时,defer函数执行,首先把参数b拷贝到栈上的参数空间,再执行后续操作。关键要理解defer传参和闭包捕获变量的实现机制。
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
go 1.12版本的defer设计有一个明显的问题,慢!主要原因如下,首先是_defer结构体堆分配,即使有预分配的deferpool也需要去堆上分配或释放,且defer函数传参还要在堆栈间进行拷贝。其次,使用_defer链表来注册当前goroutine的defer信息,而链表结构本身操作比较慢。于是,go1.13版本和go1.14版本中对defer进行了优化。
3. defer优化策略
Go1.12局限性在于,通过deferproc函数注册defer函数信息,defer结构体分配在堆上。
3.1. go 1.13版本优化策略
如下代码,go1.12和go 1.13版本以及编译后的指令。
func A() {
defer B(10)
// code todo sth
}
func B(i int) {
...
}
// go 1.12版本,编译后伪指令
func A() {
r := runtime.deferproc(8, B)
if r > 0 {
goto ret
}
// code to do something
runtime.deferreturn()
ret:
runtime.deferreturn()
}
// go1.13版本,编译后伪指令
func A() {
// 通过在编译阶段增加局部变量,把defer信息保存到当前函数栈帧的局部变量区域。
var d struct {
runtime._defer
i int
}
d.siz = 0
d.fn = B
d.i = 10
// 通过deferprocStack把栈上这个_defer结构体,注册到defer链表中。
r := runtime.deferprocStack(&d._defer)
if r > 0{
go to ret
}
// code to do sth
runtime.deferreturn()
return
ret:
runtime.deferreturn()
}
go 1.13中defer优化点主要在减少defer信息的堆分配,之所以说是减少,是因为在显式循环或隐式循环中,依然使用1.12中的方案,在堆上分配,因此_defer结构体中增加了一个字段,heap bool用于标识是否为堆分配。
// 显式循环
for i := 0; i < n; i++ {
defer B(i)
}
// 隐式循环
again:
defer B()
if i < n {
n++
goto again
}
type _defer struct {
siz int32 // 参数和返回值大小(字节),注册时保存参数,执行时拷贝到调用者参数和返回值空间
started bool // defer是否已经执行
heap bool // 是否堆分配
sp uintptr // 注册这个defer的函数栈指针,通过它函数可以判断自己注册的defer是否已经执行完
pc uintptr // deferproc的返回地址
fn *funcval // 注册的函数
_panic *_panic
link *_defer // 前一个注册的_defer结构体
}
在go1.13中,通过在编译阶段增加局部变量,将_defer结构体信息保存到当前函数栈帧的局部变量区域,再通过deferprocStack把栈上这个_defer结构体注册到defer链表中。defer执行时,依然通过deferreturn实现的,也同样要在defer函数执行时拷贝参数,这次不是在堆栈间,而是从栈上的局部变量空间拷贝到参数空间。
3.2. Go1.14版本优化策略
如下go语言代码,编译后的产生的指令。
func A(i int) {
defer A1(i, 2*ij)
// code to do sth
if i > 1 {
defer A2("hello")
}
// code to do sth
return
}
func A1(a, b int) {
...
}
func A2(m, n string) {
...
}
func A(i int) {
// df每一位,对应标识一个defer函数是否要被执行
var df byte
var a, b int = i, 2*i
// code to do sth
var m, n string = "hello","eggo"
// 通过或运算把df第一位置为1
df |= 1
// 根据具体条件,判断df第2个标识位是否要被置为1
if i > 1 {
df |= 2
}
// code to do sth
// 依据第二个标识位,判断是否要
if df & 2 > 0 {
// 函数返回前也要依据第二个标识位,决定是否要调用函数A2
df = df &^ 2
A2(m, n)
}
// 判断defer标识位是否为1
if df & 1 > 0 {
// 执行前,把df对应标识位置为0
df = df&^ 1
A1(a, b)
}
return
}
Go1.14的defer就是在编译阶段插入代码,把defer函数的执行逻辑展开在所属函数内,从而免于创建_defer结构体,而且不需要注册到defer链表,这种方式称为open coded defer。go1.14版本和go1.13版本一样,它依然不适合循环中的defer。因此,在go1.13和go
go1.14版本相比前两个版本提升了一个数量级,但是在代码发送panic或调用runtime.Goexit时,由于后面的defer函数代码执行不到,需要通过栈扫描的方式来发现,因此go1.14版本中,defer确实变快了,但是panic却变慢了,这也是go团队综合考虑的结果,毕竟panic发生的几率要比defer发生的几率小很多。