目前的Go defer的源码分析文章很多都绕过了最为复杂的编译器优化阶段,而且对开放编码方式实现defer关键字的原理解释的不够清楚,本文尝试啃下defer在编译器(gc, go compiler)优化阶段的这个硬骨头,并给出defer在堆上分配、栈上分配、开放编码三种实现方式的编译期和运行时的完整的执行过程。
对defer的原理分析,最为经典的还是《Go语言设计与实现》5.3节defer 和《Go语言原本》的3.4节延迟语句,只是代码不够新。
本文的代码基于Go1.18.1版本,相比于Go1.14,defer的实现没有大逻辑的变化,不过在部分地方进行了优化,如堆分配调用的runtime.deferproc函数,没有调用汇编实现的 runtime.jmpdefer 函数进行多 defer 的循环遍历,而是简单直接的采用 for 循环。
1. Go最早引入的是defer在堆上分配的实现方式,实现原理是:编译器首先会将defer延迟语句转成 runtime.deferproc 调用,并在defer所在函数返回之前插入runtime.deferreturn函数;运行时调用runtime.deferproc函数获取一个runtime._defer 结构体纪录defer函数的相关参数,并将该_defer结构体入栈到当前 Goroutine 的defer延迟链表头;defer所在函数返回时,调用runtime.deferreturn函数从Goroutine的链表头取出runtime._defer结构体并依次执行,这样能保证同一个函数中的多个defer能以LIFO的顺序执行;此类defer的性能问题在于每个defer语句都要在堆上分配内存,性能最差;
2. Go1.13 引入defer在栈上分配的实现方式,实现原理是:编译器会直接在栈上创建一个runtime._defer 结构体,不涉及内存分配,并将_defer结构体指针作为参数,传入runtime.deferprocStack函数,同时,会将runtime._defer 压入 Goroutine 的延迟调用链表中;在函数退出时,调用 deferreturn,将 runtime._defer 结构体出栈并执行;此类 defer 的成本是需要将运行时确定的参数赋值给栈上创建的 runtime._defer 结构体,运行时性能比堆上分配高 30%;
3. Go1.14引入了开放编码方式实现defer,实现了近乎零成本的 defer 调用,当没有设置-N禁用内联、 defer 函数个数和返回值个数乘积不超过 15 个、defer 函数个数不超过 8 个、且defer没有出现在循环语句中时,会使用此类开放编码方式实现defer;实现原理是:编译器会根据延迟比特deferBits和state.openDeferInfo结构体存储defer相关参数,将defer函数直接在当前函数内展开,并在返回语句的末尾根据延迟比特的相关位是否为1执行defer调用;此类 defer 的运行时成本是根据执行条件与延迟比特确定相关defer函数是否需要执行,开放编码运行时性能最好。
从defer三种实现方式中,我们能够学到Go语言的一些设计思想:能在编译期确定的事情尽量在编译期确定,避免运行时的计算增加额外的性能损耗;条件简单、使用频率最高的场景优先用性能最高的方式实现,尽量在栈上而不是堆上分配内存空间去实现,情况复杂的条件、需要运行时动态确定的才在运行时执行。
defer关键字可以说是Go日常开发中使用最为频繁的功能之一。
defer的定义是:defer语句调用一个函数,该函数的执行被推迟到其所在函数返回的那一刻,要么是所在函数执行了return语句,要么是到达了所在函数体的末尾,要么是所在的goroutine发生了panic。见:
A "defer" statement invokes a function whose execution is deferred to the moment the surrounding function returns, either because the surrounding function executed a return statement, reached the end of its function body, or because the corresponding goroutine is panicking.
defer常用来关闭文件描述符、关闭数据库连接、释放锁资源等,可以减少资源泄漏的情况发生。
defer的基本用法有五种,分别是:
1. 延迟调用:defer调用的函数在return前才被执行;
2. LIFO(Last In First Out,后进先出):多个defer语句,按压栈的方式即后进先出的顺序执行;
3. 作用域:defer 只会和 defer 语句所在的特定函数绑定在一起,作用域也只在这个函数;
4. 对函数传入变量的不同处理:变量作为函数参数,在defer申明时就把值传递给defer,并将值缓存起来使用;变量作为匿名函数引用,在defer函数执行时根据整个上下文确定变量的值;
5. 异常处理:通过 defer 和 recover 的结合,可以捕捉 defer 所在函数的 panic;
下面分别举例介绍。
Go执行如下代码:
package main
import (
"fmt"
)
func main() {
defer fmt.Println("defer runs")
fmt.Println("main ends")
}
打印结果是:
main ends
defer runs
defer语句调用的函数,会在所在函数main所有语句执行完成之后,return之前调用。
一个函数中有多个defer,多个defer像被压栈一样,后被压栈的defer先出栈被调用。
package main
import (
"fmt"
)
func main() {
defer fmt.Println("defer 1 runs")
defer fmt.Println("defer 2 runs")
defer fmt.Println("defer 3 runs")
fmt.Println("main ends")
}
执行结果是:
main ends
defer 3 runs
defer 2 runs
defer 1 runs
defer 只会和 defer 语句所在的特定函数绑定在一起,作用域也只在这个函数。 从语法上来讲,defer 语句也一定要在函数内,否则会报告语法错误。
package main
import (
"fmt"
)
func main() {
{
defer fmt.Println("defer runs")
fmt.Println("block ends")
}
fmt.Println("main ends")
}
这段代码的执行结果是:
block ends
main ends
defer runs
defer 调用的函数不是在退出代码块的作用域时执行的,它只会在当前函数和方法返回之前被调用。
将代码逻辑修改为:
package main
import (
"fmt"
)
func main() {
func() {
defer fmt.Println("defer runs")
fmt.Println("block ends")
}()
fmt.Println("main ends")
}
即将defer放在匿名函数里,就 main 函数本身来讲,匿名函数 fun(){}() 先调用且返回,然后才执行打印"main ends"的代码,因此执行结果是:
block ends
defer runs
main ends
变量作为函数参数,在defer申明时就把值传递给defer,并将值缓存起来使用;变量作为匿名函数引用,在defer函数执行时根据整个上下文确定变量的值。
package main
import (
"fmt"
)
func main() {
i := 0
// i变量作为defer函数参数
defer fmt.Println("a:", i)
// 匿名函数调用,捕获同作用域下的i进行计算
defer func() {
fmt.Println("b:", i)
}()
i++
}
该程序的执行结果是:
b:
a: 0
第一个defer调用函数引用参数 i,是在defer调用时确定的值 0,第二个defer调用匿名函数(或闭包)传入的 i, 是在main函数返回时的值 1。
package main
import (
"fmt"
)
func main() {
defer func() {
if e := recover(); e != nil {
fmt.Println("defer runs")
}
}()
panic("throw panic")
}
执行结果是:
defer runs
main 函数先注册一个 defer ,且稍后主动触发 panic,main 函数退出之际就会调用 defer 注册的匿名函数,匿名函数里,recover函数可以捕获panic,使程序正常退出。
defer关键字在Go语言源代码中的数据结构是runtime._defer:
type _defer struct {
started bool // 标识defer函数是否已经开始执行
heap bool // 标识该defer结构是否在堆上分配
openDefer bool // 是否以开放编码的方式实现该defer
sp uintptr // 调用方的栈指针
pc uintptr // 调用方的程序计数器
fn func() // defer调用的函数
_panic *_panic // defer关联的panic,非panic触发时为空
link *_defer // G 上的defer链表; 既可以指向堆也可以指向栈
fd unsafe.Pointer // 跟defer调用函数相关的参数
varp uintptr
framepc uintptr
}
主要的字段有:
heap 标识该defer结构是否在堆上分配;
openDefer 表示当前 defer 是否以开放编码的方式实现;
sp 和 pc 分别代表调用方的栈指针和程序计数器;
fn 是 defer 关键字中传入的函数;
_panic 是触发defer延迟调用的结构体,非panic触发时为空;
link defer链表,指所在函数的goroutine的defer链表;
一个函数内可以有多个 defer 调用,所以需要一个数据结构来组织这些 _defer 结构体,这就是link字段的作用, 它把函数中的所有_defer串成一个链表,表头是挂在当前Goroutine 的 _defer 字段。
函数中的defer按从上到下的注册顺序依次放入link链表头,函数退出时调用的顺序也是依次从链表头获取,这就是LIFO特性的由来,后面会具体分析。
从直觉上看, defer 应该由编译器直接将需要的函数调用插入到该调用的地方,似乎是一个编译期特性, 不应该存在运行时性能问题。但实际情况是,由于 defer 并没有与其依赖资源挂钩,也允许在条件、循环语句中出现, 这使得 defer 的语义变得相对复杂,有时无法在编译期决定存在多少个 defer 调用。这使defer有可能在堆上或栈上实现。
在讨论defer的堆分配、栈分配、开放编码三种实现方式之前,需要先读懂 Go 语言编译器优化的代码逻辑,要读懂 Go 编译器优化的代码,需要先弄清楚 Go 语言编译器怎样将程序员写的 Go代码编译成机器可执行的二进制代码。
Go编译器将程序员写的Go代码编译成机器可执行的二进制代码的主要步骤是:
1)Go 编译器将程序员写的源代码经过词法分析(lexical analysis, 解析源代码文件,它将文件中的字符串序列转换成 Token 序列)和语法分析(syntax analysis,按照顺序解析 Token 序列并对每个源文件生成一个个语法树syntax tree,语法树使用节点Node表达变量、函数声明、表达式语句等),该段逻辑主要体现在 cmd/compile/internal/syntax 包中;
2)接着编译器会将上面生成的语法树转化为抽象语法树 AST(Abstract Syntax Tree),接着是做类型检查,类型检查会遍历抽象语法树中的Node节点,对每个节点的类型进行检验,找出其中存在的语法错误,去除不会被执行的代码,执行逃逸分析,进行函数内联优化以提高效率等;该段逻辑主要体现在 cmd/compile/internal/gc 包中,注意,这里的 gc 是go compiler编译器的意思,而不是GC垃圾回收的意思;
3)经过上面的语法分析和类型检查,就可以认为当前文件中的代码不存在语法错误和类型错误的问题了;接着,Go编译器会将抽象语法树AST编译成一种具有SSA(Static Single Assignment,静态单赋值)特性的中间代码,中间代码是一种更接近机器语言的表示形式,这种中间代码要经过50多轮处理转换,会生成最终的SSA中间代码,目的是为了提高执行效率,Go语言中的许多关键字都是在这个阶段被转换成运行时中的方法的,包括今天要分析的defer;该段逻辑主要体现在cmd/compile/internal/gc 和 cmd/compile/internal/ssa 包中;
4)最后一步,是将SSA中间代码生成可执行的机器代码,即在目标 CPU 架构上能够运行的二进制代码,该过程其实是对 SSA 中间代码的降级(lower)过程,并涉及对特定CPU、寄存器和内存的处理;我们经常用来打印 Go 汇编代码的命令GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go生成的就是机器码;该段逻辑主要体现在 cmd/compile/internal/ssa 和cmd/internal/obj包中;
参考 src/cmd/compile/README.md 和《Go语言设计与实现》第一章,要了解 Go语言各种关键字的实现原理,阅读Go编译器的源码是绕不开的。
如图2.2所示,是Go1.18的编译器的执行过程。
1)编译器入口在cmd/compile/internal/gc/main.go包的gc.Main()方法;
2)gc.Main() 调用cmd/compile/internal/noder/noder.go 的 noder.LoadPackage() 进行词法分析、语法分析和类型检查,并生成抽象语法树 AST;
3)Main() 调用 cmd/compile/internal/gc/compile.go的gc.enqueueFunc(),后者调用gc.prepareFunc(),最终调用cmd/compile/internal/walk/walk.go包的walk.Walk()方法,遍历并改写代码中的AST节点,为生成最终的抽象语法树AST做好准备;需要注意的是:walk.Walk()方法里会将一些关键字和内建函数转换成运行时的函数调用,比如,会将 panic、recover 两个内建函数转换成 runtime.gopanic 和 runtime.gorecover 两个真正运行时函数,关键字 new 也会被转换成调用 runtime.newobject 函数,还会将Channel、map、make、new 以及 select 等关键字转换成相应运行时函数;而defer关键字的主要处理逻辑却不在这里;
4)然后,Main() 方法调用 cmd/compile/internal/gc/compile.go 的 gc.compileFunctions()方法,将抽象语法树AST生成SSA中间代码,其中具体调用的是cmd/compile/internal/ssagen/pgen.go 的 ssagen.Compile()方法,该方法调用cmd/compile/internal/ssagen/ssa.go 的ssagen.buildssa();
5)ssagen.buildssa()调用同文件的state.stmtList(),state.stmtList()会为传入的每个节点调用state.stmt()方法,state.stmt()根据节点操作符的不同将当前AST节点转换成对应的中间代码;注意:defer关键字的处理在state.stmt()方法这里;
6)ssagen.buildssa() 调用 cmd/compile/internal/ssa/compile.go 的 ssa.Compile() 方法,经过50多轮处理优化,包括去掉无用代码、根据目标CPU架构对代码进行改写等,提高中间代码执行效率,得到最终的SSA中间代码;
7)通过命令 GOSSAFUNC=main GOOS=linux GOARCH=amd64 go build main.go可以打印并查看源代码、对应的抽象语法树AST、几十个版本的中间代码、最终生成的 SSA以及机器码。
这整个编译过程中,涉及到defer关键字处理的逻辑在cmd/compile/internal/ssagen/ssa.go包的state.stmtList()调用的state.stmt()方法,下面会多次用到。
cmd/compile/internal/ssagen/ssa.go包的state.stmt()方法负责处理代码中的defer关键字,会根据条件的不同,使用三种不同的机制实现 defer:
// 转化语句到SSA中间代码
func (s *state) stmt(n ir.Node) {
......
case ir.ODEFER: // 如果AST节点是defer类型
n := n.(*ir.GoDeferStmt)
if base.Debug.Defer > 0 { // 为编译器的调试状态打印defer类型: 开放编码,栈分配,堆分配
var defertype string
if s.hasOpenDefers {
defertype = "open-coded"
} else if n.Esc() == ir.EscNever {
defertype = "stack-allocated"
} else {
defertype = "heap-allocated"
}
base.WarnfAt(n.Pos(), "%s defer", defertype)
}
if s.hasOpenDefers { // 如果允许开放编码,则用该方式实现defer
s.openDeferRecord(n.Call.(*ir.CallExpr))
} else {
d := callDefer // 默认使用堆分配的方式实现defer
if n.Esc() == ir.EscNever { // 如果没有发生内存逃逸,使用栈分配的方式实现defer
d = callDeferStack
}
s.callResult(n.Call.(*ir.CallExpr), d)
}
......
}
从这段逻辑可以知道:
1)实现defer的方式有三种:开放编码,栈分配,堆分配;
2)如果允许使用开放编码的方式,则优先使用该方式实现defer,这是Go1.14引入的,性能最好;后面我们会分析到,实现开放编码的条件是:a) 编译器没有设置参数-N,即没有禁用内联优化;b)函数中defer的个数不超过一个字节的位数,即不超过8个;c)defer的函数个数和参数的个数乘积小于15;d)defer关键字不能在循环中执行,满足这些条件的defer调用场景简单,绝大多数信息都能在编译期确定;
3)如果没有发生内存逃逸到堆上,则优先使用栈分配的方式实现defer,这是Go1.13引入的优化方式,减少了内存在堆上分配的额外开销,提升了30%左右的性能;
4)前面两种方式都不符合条件,则默认使用堆上分配的方式实现defer;
下面首先分析Go最早采用的defer在堆上分配的实现方式,接着分析defer栈上分配,最后是开放编码的实现方式。
如果defer在循环中,由于可执行的次数可能无法在编译期决定,则defer会在堆上分配,有如下defer在for循环中调用的代码:
package main
import (
"fmt"
)
func main() {
for i:=0; i < 3; i++ {
defer A(i)
}
}
func A(i int) {
fmt.Println(i)
}
通过 GOSSAFUNC=main GOOS=linux GOARCH=amd64 go build main.go 可以打印出源代码到抽象语法树AST到50多轮SSA中间代码的生成过程,如图3.1所示:
在图3.1的start环节,这个SSA中间代码生成的早期阶段,可以看到,defer在循环中,会转成调用runtime.deferproc函数,在main函数退出时,会调用runtime.deferreturn函数。
这个转换过程,在 cmd/compile/internal/ssagen/ssa.go 的 stat.stmt() 函数处理defer时调用的stat.call() 函数中体现:
func (s *state) call(n *ir.CallExpr, k callKind, returnResultAddr bool) *ssa.Value {
.....
var callArgs []*ssa.Value
......
var call *ssa.Value
if k == callDeferStack {
......
} else {
// 在调用runtime.deferproc实现defer函数调用前,需要把defer函数的参数,和接受者的地址存放到defer所在函数的栈帧上,以栈指针SP+偏移的方式入栈
argStart := base.Ctxt.FixedFrameSize()
// 记录defer函数的参数
if k != callNormal && k != callTail {
// 处理闭包,记录闭包函数地址,分配空间
ACArgs = append(ACArgs, types.Types[types.TUINTPTR])
callArgs = append(callArgs, closure)
stksize += int64(types.PtrSize)
argStart += int64(types.PtrSize)
}
// 记录接受者地址
if rcvr != nil {
callArgs = append(callArgs, rcvr)
}
// 写入defer函数参数到变量callArgs
t := n.X.Type()
args := n.Args
for _, p := range params.InParams() {
ACArgs = append(ACArgs, p.Type)
}
for i, n := range args {
callArgs = append(callArgs, s.putArg(n, t.Params().Field(i).Type))
}
callArgs = append(callArgs, s.mem())
// 调用目标函数
switch {
case k == callDefer:
// defer在堆上分配会调用运行时的runtime.deferproc函数
aux := ssa.StaticAuxCall(ir.Syms.Deferproc, s.f.ABIDefault.ABIAnalyzeTypes(nil, ACArgs, ACResults))
call = s.newValue0A(ssa.OpStaticLECall, aux.LateExpansionResultType(), aux)
......
}
call.AddArgs(callArgs...)
call.AuxInt = stksize
}
......
// 结束 defer 块
if k == callDefer || k == callDeferStack {
......
}
......
}
函数退出时,如果有defer,堆分配或栈分配到方式会调用runtime.deferreturn函数,保证函数中的defer按LIFO后进先出的顺序执行:
// cmd/compile/internal/ssagen/ssa.go
func (s *state) exit() *ssa.Block {
// 函数退出时,如果有defer
if s.hasdefer {
if s.hasOpenDefers {
......
} else {
// 调用runtime.deferreturn函数
s.rtcall(ir.Syms.Deferreturn, true, nil)
}
}
Go代码还是跟3.1节一样:
package main
import (
"fmt"
)
func main() {
for i:=0; i < 3; i++ {
defer A(i)
}
}
func A(i int) {
fmt.Println(i)
}
执行下面命令打印汇编代码:
GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go
得到的汇编指令如下:
"".main STEXT size=177 args=0x0 locals=0x30 funcid=0x0
0x0000 00000 (main.go:7) TEXT "".main(SB), ABIInternal, $48-0
......
0x000a 00010 (main.go:7) SUBQ $48, SP
0x000e 00014 (main.go:7) MOVQ BP, 40(SP)
0x0013 00019 (main.go:7) LEAQ 40(SP), BP
......
0x0018 00024 (main.go:8) MOVQ $0, "".i+16(SP) // 将0入栈到SP+16位置
0x0021 00033 (main.go:8) JMP 35 // 执行35行也就是下一行指令
0x0023 00035 (main.go:8) CMPQ "".i+16(SP), $3 // 比较i=0和3的大小
0x0029 00041 (main.go:8) JLT 45 // i小于3则执行45行指令
0x002b 00043 (main.go:8) JMP 151 // 否则跳转151行指令
0x002d 00045 (main.go:9) MOVQ "".i+16(SP), CX // 将i=0这个局部变量赋值给CX寄存器
0x0032 00050 (main.go:9) MOVQ CX, ""..autotmp_1+24(SP) //将CX寄存器存储的0值传入SP+24的栈地址,作为defer函数的入参
0x0037 00055 (main.go:9) LEAQ type.noalg.struct { F uintptr; ""..autotmp_1 int }(SB), AX // 为defer函数定义一个funcval类型的闭包函数结构体
0x003e 00062 (main.go:9) PCDATA $1, $0
0x003e 00062 (main.go:9) NOP
0x0040 00064 (main.go:9) CALL runtime.newobject(SB) // 调用newobject在堆上为defer函数开辟一块空间
0x0045 00069 (main.go:9) MOVQ AX, ""..autotmp_2+32(SP) // 将堆上的defer函数的地址入栈到SP+32的位置
0x004a 00074 (main.go:9) LEAQ "".main·dwrap·1(SB), CX // 将编译器生成的代码段中的defer函数地址赋值给CX寄存器
0x0051 00081 (main.go:9) MOVQ CX, (AX) // 将编译器生成到代码段的 main·dwrap·1 函数地址,通过CX寄存器赋值给闭包函数funcval
0x0054 00084 (main.go:9) MOVQ ""..autotmp_2+32(SP), CX // 将堆上的defer函数地址赋值给CX寄存器
0x0059 00089 (main.go:9) TESTB AL, (CX)
0x005b 00091 (main.go:9) MOVQ ""..autotmp_1+24(SP), DX // 将参数0赋值给寄存器DX
0x0060 00096 (main.go:9) MOVQ DX, 8(CX) // 将DX寄存器的参数赋值给CX+8即作为defer函数的入参
0x0064 00100 (main.go:9) MOVQ ""..autotmp_2+32(SP), BX
0x0069 00105 (main.go:9) XORL AX, AX
0x006b 00107 (main.go:9) CALL runtime.deferproc(SB) //调用deferproc在堆上创建_defer结构
0x0070 00112 (main.go:9) TESTL AX, AX // 检查AX记录的返回值是否大于0
0x0072 00114 (main.go:9) JNE 135 // deferproc返回值大于0,则执行第二个deferreturn
0x0074 00116 (main.go:9) JMP 118 // deferproc返回值是0,执行第一个deferreturn
0x0076 00118 (main.go:8) PCDATA $1, $-1
0x0076 00118 (main.go:8) JMP 120
0x0078 00120 (main.go:8) MOVQ "".i+16(SP), CX // i=0赋值给CX寄存器
0x007d 00125 (main.go:8) INCQ CX // 执行i++
0x0080 00128 (main.go:8) MOVQ CX, "".i+16(SP) // CX存储的i=1存入SP+16
0x0085 00133 (main.go:8) JMP 35 // 跳转到35行执行for循环
0x0087 00135 (main.go:9) PCDATA $1, $0
0x0087 00135 (main.go:9) XCHGL AX, AX
0x0088 00136 (main.go:9) CALL runtime.deferreturn(SB)
0x008d 00141 (main.go:9) MOVQ 40(SP), BP
0x0092 00146 (main.go:9) ADDQ $48, SP
0x0096 00150 (main.go:9) RET
0x0097 00151 (main.go:11) XCHGL AX, AX // 如果i<3为false,执行该行
0x0098 00152 (main.go:11) CALL runtime.deferreturn(SB) //在main函数退出时调用runtime.deferreturn函数,按后进先出的顺序执行defer函数
0x009d 00157 (main.go:11) MOVQ 40(SP), BP // 退出main函数
0x00a2 00162 (main.go:11) ADDQ $48, SP // 释放main函数栈空间
这里,除了main函数的汇编代码,还有main·dwrap·1这个函数,由编译器生成在代码段中,其实就是main中的defer函数:
"".main·dwrap·1 STEXT size=77 args=0x0 locals=0x18 funcid=0x16
0x0000 00000 (main.go:15) TEXT "".main·dwrap·1(SB), WRAPPER|NEEDCTXT|ABIInternal, $24-0
......
0x0026 00038 (main.go:15) CALL "".A(SB) // 调用 A()函数
......
main·dwrap·1函数会调用 A() 函数,A() 函数的代码也是由编译器生成在代码段中:
"".A STEXT size=181 args=0x8 locals=0x58 funcid=0x0
0x0000 00000 (main.go:19) TEXT "".A(SB), ABIInternal, $88-8
......
0x0091 00145 (main.go:20) CALL fmt.Println(SB) //执行打印动作
......
在这三个函数的汇编实现中,最复杂的是 main函数在堆中生成 defer函数的实现,首先通过 type.noalg.struct { F uintptr; ""..autotmp_1 int }(SB), AX 定义一个funcval类型的闭包函数结构体,再调用runtime.newobject() 函数为这个闭包函数分配空间,因为 defer 在 for 循环中调用,编译器不确定会执行多少次,会逃逸到堆上,包括引用的变量 i,接着将编译器生成到代码段的 main·dwrap·1 函数地址,通过CX寄存器赋值给闭包函数funcval:
0x0037 00055 (main.go:9) LEAQ type.noalg.struct { F uintptr; ""..autotmp_1 int }(SB), AX // 为defer函数定义一个funcval类型的闭包函数结构体
......
0x0040 00064 (main.go:9) CALL runtime.newobject(SB) // 调用newobject在堆上为defer函数开辟一块空间
0x0045 00069 (main.go:9) MOVQ AX, ""..autotmp_2+32(SP) // 将堆上的defer函数的地址入栈到SP+32的位置
0x004a 00074 (main.go:9) LEAQ "".main·dwrap·1(SB), CX // 将编译器生成的代码段中的defer函数地址赋值给CX寄存器
0x0051 00081 (main.go:9) MOVQ CX, (AX) // 将编译器生成到代码段的 main·dwrap·1 函数地址,通过CX寄存器赋值给闭包函数funcval
整个栈内存空间、编译器生成的main·dwrap·1函数和 A() 函数、堆空间的funcval闭包函数,和runtime.deferproc即将在堆上创建的_defer结构体内存布局如下图所示:
从这里可以看到,调用deferproc函数前,编译器已经把变量 i 的地址拷贝到了defer函数地址+8的位置,我们接着看 runtime.deferproc 函数怎样在堆上构建 runtime._defer 结构体:
// src/runtime/panic.go
func deferproc(fn func()) {
// 获取defer所在的goroutine
gp := getg()
if gp.m.curg != gp {
throw("defer on system stack")
}
// 获取一个新的defer
d := newdefer()
if d._panic != nil {
throw("deferproc: d.panic != nil after newdefer")
}
// 将defer加入新defer的链表中
d.link = gp._defer
gp._defer = d // 将当前goroutine的_defer替换为新defer
d.fn = fn // 将传入的defer函数地址赋值给新defer结构的fn字段
d.pc = getcallerpc() //设置调用者的PC程序计数器到新defer的pc字段
d.sp = getcallersp() // 设置调用者的栈指针SP到新defer的sp字段
// deferproc正常情况下返回0,如果是panic触发的defer则返回1
return0()
}
deferproc() 函数主要会为 defer 创建一个新的 runtime._defer 结构体、设置它的函数指针 fn、程序计数器 pc 和栈指针 sp。中间调用的runtime.newdefer() 函数的作用是获得 runtime._defer 结构体:
func newdefer() *_defer {
var d *_defer
mp := acquirem()
pp := mp.p.ptr()
// 若P本地的延迟调用缓存池为空,则从全局的调度器的延迟调用缓存池 sched.deferpool 中取出结构体并将该结构体追加到当前P本地的缓存池中
if len(pp.deferpool) == 0 && sched.deferpool != nil {
lock(&sched.deferlock)
for len(pp.deferpool) < cap(pp.deferpool)/2 && sched.deferpool != nil {
d := sched.deferpool
sched.deferpool = d.link
d.link = nil
pp.deferpool = append(pp.deferpool, d)
}
unlock(&sched.deferlock)
}
// 从P本地的延迟调用缓存池中获取defer结构体
if n := len(pp.deferpool); n > 0 {
d = pp.deferpool[n-1]
pp.deferpool[n-1] = nil
pp.deferpool = pp.deferpool[:n-1]
}
releasem(mp)
mp, pp = nil, nil
// 如果全局的调度器的缓存池和P本地缓存池都没有defer,则在堆上重新分配一个
if d == nil {
// 通过new关键字分配一个新的defer
d = new(_defer)
}
d.heap = true // heap字段为true标识defer是在堆上分配
return d
}
newdefer()为了提高性能,会通过 P 或者调度器 sched 上的本地或全局 defer 池来 复用已经在堆上分配的内存结构:
1)若P本地的延迟调用缓存池为空,则从全局的调度器的延迟调用缓存池 sched.deferpool 中取出结构体,并将该结构体追加到当前P本地的缓存池中;
2)从P本地的延迟调用缓存池中获取defer结构体;
3)如果全局的调度器的缓存池和P本地缓存池都没有defer,则在堆上重新分配一个;
无论使用哪种方式,只要获取到 runtime._defer 结构体,它都会被追加到所在 Goroutine _defer 链表的最前面。
defer 关键字的插入顺序是从后向前的,而 defer 关键字执行是从前向后的,这也是为什么多个defer的执行顺序是LIFO后进先出。defer的执行逻辑在函数退出时调用的runtime.deferreturn函数:
// src/runtime/panic.go
func deferreturn() {
gp := getg()
// 在for循环中遍历defer链表
for {
// 获取当前的_defer结构体
d := gp._defer
if d == nil {
return
}
// 检查defer调用方的栈指针是否是当前deferreturn函数的调用方的栈指针
sp := getcallersp()
if d.sp != sp {
return
}
// 开放编码方式的处理逻辑
if d.openDefer {
done := runOpenDeferFrame(gp, d)
if !done {
throw("unfinished open-coded defers in deferreturn")
}
gp._defer = d.link
freedefer(d)
return
}
// 堆或栈分配defer的处理逻辑
// 获取defer函数fn,调用freedefer函数释放defer,执行该defer函数fn()
fn := d.fn
d.fn = nil
gp._defer = d.link // 遍历下一个defer
freedefer(d) // 释放defer结构体,会优先放入缓存池
fn() // 执行defer函数
}
}
需要注意的是,Go1.18.1 中 deferreturn 函数执行defer时,不会做参数的拷贝,因为在main函数创建defer结构体时,已经把变量i的地址拷贝到了defer函数地址+8的位置,这个动作跟Go1.14版本中的实现不一样,后者是在执行deferproc时进行参数拷贝。
Go1.13 引入了 defer 在栈上分配的方式,好处在于函数返回后 _defer 便已得到释放, 不再需要考虑内存分配时产生的性能开销和额外的 GC 损耗,只需要适当的维护 _defer 的链表即可。
Go 在编译的时候在 SSA中间代码阶段判断,如果是栈上分配,需要直接在函数调用栈上使用编译器来初始化 _defer 记录,并作为参数传递给 deferprocStack:
func (s *state) call(n *ir.CallExpr, k callKind, returnResultAddr bool) *ssa.Value {
......
var callArgs []*ssa.Value
......
var call *ssa.Value
if k == callDeferStack {
// 直接在栈上创建 defer 记录
if stksize != 0 {
s.Fatalf("deferprocStack with non-zero stack size %d: %v", stksize, n)
}
// 从编译器角度构造 _defer 结构
t := deferstruct()
d := typecheck.TempAt(n.Pos(), s.curfn, t)
s.vars[memVar] = s.newValue1A(ssa.OpVarDef, types.TypeMem, d, s.mem())
addr := s.addr(d)
// 在栈上预留记录 _defer 的各个字段的空间
// 0: started, 该字段在运行时调用的deferprocStack函数中设置
// 1: heap, 该字段在deferprocStack函数中设置
// 2: openDefer
// 3: sp, 该字段在deferprocStack函数中设置
// 4: pc, 该字段在deferprocStack函数中设置
// 5: fn
s.store(closure.Type,
s.newValue1I(ssa.OpOffPtr, closure.Type.PtrTo(), t.FieldOff(5), addr),
closure)
// 6: panic, 该字段在deferprocStack函数中设置
// 7: link, 该字段在deferprocStack函数中设置
// 8: fd
// 9: varp
// 10: framepc
// 调用 deferprocStack,将栈上创建的_defer的指针作为参数传递
ACArgs = append(ACArgs, types.Types[types.TUINTPTR])
aux := ssa.StaticAuxCall(ir.Syms.DeferprocStack, s.f.ABIDefault.ABIAnalyzeTypes(nil, ACArgs, ACResults))
callArgs = append(callArgs, addr, s.mem())
call = s.newValue0A(ssa.OpStaticLECall, aux.LateExpansionResultType(), aux)
call.AddArgs(callArgs...)
call.AuxInt = int64(types.PtrSize)
} else {
......
}
......
// 函数调用结束跟堆上分配一样,调用exit()中的deferreturn函数
if k == callDefer || k == callDeferStack {
......
s.exit()
......
}
}
可见,在编译阶段,一个 _defer 结构体已经在栈上创建,部分参数如 openDefer, fn 等在编译期已经初始化了,deferprocStack 的作用就仅仅承担了运行时对该_defer结构体的部分参数的设置工作,如started, heap, sp, pc等参数:
// src/runtime/panic.go
func deferprocStack(d *_defer) {
gp := getg()
if gp.m.curg != gp {
throw("defer on system stack")
}
d.started = false
d.heap = false // 栈上分配的heap字段为false
d.openDefer = false // 栈上分配的openDefer字段为false
d.sp = getcallersp() // 设置调用者的栈指针
d.pc = getcallerpc() // 设置调用者的程序计数器pc
d.framepc = 0
d.varp = 0
// 下面依次设置_panic, fd为空,将_defer结构加入当前g的defer链表头
*(*uintptr)(unsafe.Pointer(&d._panic)) = 0
*(*uintptr)(unsafe.Pointer(&d.fd)) = 0
*(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
*(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))
return0()
}
至于函数结尾的行为,与在堆上进行分配的操作同样是调用 deferreturn,这里就不再重复说明了。
Go 语言在 1.14 中通过开放编码(Open Coded)实现 defer 关键字:在defer使用场景不复杂时,defer的实现完全体现为编译期的特性,无须运行时的额外开销,并在函数末尾直接对延迟函数进行调用。
所谓defer使用场景不复杂,需要满足以下三个条件:
1)没有禁用编译器优化,即没有设置 -gcflags "-N"; 2)函数的 defer 关键字不能在循环中执行; 3)函数的 defer 数量少于或者等于 8 个,函数的 return 个数与 defer 函数个数的乘积小于或者等于 15 个;
启用开放编码方式实现defer的逻辑在 cmd/compile/internal/walk/stmt.go 包的 walkStmt() 函数 和 cmd/compile/internal/ssagen/ssa.go 的 buildssa()函数。在上文分析Go编译器原理时,我们都了解过。
先看 cmd/compile/internal/walk/stmt.go 包的 walkStmt() 函数:
func walkStmt(n ir.Node) ir.Node {
......
case ir.ODEFER:
n := n.(*ir.GoDeferStmt)
ir.CurFunc.SetHasDefer(true)
ir.CurFunc.NumDefers++
if ir.CurFunc.NumDefers > maxOpenDefers { // maxOpenDefers 是常量 8
// 在defer函数个数大于8时,不允许使用开放编码方式实现defer,因为个数超过1个字节的比特数
ir.CurFunc.SetOpenCodedDeferDisallowed(true)
}
if n.Esc() != ir.EscNever {
// 如果n.Esc 不是 EscNever, 那么这个defer发生在循环中, 这种情况下也不允许开放编码方式
ir.CurFunc.SetOpenCodedDeferDisallowed(true)
}
......
}
再看 cmd/compile/internal/ssagen/ssa.go 的 buildssa() 函数:
func buildssa(fn *ir.Func, worker int) *ssa.Func {
......
// 可以对defer进行开放编码的条件,没有设置-N
s.hasOpenDefers = base.Flag.N == 0 && s.hasdefer && !s.curfn.OpenCodedDeferDisallowed()
if s.hasOpenDefers &&
s.curfn.NumReturns*s.curfn.NumDefers > 15 {
// defer所在函数返回值个数和defer函数个数乘积大于15,则不能用开放编码实现defer
s.hasOpenDefers = false
}
......
}
中间代码生成的这两个步骤会决定当前函数是否应该使用开放编码优化 defer 关键字,一旦确定使用开放编码,就会在编译期间初始化延迟比特和延迟记录。
用开放编码方式实现defer,首先,中间代码生成阶段的 cmd/compile/internal/ssagen/ssa.go 的 buildssa()函数会在栈上初始化大小为 8 个比特的 deferBits 变量:
func buildssa(fn *ir.Func, worker int) *ssa.Func {
......
if s.hasOpenDefers {
// 创建 deferBits 临时变量
deferBitsTemp := typecheck.TempAt(src.NoXPos, s.curfn, types.Types[types.TUINT8])
deferBitsTemp.SetAddrtaken(true)
s.deferBitsTemp = deferBitsTemp
// deferBits 被设计为 8 位二进制,因此可以被开放编码的 defer 数量不能超过 8 个
// 此处还将 deferBits 初始化为 0
startDeferBits := s.entryNewValue0(ssa.OpConst8, types.Types[types.TUINT8])
s.vars[deferBitsVar] = startDeferBits
s.deferBitsAddr = s.addr(deferBitsTemp)
s.store(types.Types[types.TUINT8], s.deferBitsAddr, startDeferBits)
s.vars[memVar] = s.newValue1Apos(ssa.OpVarLive, types.TypeMem, deferBitsTemp, s.mem(), false)
}
......
s.stmtList(fn.Body) // 执行stat.stmtList()函数
......
}
延迟比特中的每一个比特位都表示该位对应的 defer 关键字是否需要被执行,如下图所示,其中 8 个比特的倒数第二个比特在函数返回前被设置成了 1,那么该比特位对应的函数会在函数返回前执行:
defer所在函数中的多个defer在延迟比特中的纪录顺序是从低位到高位,所在函数exit退出时的多个defer函数的执行顺序时从高位到低位,这样能保证 LIFO 后进先出。
使用延迟比特的核心思想可以用下面的伪代码来概括:
a)多个defer函数的写入延迟比特的顺序,是从低位到高位:
// 多个defer函数的写入逻辑
deferBits := 0 // 初始化 deferBits
defer f1 // 保存defer函数 f1 及参数
deferBits |= 1 << 0 // 将 deferBits 最后一位置 1
if cond {
defer f2 // 保存defer函数 f2 及参数
deferBits |= 1 << 1 // 将 deferBits 倒数第二位置 1
}
b)defer所在函数退出时,多个defer函数的执行顺序是从高位到低位,依次遍历判断是否执行:
// defer所在函数退出时的执行逻辑:
exit:
if deferBits & 1 << 1 != 0 { // 判断defer函数 f2 的延迟比特位是否为 1
deferBits &^= 1 << 1
f2(a2) // 执行defer函数 f2
}
if deferBits & 1 << 0 != 0 { // 判断defer函数 f1 的延迟比特位是否为 1
deferBits &^= 1 << 0
f1(a1) // 执行defer函数 f1
}
在buildssa() 中设置好延迟比特后,接下来时执行state.stmtList() 函数,其中会调用state.stmt()函数,处理开放编码方式实现defer的逻辑:
func buildssa(fn *ir.Func, worker int) *ssa.Func {
......
if s.hasOpenDefers {
// 创建 deferBits 临时变量
deferBitsTemp := typecheck.TempAt(src.NoXPos, s.curfn, types.Types[types.TUINT8])
......
}
......
s.stmtList(fn.Body) // 执行stat.stmtList()函数
......
}
func (s *state) stmtList(l ir.Nodes) {
for _, n := range l {
s.stmt(n)
}
}
func (s *state) stmt(n ir.Node) {
......
case ir.ODEFER:
n := n.(*ir.GoDeferStmt)
.......
if s.hasOpenDefers { // 开放编码方式实现defer
s.openDeferRecord(n.Call.(*ir.CallExpr))
} else {
.......
}
.......
}
state.stmt()函数调用state.openDeferRecord(),其中主要会创建openDeferInfo结构体存储defer相关参数:
// 开放编码defer结构体
type openDeferInfo struct {
// n 标识defer所在的语法树节点
n *ir.CallExpr
// closure存储着调用的defer函数
closure *ssa.Value
// defer函数的节点名称
closureNode *ir.Name
}
接下来,看看 state.openDeferRecord() 函数的逻辑:
// cmd/compile/internal/ssagen/ssa.go
func (s *state) openDeferRecord(n *ir.CallExpr) {
if len(n.Args) != 0 || n.Op() != ir.OCALLFUNC || n.X.Type().NumResults() != 0 {
s.Fatalf("defer call with arguments or results: %v", n)
}
opendefer := &openDeferInfo{
n: n,
}
fn := n.X
// 设置openDeferInfo结构体的closure字段和closureNode字段值
closureVal := s.expr(fn)
closure := s.openDeferSave(fn.Type(), closureVal)
opendefer.closureNode = closure.Aux.(*ir.Name)
if !(fn.Op() == ir.ONAME && fn.(*ir.Name).Class == ir.PFUNC) {
opendefer.closure = closure
}
index := len(s.openDefers)
s.openDefers = append(s.openDefers, opendefer)
// 每多出现一个 defer,延迟比特 deferBits |= 1<<len(defers) 被设置在不同的位上
bitvalue := s.constInt8(types.Types[types.TUINT8], 1<<uint(index))
newDeferBits := s.newValue2(ssa.OpOr8, types.Types[types.TUINT8], s.variable(deferBitsVar, types.Types[types.TUINT8]), bitvalue)
s.vars[deferBitsVar] = newDeferBits
s.store(types.Types[types.TUINT8], s.deferBitsAddr, newDeferBits)
}
在defer所在函数退出时,state.exit() 函数依次倒序创建对延迟比特的检查代码, 从而以LIFO顺序调用被延迟的函数调用:
func (s *state) exit() *ssa.Block {
if s.hasdefer {
if s.hasOpenDefers {
......
s.openDeferExit()
} else {
......
}
}
......
}
其中调用的state.openDeferExit() 函数的逻辑是:
func (s *state) openDeferExit() {
deferExit := s.f.NewBlock(ssa.BlockPlain)
s.endBlock().AddEdgeTo(deferExit)
s.startBlock(deferExit)
s.lastDeferExit = deferExit
s.lastDeferCount = len(s.openDefers)
zeroval := s.constInt8(types.Types[types.TUINT8], 0)
// 倒序检查defer函数
for i := len(s.openDefers) - 1; i >= 0; i-- {
r := s.openDefers[i]
bCond := s.f.NewBlock(ssa.BlockPlain)
bEnd := s.f.NewBlock(ssa.BlockPlain)
// 检查 deferBits 的各个比特位是否为1,判断各个defer函数是否需要执行
deferBits := s.variable(deferBitsVar, types.Types[types.TUINT8])
// 创建 if deferBits & 1 << len(defer) != 0
bitval := s.constInt8(types.Types[types.TUINT8], 1<<uint(i))
andval := s.newValue2(ssa.OpAnd8, types.Types[types.TUINT8], deferBits, bitval)
eqVal := s.newValue2(ssa.OpEq8, types.Types[types.TBOOL], andval, zeroval)
b := s.endBlock()
b.Kind = ssa.BlockIf
b.SetControl(eqVal)
b.AddEdgeTo(bEnd)
b.AddEdgeTo(bCond)
bCond.AddEdgeTo(bEnd)
s.startBlock(bCond)
// 如果创建的条件分支被触发,则清空当前的延迟比特,避免defer执行panic时的重复执行
nbitval := s.newValue1(ssa.OpCom8, types.Types[types.TUINT8], bitval)
maskedval := s.newValue2(ssa.OpAnd8, types.Types[types.TUINT8], deferBits, nbitval)
s.store(types.Types[types.TUINT8], s.deferBitsAddr, maskedval)
s.vars[deferBitsVar] = maskedval
// 调用defer函数
fn := r.n.X
stksize := fn.Type().ArgWidth()
var callArgs []*ssa.Value
var call *ssa.Value
if r.closure != nil {
v := s.load(r.closure.Type.Elem(), r.closure)
s.maybeNilCheckClosure(v, callDefer)
codeptr := s.rawLoad(types.Types[types.TUINTPTR], v)
aux := ssa.ClosureAuxCall(s.f.ABIDefault.ABIAnalyzeTypes(nil, nil, nil))
call = s.newValue2A(ssa.OpClosureLECall, aux.LateExpansionResultType(), aux, codeptr, v)
} else {
aux := ssa.StaticAuxCall(fn.(*ir.Name).Linksym(), s.f.ABIDefault.ABIAnalyzeTypes(nil, nil, nil))
call = s.newValue0A(ssa.OpStaticLECall, aux.LateExpansionResultType(), aux)
}
callArgs = append(callArgs, s.mem())
call.AddArgs(callArgs...)
call.AuxInt = stksize
s.vars[memVar] = s.newValue1I(ssa.OpSelectN, types.TypeMem, 0, call)
if r.closureNode != nil {
s.vars[memVar] = s.newValue1Apos(ssa.OpVarLive, types.TypeMem, r.closureNode, s.mem(), false)
}
s.endBlock()
s.startBlock(bEnd)
}
}
从整个过程中我们可以看到,根据延迟比特deferBits和state.openDeferInfo结构体存储defer相关参数,将defer函数直接在当前函数内展开,并在返回语句的末尾根据延迟比特的相关位是否为1执行defer调用。 开放编码式 defer 的成本体现在非常少量的指令和位运算来配合在运行时判断是否存在需要被延迟调用的 defer。
1. Go最早引入的是defer在堆上分配的实现方式,实现原理是:编译器首先会将defer延迟语句转成 runtime.deferproc 调用,并在defer所在函数返回之前插入runtime.deferreturn函数;运行时调用runtime.deferproc函数获取一个runtime._defer 结构体纪录defer函数的相关参数,并将该_defer结构体入栈到当前 Goroutine 的defer延迟链表头;defer所在函数返回时,调用runtime.deferreturn函数从Goroutine的链表头取出runtime._defer结构体并依次执行,这样能保证同一个函数中的多个defer能以LIFO的顺序执行;此类defer的性能问题在于每个defer语句都要在堆上分配内存,性能最差;
2. Go1.13 引入defer在栈上分配的实现方式,实现原理是:编译器会直接在栈上创建一个runtime._defer 结构体,不涉及内存分配,并将_defer结构体指针作为参数,传入runtime.deferprocStack函数,同时,会将runtime._defer 压入 Goroutine 的延迟调用链表中;在函数退出时,调用 deferreturn,将 runtime._defer 结构体出栈并执行;此类 defer 的成本是需要将运行时确定的参数赋值给栈上创建的 runtime._defer 结构体,运行时性能比堆上分配高 30%;
3. Go1.14引入了开放编码方式实现defer,实现了近乎零成本的 defer 调用,当没有设置-N禁用内联、 defer 函数个数和返回值个数乘积不超过 15 个、defer 函数个数不超过 8 个、且defer没有出现在循环语句中时,会使用此类开放编码方式实现defer;实现原理是:编译器会根据延迟比特deferBits和state.openDeferInfo结构体存储defer相关参数,并在返回语句的末尾根据延迟比特的相关位是否为1执行defer调用;此类 defer 的运行时成本是根据条件与延迟比特确定相关defer函数是否需要执行,开放编码运行时性能最好。
Go语言程序设计与实现第2.4节中间代码生成 https://draveness.me/golang/docs/part1-prerequisite/ch02-compile/golang-ir-ssa/
Go语言程序设计与实现第5.3节defer https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-defer/#535-%E5%BC%80%E6%94%BE%E7%BC%96%E7%A0%81
Go语言原本3.4节延迟语句 https://golang.design/under-the-hood/zh-cn/part1basic/ch03lang/defer/
深入 Go 语言 defer 实现原理 https://www.luozhiyun.com/archives/523
Go defer 原理和源码剖析 http://www.codebaoku.com/godeep/it-deep-defer.html
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。