七叶笔记 » golang编程 » Golang 之轻松化解 defer 的温柔陷阱

Golang 之轻松化解 defer 的温柔陷阱

什么是defer?

defer是Go语言提供的一种用于注册延迟调用的机制:让函数或语句可以在当前函数执行完毕后(包括通过return正常结束或者panic导致的异常结束)执行。

defer语句通常用于一些成对操作的场景:打开连接/关闭连接;加锁/释放锁;打开文件/关闭文件等。

defer在一些需要回收资源的场景非常有用,可以很方便地在函数结束前做一些清理操作。在打开资源语句的下一行,直接一句defer就可以在函数返回前关闭资源,可谓相当优雅。

f, _ := os. Open ("defer.txt")
defer f. Close ()
 

注意:以上代码,忽略了err, 实际上应该先判断是否出错,如果出错了,直接return. 接着再判断f是否为空,如果f为空,就不能调用f.Close()函数了,会直接panic的。

为什么需要defer?

程序员在编程的时候,经常需要打开一些资源,比如数据库连接、文件、锁等,这些资源需要在用完之后释放掉,否则会造成内存泄漏。

但是程序员都是人,是人就会犯错。因此经常有程序员忘记关闭这些资源。Golang直接在语言层面提供defer关键字,在打开资源语句的下一行,就可以直接用defer语句来注册函数结束后执行关闭资源的操作。因为这样一颗“小小”的语法糖,程序员忘写关闭资源语句的情况就大大地减少了。

怎样合理使用defer?

defer的使用其实非常简单:

f,err := os.Open(filename)
if err !=  nil  {
 panic(err)
}
if f != nil {
 defer f.Close()
}
 

在打开文件的语句附近,用defer语句关闭文件。这样,在函数结束之前,会自动执行defer后面的语句来关闭文件。

当然,defer会有小小地延迟,对时间要求特别特别特别高的程序,可以避免使用它,其他一般忽略它带来的延迟。

defer进阶

defer的底层原理是什么?

我们先看一下官方对defer的解释:

翻译一下:每次defer语句执行的时候,会把函数“压栈”,函数参数会被拷贝下来;当外层函数(非代码块,如一个for循环)退出时,defer函数按照定义的逆序执行;如果defer执行的函数为nil, 那么会在最终调用函数的产生panic.

defer语句并不会马上执行,而是会进入一个栈,函数return前,会按先进后出的顺序执行。也说是说最先被定义的defer语句最后执行。先进后出的原因是后面定义的函数可能会依赖前面的资源,自然要先执行;否则,如果前面先执行,那后面函数的依赖就没有了。

在defer函数定义时,对外部变量的引用是有两种方式的,分别是作为函数参数和作为 闭包 引用。作为函数参数,则在defer定义时就把值传递给defer,并被cache起来;作为闭包引用的话,则会在defer函数真正调用时根据整个上下文确定当前的值。

defer后面的语句在执行的时候,函数调用的参数会被保存起来,也就是复制了一份。真正执行的时候,实际上用到的是这个复制的变量,因此如果此变量是一个“值”,那么就和定义的时候是一致的。如果此变量是一个“引用”,那么就可能和定义的时候不一致。

举个例子:

func main() {
var whatever [3]struct{}

for i := range whatever {
defer func() { 
fmt.Println(i) 
}()
}
}
 

执行结果:

2
2
2
 

defer后面跟的是一个闭包(后面会讲到),i是“引用”类型的变量,最后i的值为2, 因此最后打印了三个2.

有了上面的基础,我们来检验一下成果:

type number int
func (n number) print() { fmt.Println(n) }
func (n *number) pprint() { fmt.Println(*n) }
func main() {
var n number
defer n.print()
defer n.pprint()
defer func() { n.print() }()
defer func() { n.pprint() }()
n = 3
}
 

执行结果是:

3
3
3
0
 

第四个defer语句是闭包,引用外部函数的n, 最终结果是3;

第三个defer语句同第四个;

第二个defer语句,n是引用,最终求值是3.

第一个defer语句,对n直接求值,开始的时候n=0, 所以最后是0;

利用defer原理

有些情况下,我们会故意用到defer的先求值,再延迟调用的性质。想象这样的场景:在一个函数里,需要打开两个文件进行合并操作,合并完后,在函数执行完后关闭打开的文件句柄。

func mergeFile() error {
f, _ := os.Open("file1.txt")
if f != nil {
defer func(f io.Closer) {
if err := f.Close(); err != nil {
fmt.Printf("defer close file1.txt err %v\n", err)
}
}(f)
}
// ……
f, _ = os.Open("file2.txt")
if f != nil {
defer func(f io.Closer) {
if err := f.Close(); err != nil {
fmt.Printf("defer close file2.txt err %v\n", err)
}
}(f)
}
return nil
}
 

上面的代码中就用到了defer的原理,defer函数定义的时候,参数就已经复制进去了,之后,真正执行close()函数的时候就刚好关闭的是正确的“文件”了,妙哉!可以想像一下如果不这样将f当成函数参数传递进去的话,最后两个语句关闭的就是同一个文件了,都是最后一个打开的文件。

不过在调用close()函数的时候,要注意一点:先判断调用主体是否为空,否则会panic. 比如上面的代码片段里,先判断f不为空,才会调用Close()函数,这样最安全。

defer命令的拆解

如果defer像上面介绍地那样简单(其实也不简单啦),这个世界就完美了。事情总是没这么简单,defer用得不好,是会跳进很多坑的。

理解这些坑的关键是这条语句:

return xxx
 

上面这条语句经过编译之后,变成了三条指令:

1. 返回值 = xxx
2. 调用defer函数
3. 空的return
 

1,3步才是Return 语句真正的命令,第2步是defer定义的语句,这里可能会操作返回值。

下面我们来看两个例子,试着将return语句和defer语句拆解到正确的顺序。

第一个例子:

func f() (r int) {
 t := 5
 defer func() {
 t = t + 5
 }()
 return t
}
 

拆解后:

func f() (r int) {
 t := 5
 
 // 1. 赋值指令
 r = t
 
 // 2. defer被插入到赋值与返回之间执行,这个例子中返回值r没被修改过
 func() { 
 t = t + 5
 }
 
 // 3. 空的return指令
 return
}
 

这里第二步没有操作返回值r, 因此,main函数中调用f()得到5.

第二个例子:

func f() (r int) {
 defer func(r int) {
 r = r + 5
 }(r)
 return 1
}
 

拆解后:

func f() (r int) {
 // 1. 赋值
 r = 1
 
 // 2. 这里改的r是之前传值传进去的r,不会改变要返回的那个r值
 func(r int) { 
 r = r + 5
 }(r)
 
 // 3. 空的return
 return
}
 

因此,main函数中调用f()得到1.

defer语句的参数

defer语句表达式的值在定义时就已经确定了。下面展示三个函数:

func f1() {
var err error

defer fmt.Println(err)
err = errors.New("defer error")
return
}
func f2() {
var err error

defer func() {
fmt.Println(err)
}()
err = errors.New("defer error")
return
}
func f3() {
var err error

defer func(err error) {
fmt.Println(err)
}(err)
err = errors.New("defer error")
return
}
func main() {
f1()
f2()
f3()
}
 

运行结果:

<nil>
defer error
<nil>
 

第1,3个函数是因为作为函数参数,定义的时候就会求值,定义的时候err变量的值都是nil, 所以最后打印的时候都是nil. 第2个函数的参数其实也是会在定义的时候求值,只不过,第2个例子中是一个闭包,它引用的变量err在执行的时候最终变成defer error了。关于闭包在本文后面有介绍。

第3个函数的错误还比较容易犯,在生产环境中,很容易写出这样的错误代码。最后defer语句没有起到作用。

闭包是什么?

闭包是由函数及其相关引用环境组合而成的实体,即:

闭包=函数+引用环境
 

一般的函数都有函数名,但是 匿名函数 就没有。匿名函数不能独立存在,但可以直接调用或者赋值于某个变量。匿名函数也被称为闭包,一个闭包继承了函数声明时的作用域。在Golang中,所有的匿名函数都是闭包。

有个不太恰当的例子,可以把闭包看成是一个类,一个闭包函数调用就是实例化一个类。闭包在运行时可以有多个实例,它会将同一个作用域里的变量和常量捕获下来,无论闭包在什么地方被调用(实例化)时,都可以使用这些变量和常量。而且,闭包捕获的变量和常量是引用传递,不是值传递。

举个简单的例子:

func main() {
var a = Accumulator()
fmt.Printf("%d\n", a(1))
fmt.Printf("%d\n", a(10))
fmt.Printf("%d\n", a(100))
fmt.Println("------------------------")
var b = Accumulator()
fmt.Printf("%d\n", b(1))
fmt.Printf("%d\n", b(10))
fmt.Printf("%d\n", b(100))
}
func Accumulator() func(int) int {
var x int
return func(delta int) int {
fmt.Printf("(%+v, %+v) - ", &x, x)
x += delta
return x
}
}
 

执行结果:

(0xc420014070, 0) - 1
(0xc420014070, 1) - 11
(0xc420014070, 11) - 111
------------------------
(0xc4200140b8, 0) - 1
(0xc4200140b8, 1) - 11
(0xc4200140b8, 11) - 111
 

闭包引用了x变量,a,b可看作2个不同的实例,实例之间互不影响。实例内部,x变量是同一个地址,因此具有“累加效应”。

defer配合recover

Golang被诟病比较多的就是它的error, 经常是各种error满天飞。编程的时候总是会返回一个error, 留给调用者处理。如果是那种致命的错误,比如程序执行初始化的时候出问题,直接panic掉,省得上线运行后出更大的问题。

但是有些时候,我们需要从异常中恢复。比如服务器程序遇到严重问题,产生了panic, 这时我们至少可以在程序崩溃前做一些“扫尾工作”,如关闭客户端的连接,防止客户端一直等待等等。

panic会停掉当前正在执行的程序,不只是当前协程。在这之前,它会有序地执行完当前协程defer列表里的语句,其它协程里挂的defer语句不作保证。因此,我们经常在defer里挂一个recover语句,防止程序直接挂掉,这起到了try…catch的效果。

注意,recover()函数只在defer的上下文中才有效(且只有通过在defer中用匿名函数调用才有效),直接调用的话,只会返回nil.

func main() {
defer fmt.Println("defer main")
var user = os.Getenv("USER_")

go func() {
defer func() {
fmt.Println("defer caller")
if err := recover(); err != nil {
fmt.Println("recover success. err: ", err)
}
}()
func() {
defer func() {
fmt.Println("defer here")
}()
if user == "" {
panic("should set user env.")
}
// 此处不会执行
fmt.Println("after panic")
}()
}()
time.Sleep(100)
fmt.Println("end of main function")
}
 

上面的panic最终会被recover捕获到。这样的处理方式在一个http server的主流程常常会被用到。一次偶然的请求可能会触发某个bug, 这时用recover捕获panic, 稳住主流程,不影响其他请求。

程序员通过监控获知此次panic的发生,按时间点定位到日志相应位置,找到发生panic的原因,三下五除二,修复上线。一看四周,大家都埋头干自己的事,简直完美:偷偷修复了一个bug, 没有发现!嘿嘿!

后记

defer非常好用,一般情况下不会有什么问题。但是只有深入理解了defer的原理才会避开它的温柔陷阱。掌握了它的原理后,就会写出易懂易维护的代码。

参考资料

【defer那些事】

【defer代码案例】

【闭包】

【闭包】

【闭包】

【延迟】

【defer三条原则】

【defer代码例子】

【defer panic】

【defer panic】

本文作者: 饶全成,原创授权发布

本文链接:

版权声明: 本文章著作权归作者所有,任何形式的转载都请注明出处。

相关文章