defer的使用方法和行为分析
kevinyan815 opened this issue · comments
Go 语言的 defer 会在当前函数返回前执行传入的函数,它会经常被用于关闭文件描述符、关闭数据库连接以及解锁资源。
使用 defer 的最常见场景是在函数调用结束后完成一些收尾工作,例如在 defer 中回滚数据库的事务:
func createData(db *gorm.DB) error {
tx := db.Begin()
defer func () {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}
defer tx.Rollback()
if err := tx.Create(&Post{Name: "Kevin"}).Error; err != nil {
return err
}
}
使用 defer 常遇见的两个问题
我们在 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 的数据结构
defer 关键字在 Go 语言源代码中对应的数据结构如下:
type _defer struct {
siz int32
started bool
openDefer bool
sp uintptr
pc uintptr
fn *funcval
_panic *_panic
link *_defer
}
runtime._defer 结构体是延迟调用链表上的一个元素,所有的结构体都会通过 link 字段串联成链表。后调用的 defer 函数会被追加到当前 goroutine 的 _defer 链表的最前面;而在当前函数返回前,运行时会从延迟调用链表上从前到后取出 runtime._defer 依次执行,这也就是为什么后调用的 defer 函数会先执行。