Go:浅谈defer

前言:
最近在看《effetive go》看到defer,由于我平时没怎么用过defer,之前学得又给忘了,看到一道题试着自己推导一下,发现推导错了,所以重新好好再总结一下。作者属于菜鸡级别,所以本文还不会涉及到原理层面,文章的题目也是浅谈。

1. 需求分析

对于某些需要释放资源的函数,引入defer是必要的。比如打开文件,对这个文件进行读写,在函数的最后对文件在进行关闭,释放资源。但是函数一长,程序员就容易忘记在函数最后对资源进行释放,便会为程序埋下雷。那么就引入一种延迟机制,使得一些操作可以在一开始就被定义,但是可以等到函数末尾再去执行。Go实现的这种机制就是defer。

2. 特性

特性将会分为四个部分:延迟特性后进先出作用域以及错误处理四个方面来讲。通过这四个特性可以了解对于defer到底该如何使用。

2.1. 延迟特性

在需求分析中也说得很清楚,被defer的函数会等到函数的最后运行。

func test()  {
	fmt.Println("hello in test")
}

func main()  {
	defer test()
	fmt.Println("hello in main")
}

结果:

hello in main
hello in test
2.2. 后进先出

一个函数中可能会出现多个defer,那么对于多个defer,采取的是后进先出的策略,也就是按照被defer的顺序,从后往前执行,最后defer的函数最先运行。(**提醒:**这并不意味着defer是用栈实现的,实际上其实现很复杂,有堆,栈以及开放源码)

func main()  {
	for i:=0; i<5; i++ {
		defer test(strconv.Itoa(i))
	}
	fmt.Println("hello in main")
}

结果如下:

hello in main
hello in test4
hello in test3
hello in test2
hello in test1
hello in test0
2.3. 错误处理

用于处理panic,使得panic之后程序能够继续进行。
先看一下代码:

func A() {
	fmt.Println("A")
}

func B() {
	fmt.Println("B")
	panic("panic in B")
}


func C() {
	fmt.Println("C")
}


func main() {
	A()
	B()
	C()
}

上述代码执行结果:

A
B
panic: panic in B

可以看到函数C并没有被执行。
但是,如果我们可以预知这种错误,并且能够在运行过程中处理这种错误,使其不会影响程序的运行,然后保证整体程序的运行,那么可以使用defer以及rcover配合来恢复程序的。
这也说明defer不受错误的影响,只要在引发错误的地方之前声明了defer函数,那么该函数即使在后面还出现错误的情况下依然会继续运行。记住一定要在panic之前声明。
对于上面的B函数,可以修改为:

func B() {
	fmt.Println("B")
	defer func() {
		fmt.Println("Func in B")
	}()
	panic("panic in B")
}

修改后的执行结果:

A
B
Func in B
panic: panic in B
2.4. 作用域

defer的作用域一般只在一个函数体之内

func main()  {
	func() {
		defer fmt.Println("in subFunc")
	}()
	fmt.Println("in main")
}

结果:

in subFunc
in main

3. 被defer函数的参数和变量问题

先上结论,然后在一个个分析:

  1. 参数是函数的返回值,那么会优先执行该参数函数
  2. 闭包中的变量。如果是在主体函数中的局部变量,那么一定要小心使用。因为在执行在被defer的函数中,使用外部变量是使用外部变量的最后一个状态。
3.1. 参数是函数的返回值

如果被defer函数使用参数是函数的返回值,那么会优先执行该参数函数。
手动写下下面代码的执行结果,看看自己能不能写对:

func trace(s string) string {
    fmt.Println("entering:", s)
    return s
}

func un(s string) {
    fmt.Println("leaving:", s)
}

func a() {
    defer un(trace("a"))
    fmt.Println("in a")
}

func b() {
    defer un(trace("b"))
    fmt.Println("in b")
    a()
}

func main() {
    b()
}

结果:

entering: b
in b
entering: a
in a
leaving: a
leaving: b

简单说下:main调用了b函数。而b首先defer了一个un函数,这个un函数的参数是一个trace函数,所以即便这个un函数会在b函数最后才调用,也会优先执行trace函数。

3.2. 形成闭包的defer函数

这个也是个大坑,需要多加小心。其主要原理就是形成闭包的defer函数,在被调用的时候使用的外部变量是该变量的最后状态。
将上面2.2节中的代码改写为下面形式:

func main()  {
	for i:=0; i<5; i++ {
		defer func() {
			fmt.Println("hello in test" + strconv.Itoa(i))
		}()
	}
	fmt.Println("in main")
}

结果如下:

in main
hello in test5
hello in test5
hello in test5
hello in test5
hello in test5

当然可以修改一下,就好了:

func main()  {
	for i:=0; i<5; i++ {
		defer func(i int) {
			fmt.Println("hello in test" + strconv.Itoa(i))
		}(i)
	}
	fmt.Println("in main")
}

上面主要原因是把一个闭包给解除掉了,使得defered函数不在使用外部变量,而是使用传递进来的参数,这样不再是一个闭包。

4. 内部原理浅析

Go的多个被defer函数的调用关系是后进先出。但这不意味着defer的内存分配是栈区,千万不要混淆。
实际上defer的内存分配一开始是堆,但是经过不断的优化之后有三种模式,具体选用哪种分配模式要根据实际代码和编译的的选择。
由于本人水平还很有限,就不进行过多深入,怕误导大家,至于哪三种模式的总结我将引用坤神的文章

  1. 对于开放编码式 defer 而言:
    编译器会直接将所需的参数进行存储,并在返回语句的末尾插入被延迟的调用;
    当整个调用中逻辑上会执行的 defer 不超过 15 个(例如七个 defer 作用在两个返回语句)、总 defer 数量不超过 8 个、且没有出现在循环语句中时,会激活使用此类 defer;
    此类 defer 的唯一的运行时成本就是存储参与延迟调用的相关信息,运行时性能最好。
  2. 对于栈上分配的 defer 而言:
    编译器会直接在栈上记录一个 _defer 记录,该记录不涉及内存分配,并将其作为参数,传入被翻译为 deferprocStack 的延迟语句,在延迟调用的位置将 _defer 压入 Goroutine 对应的延迟调用链表中;
    在函数末尾处,通过编译器的配合,在调用被 defer 的函数前,调用 deferreturn,将被延迟的调用出栈并执行;
    此类 defer 的唯一运行时成本是从 _defer 记录中将参数复制出,以及从延迟调用记录链表出栈的成本,运行时性能其次。
  3. 对于堆上分配的 defer 而言:
    编译器首先会将延迟语句翻译为一个 deferproc 调用,进而从运行时分配一个用于记录被延迟调用的 _defer 记录,并将被延迟的调用的入口地址及其参数复制保存,入栈到 Goroutine 对应的延迟调用链表中;
    在函数末尾处,通过编译器的配合,在调用被 defer 的函数前,调用 deferreturn,从而将 _defer 实例归还到资源池,而后通过模拟尾递归的方式来对需要 defer 的函数进行调用。
    此类 defer 的主要性能问题存在于每个 defer 语句产生记录时的内存分配,记录参数和完成调用时的参数移动时的系统调用,运行时性能最差。

5. 小结

  1. 使用defer会产生一定的开销,虽然已经进行的很多优化,但是还是尽量不要使用
  2. 注意闭包和函数作为参数的问题

参考文献:
golang defer原理
坤神文章

撩我?
可以搜索我的公众号:Kyda
在这里插入图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章