5.3 defer
很多现代的编程语言中都有 defer
关键字,Go 语言的 defer
会在当前函数或者方法返回之前执行传入的函数。它会经常被用于关闭文件描述符、关闭数据库连接以及解锁资源。
在这一节中我们就会深入 Go 语言的源代码介绍 defer
关键字的实现原理,相信读者读完这一节会对 defer
的数据结构、实现以及调用过程有着更清晰的理解。
作为一个编程语言中的关键字,defer
的实现一定是由编译器和运行时共同完成的,不过在深入源码分析它的实现之前我们还是需要了解 defer
关键字的常见使用场景以及使用时的注意事项。
使用 defer
的最常见场景就是在函数调用结束后完成一些收尾工作,例如在 defer
中回滚数据库的事务:
func createPost(db *gorm.DB) error {
tx := db.Begin()
defer tx.Rollback()
if err := tx.Create(&Post{Author: "Draveness"}).Error; err != nil {
return err
}
return tx.Commit().Error
}
在使用数据库事务时,我们可以使用如上所示的代码在创建事务之后就立刻调用 Rollback
保证事务一定会回滚。哪怕事务真的执行成功了,那么调用 tx.Commit()
之后再执行 tx.Rollback()
也不会影响已经提交的事务。
5.3.1 现象
我们在 Go 语言中使用 defer
时会遇到两个比较常见的问题,这里会介绍具体的场景并分析这两个现象背后的设计原理:
defer
关键字的调用时机以及多次调用defer
时执行顺序是如何确定的;defer
关键字使用传值的方式传递参数时会进行预计算,导致不符合预期的结果;
作用域
向 defer
关键字传入的函数会在函数返回之前运行。假设我们在 for
循环中多次调用 defer
关键字:
func main() {
for i := 0; i < 5; i++ {
defer fmt.Println(i)
}
}
$ go run main.go
4
3
2
1
0
运行上述代码会倒序执行所有向 defer
关键字中传入的表达式,最后一次 defer
调用传入了 fmt.Println(4)
,所以会这段代码会优先打印 4。我们可以通过下面这个简单例子强化对 defer
执行时机的理解:
func main() {
{
defer fmt.Println("defer runs")
fmt.Println("block ends")
}
fmt.Println("main ends")
}
$ go run main.go
block ends
main ends
defer runs
从上述代码的输出我们会发现,defer
传入的函数不是在退出代码块的作用域时执行的,它只会在当前函数和方法返回之前被调用。
预计算参数
Go 语言中所有的函数调用都是传值的,defer
虽然是关键字,但是也继承了这个特性。假设我们想要计算 main
函数运行的时间,可能会写出以下的代码:
func main() {
startedAt := time.Now()
defer fmt.Println(time.Since(startedAt))
time.Sleep(time.Second)
}
$ go run main.go
0s
然而上述代码的运行结果并不符合我们的预期,这个现象背后的原因是什么呢?经过分析,我们会发现调用 defer
关键字会立刻对函数中引用的外部参数进行拷贝,所以 time.Since(startedAt)
的结果不是在 main
函数退出之前计算的,而是在 defer
关键字调用时计算的,最终导致上述代码输出 0s。
想要解决这个问题的方法非常简单,我们只需要向 defer
关键字传入匿名函数:
func main() {
startedAt := time.Now()
defer func() { fmt.Println(time.Since(startedAt)) }()
time.Sleep(time.Second)
}
$ go run main.go
1s
虽然调用 defer
关键字时也使用值传递,但是因为拷贝的是函数指针,所以 time.Since(startedAt)
会在 main
函数执行前被调用并打印出符合预期的结果。
5.3.2 数据结构
在介绍 defer
函数的执行过程与实现原理之前,我们首先来了解一下 defer
关键字在 Go 语言源代码中对应的数据结构:
type _defer struct {
siz int32
started bool
sp uintptr
pc uintptr
fn *funcval
_panic *_panic
link *_defer
}
runtime._defer
结构体是延迟调用链表上的一个元素,所有的结构体都会通过 link
字段串联成链表。
图 5-10 延迟调用链表
我们简单介绍一下 runtime._defer
结构体中的几个字段:
siz
是参数和结果的内存大小;sp
和pc
分别代表栈指针和调用方的程序计数器;fn
是defer
关键字中传入的函数;_panic
是触发延迟调用的结构体,可能为空;
除了上述的这些字段之外,runtime._defer
中还包含一些垃圾回收机制使用的字段,这里为了减少理解的成本就都省去了。
5.3.3 编译过程
中间代码生成阶段执行的被 cmd/compile/internal/gc.state.stmt
函数会处理 defer
关键字。从下面截取的这段代码中,我们会发现编译器调用了 cmd/compile/internal/gc.state.call
函数,这表示 defer
在编译器看来也是函数调用:
func (s *state) stmt(n *Node) {
switch n.Op {
case ODEFER:
s.call(n.Left, callDefer)
}
}
cmd/compile/internal/gc.state.call
函数会负责了为所有函数和方法调用生成中间代码,它的工作包括以下内容:
- 获取需要执行的函数名、闭包指针、代码指针和函数调用的接收方;
- 获取栈地址并将函数或者方法的参数写入栈中;
- 使用
cmd/compile/internal/gc.state.newValue1A
以及相关函数生成函数调用的中间代码; - 如果当前调用的函数是
defer
,那么就会单独生成相关的结束代码块; - 获取函数的返回值地址并结束当前调用;
func (s *state) call(n *Node, k callKind) *ssa.Value {
...
var call *ssa.Value
switch {
case k == callDefer:
call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, deferproc, s.mem())
...
}
call.AuxInt = stksize
s.vars[&memVar] = call
...
}
从上述代码中我们能看到,defer
关键字在运行期间会调用 runtime.deferproc
函数,这个函数接收了参数的大小和闭包所在的地址两个参数。
编译器不仅将 defer
关键字都转换成 runtime.deferproc
函数,它还会通过以下三个步骤为所有调用 defer
的函数末尾插入 runtime.deferreturn
的函数调用:
cmd/compile/internal/gc.walkstmt
在遇到ODEFER
节点时会执行Curfn.Func.SetHasDefer(true)
设置当前函数的hasdefer
;cmd/compile/internal/gc.buildssa
会执行s.hasdefer = fn.Func.HasDefer()
更新state
的hasdefer
;cmd/compile/internal/gc.state.exit
会根据state
的hasdefer
在函数返回之前插入runtime.deferreturn
的函数调用;
func (s *state) exit() *ssa.Block {
if s.hasdefer {
s.rtcall(Deferreturn, true, nil)
}
...
}
Go 语言的编译器不仅将 defer
转换成了 runtime.deferproc
的函数调用,还在所有调用 defer
的函数结尾插入了 runtime.deferreturn
,接下来我们就需要了解这两个运行时方法的实现原理了。
5.3.4 运行过程
defer
关键字的运行时实现分成两个部分:
runtime.deferproc
函数负责创建新的延迟调用;runtime.deferreturn
函数负责在函数调用结束时执行所有的延迟调用;
这两个函数是 defer
关键字运行时机制的入口,我们从它们开始分别介绍这两个函数的执行过程。
创建延迟调用
runtime.deferproc
会为 defer
创建一个新的 runtime._defer
结构体、设置它的函数指针 fn
、程序计数器 sp
和栈指针 sp
并将相关的参数拷贝到相邻的内存空间中:
func deferproc(siz int32, fn *funcval) {
sp := getcallersp()
argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
callerpc := getcallerpc()
d := newdefer(siz)
if d._panic != nil {
throw("deferproc: d.panic != nil after newdefer")
}
d.fn = fn
d.pc = callerpc
d.sp = sp
switch siz {
case 0:
case sys.PtrSize:
*(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
default:
memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
}
return0()
}
最后调用的 runtime.return0
函数的作用是避免无限递归调用 runtime.deferreturn
,它是唯一一个不会触发由延迟调用的函数了。
runtime.deferproc
中 runtime.newdefer
的作用就是想尽办法获得一个 runtime._defer
结构体,办法总共有三个:
- 从调度器的延迟调用缓存池
sched.deferpool
中取出结构体并将该结构体追加到当前 Goroutine 的缓存池中; - 从 Goroutine 的延迟调用缓存池
pp.deferpool
中取出结构体; - 通过
runtime.mallocgc
创建一个新的结构体;
func newdefer(siz int32) *_defer {
var d *_defer
sc := deferclass(uintptr(siz))
gp := getg()
if sc < uintptr(len(p{}.deferpool)) {
pp := gp.m.p.ptr()
if len(pp.deferpool[sc]) == 0 && sched.deferpool[sc] != nil {
for len(pp.deferpool[sc]) < cap(pp.deferpool[sc])/2 && sched.deferpool[sc] != nil {
d := sched.deferpool[sc]
sched.deferpool[sc] = d.link
pp.deferpool[sc] = append(pp.deferpool[sc], d)
}
}
if n := len(pp.deferpool[sc]); n > 0 {
d = pp.deferpool[sc][n-1]
pp.deferpool[sc][n-1] = nil
pp.deferpool[sc] = pp.deferpool[sc][:n-1]
}
}
if d == nil {
total := roundupsize(totaldefersize(uintptr(siz)))
d = (*_defer)(mallocgc(total, deferType, true))
}
d.siz = siz
d.link = gp._defer
gp._defer = d
return d
}
无论使用哪种方式获取 runtime._defer
,它都会被追加到所在的 Goroutine _defer
链表的最前面。
图 5-11 追加新的延迟调用
defer
关键字插入时是从后向前的,而 defer
关键字执行是从前向后的,而这就是后调用的 defer
会优先执行的原因。
执行延迟调用
runtime.deferreturn
会从 Goroutine 的 _defer
链表中取出最前面的 runtime._defer
结构体并调用 runtime.jmpdefer
函数传入需要执行的函数和参数:
func deferreturn(arg0 uintptr) {
gp := getg()
d := gp._defer
if d == nil {
return
}
sp := getcallersp()
switch d.siz {
case 0:
case sys.PtrSize:
*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
default:
memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
}
fn := d.fn
gp._defer = d.link
freedefer(d)
jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}
runtime.jmpdefer
是一个用汇编语言实现的运行时函数,它的工作就是跳转 defer
所在的代码段并在执行结束之后跳转回 runtime.deferreturn
。
TEXT runtime·jmpdefer(SB), NOSPLIT, $0-8
MOVL fv+0(FP), DX // fn
MOVL argp+4(FP), BX // caller sp
LEAL -4(BX), SP // caller sp after CALL
#ifdef GOBUILDMODE_shared
SUBL $16, (SP) // return to CALL again
#else
SUBL $5, (SP) // return to CALL again
#endif
MOVL 0(DX), BX
JMP BX // but first run the deferred function
runtime.deferreturn
函数会多次判断当前 Goroutine 的 _defer
链表中是否有未执行的剩余结构,在所有的延迟函数调用都执行完成之后,该函数才会返回。
5.3.5 小结
defer
关键字的实现主要依靠编译器和运行时的协作,我们总结一下本节提到的内容:
- 编译期;
- 将
defer
关键字被转换runtime.deferproc
; - 在调用
defer
关键字的函数返回之前插入runtime.deferreturn
;
- 将
- 运行时:
runtime.deferproc
会将一个新的runtime._defer
结构体追加到当前 Goroutine 的链表头;runtime.deferreturn
会从 Goroutine 的链表中取出runtime._defer
结构并依次执行;
我们在本节前面提到的两个现象在这里也可以解释清楚了:
- 后调用的
defer
函数会先执行:- 后调用的
defer
函数会被追加到 Goroutine_defer
链表的最前面; - 运行
runtime._defer
时是从前到后依次执行;
- 后调用的
- 函数的参数会被预先计算;
- 调用
runtime.deferproc
函数创建新的延迟调用时就会立刻拷贝函数的参数,函数的参数不会等到真正执行时计算;
- 调用
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。