它是什么?
一句话:一个在编译时优化defer的方式。
Go1.14引入了开放编码方式实现defer,实现了近乎零成本的 defer 调用,当没有设置-N禁用内联、 defer 函数个数和返回值个数乘积不超过 15 个、defer 函数个数不超过 8 个、且defer没有出现在循环语句中时,会使用此类开放编码方式实现defer;实现原理是:编译器会根据延迟比特deferBits和state.openDeferInfo结构体存储defer相关参数,将defer函数直接在当前函数内展开,并在返回语句的末尾根据延迟比特的相关位是否为1执行defer调用;此类 defer 的运行时成本是根据执行条件与延迟比特确定相关defer函数是否需要执行,开放编码运行时性能最好。(来源)
详细原理
先看一个example:
defer f1(a)
if cond {
defer f2(b)
}
body...编译结果:
deferBits |= 1<<0
tmpF1 = f1
tmpA = a
if cond {
deferBits |= 1<<1
tmpF2 = f2
tmpB = b
}
body...
exit:
if deferBits & 1<<1 != 0 {
deferBits &^= 1<<1
tmpF2(tmpB)
}
if deferBits & 1<<0 != 0 {
deferBits &^= 1<<0
tmpF1(tmpA)
}从这里我们可以看到,每一个defer被添加到程序的后面,并使用deferBits存储哪些defer被激活,哪些没有被激活;通过这种方式,程序在处理defer时不再需要维护栈runtime._defer 在遇到defer时也不再需要进行压栈等操作,因此通过这种方式实现的defer几乎是零成本的。
但是你们很快就会发现,编译器对每个defer只保存有一个副本和一个判断值,因此对于存在循环defer的情况下,编译器会选择使用旧的方式实现,即使用栈保存。在默认情况下deferBits 是8位,即最多保存8个defer的状态,但是在这里作者提及它可以被修改为64。
至于为什么不能是更大的数,作者在issue中提及:我们的目标是在常见情况下减少defer开销,这些情况通常是带有少量defer的小型函数。在特殊情况下(带有循环defer的函数或大量defer语句),我们只需恢复到当前方法即可。
panic时怎么办
聪明的你肯定想到了这种方式只实现了正常情况下的defer,但是当触发panic时,程序依然需要调用defer函数来破局,那么这种优化会带来额外的麻烦。
在当前版本(1.22)下,go使用了nextDefer 实现panic下的defer遍历。
下面是nextDefer中遍历defer的过程
p.deferBitsPtr指向一个存储位掩码的内存位置。这个位掩码用来记录哪些 open-coded defers 还未执行。首先检查位掩码是否为 0,如果是,说明已经没有待执行的 open-coded defers 了,可以退出循环。
如果位掩码不为 0,则需要找到最高位为 1 的位置。这个位置对应着下一个需要执行的 open-coded defer 函数。
使用
sys.LeadingZeros8函数找到最高位为 1 的位置,然后计算出它在位掩码中的索引i。清除位掩码中对应的位,并将更新后的位掩码写回内存。
根据索引
i,从p.slotsPtr指向的函数指针数组中取出对应的 defer 函数,并返回它。
相关源码如下:
// go/src/runtime/panic.go:874
for p.deferBitsPtr != nil {
bits := *p.deferBitsPtr
// Check whether any open-coded defers are still pending.
if bits == 0 {
p.deferBitsPtr = nil
break
}
// Find index of top bit set.
i := 7 - uintptr(sys.LeadingZeros8(bits))
// Clear bit and store it back.
bits &^= 1 << i
*p.deferBitsPtr = bits
return *(*func())(add(p.slotsPtr, i*goarch.PtrSize)), true
}
历史
在《深度探索go语言》中写到panic实现了addOneOpenDeferFrame函数和runOpenDeferFrame函数来遍历检查defer,但在最新版的go中这两个函数被删去了,检查commit发现23年7月的commit中重构了,转而使用nextDefer函数。,在旧版本中addOneOpenDeferFrame的运行逻辑为:
初始化:如果
sp为nil,从 Goroutinegp的现有延迟调用记录中获取初始的程序计数器和栈指针。系统栈操作:在系统栈上执行后续操作,确保运行时稳定性。
遍历栈帧:使用
unwinder遍历当前 Goroutine 的栈帧。处理延迟调用:
跳过已经处理过的延迟调用栈帧。
如果栈帧有
defer信息,准备插入新的 open defer 记录。
插入新记录:
创建并初始化一个新的
_defer结构体。将新
_defer按栈指针顺序插入到gp的延迟调用链表中。
终止扫描:添加一个 open defer 记录后停止进一步的栈帧扫描。
runOpenDeferFrame 函数的运行逻辑如下:
初始化:
获取延迟函数数据
fd和deferBitsOffset。读取延迟调用数量
nDefers和deferBits。
遍历延迟调用:
从最后一个延迟调用开始,逆序遍历每个延迟调用。
对于每个延迟调用,读取
closureOffset并检查是否需要执行该延迟调用(根据deferBits位掩码)。
执行延迟调用:
如果需要执行,将延迟调用设置为当前
_defer的函数并清除相应的deferBits位。调用
deferCallSave执行延迟函数,处理可能的 panic 和 recover 情况。
检查恢复:
如果
defer调用成功恢复(recovered),标记done并退出循环。如果
panic被中止,立即退出循环。
返回结果:
返回
done,表示当前栈帧是否还有未执行的延迟调用。
在Go的blog中写到:
在编译器优化的时候,会额外附带一组funcdata 信息,用于记录有关每个defer的信息。
为了处理panic,运行时在概念上与栈并行遍历defer链,以便将推入的defer与开放编码帧中的defer交错执行。当运行时遇到正在执行函数f的开放编码帧F时,它将执行以下步骤:
运行时读取包含开放
defer信息的函数f的funcdata。使用有关栈帧F中deferBits堆栈槽位置的信息,运行时加载此帧的
deferBits的当前值。运行时按照deferBits的值指定的顺序,逆序处理每个活动的defer。对于每个活动的
defer,运行时从适当的堆栈槽中加载defer调用的函数指针。它还通过将每个defer参数从其指定的堆栈槽复制到参数帧中的适当位置来构建一个参数帧。然后,它在屏蔽掉当前defer的位后,更新其堆栈槽中的deferBits。然后,它使用函数指针和参数帧调用延迟的函数。如果
defer调用正常返回而没有进行恢复,则运行时将继续为帧F执行活动的defer调用,直到所有活动的defer调用都完成为止。如果任何defer调用正常返回但已成功恢复,则运行时停止处理当前帧中的
defer。可能还有也可能没有剩余的defer要处理。然后,运行时安排跳转到deferreturn代码段,并同时将PC设置为deferreturn段的地址,并将SP设置为帧F的适当值,以展开栈至帧F。deferreturn代码段然后回调到运行时。现在,运行时可以处理帧F中任何剩余的活动defer。但对于这些defer,栈已适当展开,defer看起来是直接从函数f调用的。当帧的所有defer完成后,deferreturn结束,代码段从帧F返回继续执行.
如果步骤3中的延迟调用本身引发了panic,运行时会再次启动其正常的panic处理。对于已经运行了一些defer的任何带有开放编码defer的帧,在指定的堆栈槽中的deferBits值将始终准确反映需要运行的剩余defer。(gpt-4o翻译)
参考文献
https://github.com/golang/proposal/blob/master/design/34481-opencoded-defers.md
https://github.com/golang/go/issues/34481
https://go-review.googlesource.com/c/go/+/190098/6
https://cloud.tencent.com/developer/article/2137023
《深度探索go语言》
https://go-review.googlesource.com/c/go/+/513837
个人学习笔记,仅供参考