深入理解defer内存分配及执行过程

defer作用与使用场景

在golang编程语言中,经常看到defer关键字搭配着函数一起出现,那么defer有哪些使用场景及这种使用方式背后又有什么原由呢?

defer特性

1. 延迟调用
2. LIFO后进先出:后调用先返回
3. 作用域
4. 异常场景

注意:defer使用时,需要和函数绑定。 两个理解:defer 只会和 defer 语句所在的特定函数绑定在一起,作用域也只在这个函数。从语法上来讲,defer 语句也一定要在函数内,否则会报告语法错误。

package main

func main() {
 func() {
  defer println("--- defer ---")
 }()
 println("--- ending ---")
}

如上,defer 处于一个匿名函数中,就 main 函数本身来讲,匿名函数 fun(){}() 先调用且返回,然后再调用 println("— ending —") ,所以程序输出自然是:

--- defer ---
--- ending ---

defer作用

defer 其实并不是 Golang 独创,是多种高级语言的共同选择;
defer 最重要的一个特点就是无视异常,依然可使程序执行,这个是 Golang 在提供了 panic-recover 机制之后做的补偿;
defer 的作用域存在于函数,defer 也只有和函数结合才有意义;
defer 允许把配套的两个行为代码放在最近相邻的两行,比如创建&释放、加锁&放锁、前置&后置,以免引发加锁之后忘记之类的异常,使得代码更易读,编程体验良好。

defer使用场景

1. 并发同步
2. 锁场景
3. 资源释放
4. panic-recover
  1. 并发同步
var wg sync.WaitGroup

for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        // 程序逻辑
    }()
}
wg.Wait()
  1. 锁场景
var mu sync.Mutex
 mu.Lock()
 defer mu.Unlock()

但是请注意,lock 以下的代码都会在锁内。所以下面的代码要足够精简和快速才行,如果说下面的逻辑很复杂,那么可能就需要手动控制 unlock 防止的位置了。

  1. 资源释放
file, err := os.Open("file.go") // For read access.
if err != nil {
	log.Fatal(err)
}
defer file.Close()
  1. panic-recover场景
 defer func() {
  if v := recover(); v != nil {
   _ = fmt.Errorf("PANIC=%v", v)
  }
 }()

defer性能

go 1.13 正式版本的发布提升了 defer 的性能,号称针对 defer 场景提升了 30% 的性能。

This release improves performance of most uses of defer by 30%.

go 1.13 之前只有 defer 语句只会被编译器翻译成两个过程:

1. 回调注册函数过程:deferproc
2. 执行回调函数链过程:deferreturn

go 1.13 带来的 deferprocStack 函数,这个函数就是这个 30% 性能提升的核心手段。deferprocStack 和 deferproc 的目的都是注册回调函数,这个还是不变,但是不同的是 deferprocStatck 是在栈内存上分配 struct _defer 结构,而 deferproc 这个是需要去堆上分配结构内存的。而我们绝大部分的场景都是可以是在栈上分配的,所以自然整体性能就提升了。栈上分配内存自然是比对上要快太多了,只需要 rsp 寄存器操作下就分配出来了。

_defer 数据结构

提示:该数据结构为 go 1.13 版本,go 1.14 版本的比这个稍微复杂,加了一些开放编码优化需要的字段。

type _defer struct {
	 siz     int32 // 参数和返回值的内存大小
	 started bool
	 heap    bool    // 区分该结构是在栈上分配的,还是对上分配的
	 sp        uintptr  // sp 计数器值,栈指针;
	 pc        uintptr  // pc 计数器值,程序计数器;
	 fn        *funcval // defer 传入的函数地址,也就是延后执行的函数;
	 _panic    *_panic  // panic that is running defer
	 link      *_defer   // 链表
}

***每一次的 defer 调用都会对应到一个 _defer 结构体,一个函数内可以有多个 defer 调用,所以自然需要一个数据结构来组织这些 _defer 结构体。_defer 按照对齐规则占用 48 字节的内存。_defer 结构体中的 link 字段把所有的 _defer 串成一个链表,表头是挂在 Goroutine 的 _defer 字段。***效果如下:
_defer链表

还有一个重点,_defer 结构只是一个 header ,结构紧跟的是延迟函数的参数和返回值的空间,大小由 _defer.siz 指定。这块内存的值在 defer 关键字执行的时候被填充好,而延迟函数的参数是预计算的。
_defer函数参数与返回值

当 defer 外层出现显式(for)或者隐式(goto)的时候,将会导致 struct _defer 结构体分配在堆上,那么性能就比较差了,这个编程的时候要注意。

在讲解defer内存分配及调用过程之前,需要先了解以下地址空间、栈帧划定、函数调用等基本概念。

函数调用基本知识回顾

如果要想深入地理解 defer ,避免踩坑,那么可以从理解下面的概念开始,这对理解 defer 在函数里的行为必不可少。

地址空间

下图是一个典型的操作系统的地址空间示意图:
栈地址空间

最重要的几点:

  1. 内核栈在高地址,用户栈在低地址。如果是 32 位操作系统,那么最经典的就是,用户栈区域为 [0, 3G],内核栈区域为 [3G, 4G];
  2. 栈空间分配是从高地址往下分配的(所以我们经常看到栈分配空间,是通过减 rsp 的值来实现就是这个道理)
  3. 堆空间分配是从低地址往上分配的。

函数栈帧

函数调用执行的时候,需要分配空间以存储数据,比如函数的参数,函数内局部变量,寄存器的值(用于上下文切换)。这些数据都需要保存在一个地方,这个地方就是在栈空间上。因为这些数据的声明周期是和函数一体的,函数执行的时候存在,函数执行完立马就可以销毁。因此,函数传递的参数、函数内的局部变量都是在栈上分配。
和堆空间不同,堆上用来分配 声明周期由程序员控制的对象。
栈的使用销毁由编译器控制,堆空间的使用则是由程序或者说是程序员(在有垃圾回收的语言里,堆空间的使用由语言层面支持)。

当函数调用的时候,对应产生一个栈帧(stack frame),函数结束的时候,释放栈帧。栈帧主要用来保存

  1. 函数参数
  2. 局部变量
  3. 返回值
  4. 寄存器的值(上下文切换)

函数在执行过程中使用一块栈内存来保存上述这些值。当发生函数调用时,因为 caller 还没执行完,caller 的栈帧中保存的数据还有用,所以 callee 函数执行的时候不能覆盖 caller 的栈帧,这种情况需要分配一个 callee 的栈帧。

栈空间的使用方式由编译器管理,在编译期间就确定。栈的大小就会随函数调用层级的增加而向低地址增加,随函数的返回而缩小,调用层级越深,消耗的栈空间就越大。所以,在递归函数的场景,经常见到有些递归太深的函数会报错,被操作系统直接拒绝,就是因为考虑到这个栈空间使用的合理性,OS对栈的深度有限制。

栈帧的划定

在底层,有两个寄存器的值来划定一个函数栈帧:

rsp :栈寄存器,指向当前栈顶位置;
rbp :栈帧寄存器,指向函数栈帧的起始位置;

所以,我们可以认为在一个函数执行的时候,rsp, rbp 这两个寄存器指向的区域就是当前函数的一个栈帧。在 golang 的一个函数的代码里,开头会先保存 rbp 寄存器的值,保存到栈上,函数执行完之后,需要返回 caller 函数之前,需要恢复 rbp 寄存器。

defer内存分配

注意:defer 放在循环嵌套的上下文中,可能导致性能问题。

编译器就能决定_defer 结构体在栈上还是在堆上分配,对应函数分别是 deferprocStatck 和 deferproc 函数。deferprocStack和deferproc两个函数的目的一致:分配出 _defer 的内存结构,把回调函数初始化进去,挂到链表中。

栈上分配 deferprocStack

deferprocStack 函数做了哪些事情呢?

// 进到这个函数之前,就已经在栈上分配好了内存结构(编译器分配的,rsp 往下伸展即可)
func deferprocStack(d *_defer) {
	gp := getg()
	
	// siz 和 fn 在进到该函数之前已经赋值
	d.started = false
	// 表明是栈的内存,true表明是在堆上分配的
	d.heap = false
	// 获取到 caller 函数的 rsp 寄存器值,并赋值到 _defer 结构 sp 字段中
	d.sp = getcallersp()
	// 获取到 caller 函数的 pc (rip) 寄存器值,并赋值到 _defer 结构 pc 字段中
	// 回忆函数调用的原理,就知道 caller 压栈时的 pc 值就是 deferprocStack 的下一行指令;
	d.pc = getcallerpc()
	
	// 把这个 _defer 结构作为一个节点,挂到 goroutine 的链表中;
	*(*uintptr)(unsafe.Pointer(&d._panic)) = 0
	*(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
	*(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))
	// 注意,特殊的返回,不会触发延迟调用的函数
	return0()
}

deferprocStack作用:

1. 由于是栈上分配内存的,所以其实在调用到 deferprocStack 之前,编译器就已经把  _defer 结构的函数准备好了;
2. _defer.heap 字段用来标识(deferprocStack函数中的_defer)这个结构体是分配在栈上;
3. 保存上下文,把 caller 函数的 rsp,pc(rip) 寄存器的值保存到 _defer 结构体;
4. _defer 作为一个节点挂接到链表。注意:表头是 goroutine 结构的 _defer 字段,而在一个协程任务中大部分是有多次函数调用的,所以这个链表会挂接一个调用栈上的 _defer 结构,执行的时候按照 rsp 来过滤区分;

_defer链表挂在goroutine上执行

堆上分配 deferproc

堆上分配的函数为 deferproc ,简化逻辑如下:

func deferproc(siz int32, fn *funcval) { // fn表示defer 传入的函数地址
	// 获取 caller 函数的 rsp 寄存器值
	sp := getcallersp()
	argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
	// 获取 caller 函数的 pc(rip) 寄存器值
	callerpc := getcallerpc()
	
	// 分配 _defer 内存结构
	d := newdefer(siz)
	if d._panic != nil {
		throw("deferproc: d.panic != nil after newdefer")
	}
	// _defer 结构体初始化
	d.fn = fn
	d.pc = callerpc
	d.sp = sp
	switch siz {
		case 0:
		// Do nothing.
		case sys.PtrSize:
			*(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
		default:
			memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
 	}
	 // 注意,特殊的返回,不会触发延迟调用的函数
	 return0()
}

deferproc所做工作:

1. 与栈上分配不同, _defer  结构是在deferproc函数里分配的(调用 newdefer 分配内存空间,newdefer 函数先去 pool 缓存池里看一眼,有就直接取用,没有就调用 mallocgc 从堆上分配内存);
2. deferproc 函数接受参数 siz,fn ,这两个参数分别标识延迟函数的参数及返回值的内存大小、延迟函数地址;
3. _defer.heap 字段用来标识这个结构体是分配在堆上;
4. 保存上下文,把 caller 函数的 rsp,pc(rip) 寄存器的值保存到 _defer 结构体;
5. _defer 作为一个节点挂接到链表;

执行 defer 函数链( deferreturn )

编译器遇到 defer 语句,会插入两类函数:

分配函数:deferproc 或者 deferprocStack ;
执行函数:deferreturn ;

分配函数前面详细说过了,go1.13 之后大部分场景都会在栈上分配,函数调用的时机是在defer 关键字执行的时候。函数退出时,则由 deferreturn 负责执行所有的延迟调用链

func deferreturn(arg0 uintptr) {
 gp := getg()
 // 获取到最前的 _defer 节点
 d := gp._defer
 // 函数递归终止条件(d 链表遍历完成)
 if d == nil {
  return
 }
 // 获取 caller 函数的 rsp 寄存器值
 sp := getcallersp()
 if d.sp != sp {
  // 如果 _defer.sp 和 caller 的 sp 值不一致,那么直接返回;
  // 因为,这种情况就说明这个 _defer 结构不是在该 caller 函数注册的  
  return
 }

 switch d.siz {
 case 0:
  // Do nothing.
 case sys.PtrSize:
  *(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
 default:
  memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
 }
 // 获取到延迟回调函数地址
 fn := d.fn
 d.fn = nil
 // 把当前 _defer 节点从链表中摘除
 gp._defer = d.link
 // 释放 _defer 内存(主要是堆上才会需要处理,栈上的随着函数执行完,栈收缩就回收了)
 freedefer(d)
 // 执行延迟回调函数
 jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}

deferreturn函数小结:

1. 遍历 defer 链表,一个个执行,顺序链表从前往后执行,执行一个摘除一个,直到链表为空;
2. jmpdefer 负责跳转到延迟回调函数执行指令,执行结束之后,跳转回 deferreturn 里执行;
3. _defer.sp 的值可以用来判断哪些是当前 caller 函数注册的,这样就能保证只执行自己函数注册的延迟回调函数;
4. 举个例子,a() -> b() -> c() ,a 调用 b,b 调用 c ,而 a,b,c 三个函数都有 defer 注册延迟函数,那么自然是 c()函数返回的时候,执行 c 的回调;

defer 如何传递参数

预计算参数

在前面描述 _defer 数据结构的时候说到内存结构如下:
_defer内存结构

_defer 作为一个 header,延迟回调函数( defered )的参数和返回值紧接着 _defer 放置,而这个参数的值是在 defer 执行的时候就设置好了,也就是预计算参数,而非等到执行 defered 函数的时候才去获取。

举个例子,执行 defer func(x, y) 的时候,x,y 这两个实参是计算出来的,Go 中的函数调用都是值传递。那么就会把 x,y 的值拷贝到 _defer 结构体中使用。再看个例子:

package main

func main() {
 var x = 1
 defer fmt.Println(x)
 x += 2
 return
}

这个程序标准输出是什么呢?是 1 ,还是 3 ?答案是:1 呢。
defer 执行的函数是 Println ,Println 参数是 x ,实参x 传进去的值则是在 defer 语句执行的时候就确认了的

defered 的参数准备

defered 延迟函数执行的参数已经保存在和 _defer 一起的连续内存块了。那么执行 defered 函数的时候,参数是哪里来呢?当然不是直接去 _defer 的地址找。因为这里是走的标准的函数调用。

在 Go 语言中,一个函数的参数由 caller 函数准备好,比如说,一个 main() -> A(7) -> B(a) 形成类似以下的栈帧:
函数调用栈帧示例

所以,***deferreturn 除了跳转到 defered 函数指令,还需要做一个事情:把 defered 延迟回调函数需要的参数准备好(空间和值)***。

func deferreturn(arg0 uintptr) {

 switch d.siz {
 case 0:
  // Do nothing.
 case sys.PtrSize:
  *(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
 default:
  memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
 }
}

arg0 就是 caller 用来放置 defered 参数和返回值的栈地址。这段代码的意思就是:把 _defer 预先准备好的参数,copy 到 caller 栈帧的某个地址(arg0)。

一个函数多个 defer 语句的时候,会发生什么?

_defer函数链
_defer 是一个链表,表头是 goroutine._defer 结构。一个协程的函数注册的时候是挂在同一个链表,执行的时候按照 rsp 来区分函数。并且,这个链表是把新元素插在表头,而执行的时候是从前往后执行,所以这里导致了一个 LIFO 的特性,也就是先注册的 defered 函数后执行。

图片

defer 和 return 返回值运行顺序

函数的调用过程

要理解这些看似高深的原理,首先要回归最基础的知识:函数调用的过程。函数具体调用细节可以参看 深入剖析 defer 原理篇 —— 函数调用的原理?,复习一下知识点小结:

1. go 的一行函数调用语句其实非原子操作,对应多行汇编指令,包括 1)参数设置,2) call 指令执行;
2. 其中 call 汇编指令的内容也有两个:返回地址压栈(会导致 rsp 值往下增长,rsp-0x8),callee 函数地址加载到 pc 寄存器;
3. go 的一行函数返回 return语句其实也非原子操作,对应多行汇编指令,包括 1)返回值设置 和 2)ret 指令执行;
4. 其中 ret 汇编指令的内容是两个,指令pc 寄存器恢复为 rsp 栈顶保存的地址,rsp 往上缩减,rsp+0x8;
5. 参数设置在 caller 函数里,返回值设置在 callee 函数里;
6. rsp, rbp 两个寄存器是栈帧的最重要的两个寄存器,这两个值划定了栈帧;
7. rbp 寄存器的常见的作用:作为栈基寄存器,以方便界定栈帧。但其实再深入了解下,rbp 在当今计算机体系里其实可以作为通用寄存器。而栈基寄存器最常见的作用还是为了调试,方便划定栈帧;

最重要的一点:Go 的 return 的语句调用是个复合操作,可以对应一下两个操作序列

1. 设置返回值
2. ret 指令跳转到 caller 函数

return 之后是先返回值还是先执行 defer 函数?

Golang 官方文档是有明确说明的:

That is, if the surrounding function returns through an explicit return statement, deferred functions are executed after any result parameters are set by that return statement but before the function returns to its caller.

也就是说,defer 的函数链调用是在设置了 result parameters 之后,但在运行指令上下文返回到 caller 函数之前。
所以在有 defer 调用的地方,执行 return 语句之后,对应执行三个操作序列:

1. 设置返回值
2. 执行 defered 链表
3. ret 指令跳转到 caller 函数

那么,根据这个原理我们来解析如下的行为:

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

func f2() (r int) {
 defer func(r int) { // defer在执行的时候,传入的参数r=0
  r = r + 5
 }(r)
 return 1
}

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

这三个函数的返回值分别是多少?

答案:f1() -> 1,f2() -> 1,f3() -> 6 。

我逐个解释下:

  1. 函数 f1 执行 return t 语句之后:
1.设置返回值:这里 r = t,这个时候局部变量 t 的值等于 1,所以 r = 1;
2.执行 defered 函数,t = t+5 ,之后局部变量 t 的值为 6;
3.执行汇编 ret 指令,跳转到 caller 函数;

所以,f1() 的返回值是 1 。

  1. 函数 f2 执行 return 1 语句之后:
1.设置返回值 r = 1 ;
2.执行 defered 函数,defered 函数传入的参数是 r,r 在预计算参数的时候值为 0,Go 传参为值传递,0 赋值给了匿名函数的参数变量,所以 ,r = r+5 ,匿名函数的参数变量 r 的值为 5;
3.执行汇编 ret 指令,跳转到 caller 函数;

所以,f2() 的返回值还是 1

  1. 函数 f3 执行 return 1 语句之后:
设置返回值 r = 1;
执行 defered 函数,r= r+5 ,之后返回值变量 r 的值为 6(这是个闭包函数,注意和 f2 区分);
执行汇编 ret 指令,跳转到 caller 函数;

所以,f1() 的返回值是 6。

在看,如下代码:

func main() {
	fmt.Println(test1())
	fmt.Println(test2())

}

func test1() int {
	var i int
	defer func() {
		i++
		fmt.Println("test_1_defer1:", i)
	}()

	defer func() {
		i++
		fmt.Println("test_1_defer2:", i)
	}()

	return i
}

func test2() (i int) {
	defer func() {
		i++
		fmt.Println("test_2_defer1:", i)
	}()

	defer func() {
		i++
		fmt.Println("test_2_defer2:", i)
	}()

	return i
}

运行结果如下:

test_1_defer2: 1
test_1_defer1: 2
0
test_2_defer2: 1
test_2_defer1: 2
2

总结

1. defer 关键字执行对应 _defer 数据结构,在 go1.1 - go1.12 期间一直是在堆上分配,在 go1.13 及之后优化成在栈上分配 _defer 结构,性能提升明显(go1.14之后,还有一个开放编码的优化,类似于内联);
2. _defer 数据结构大部分场景是分配在栈上,但是遇到循环嵌套的场景会导致结构分配在堆上。所以,程序员在使用 defer 的时候要注意场景,否则可能出现性能问题; 
3. _defer 对应一个注册的延迟回调函数(defered),defered 函数的参数和返回值紧跟 _defer,可以理解成 header,_defer 和函数参数,返回值所在内存是一块连续的空间,其中 _defer.siz 指明defer函数参数和返回值的所占空间大小;
4. 在同一个协程里注册的多个defer,都挂在一个链表中,表头为 goroutine._defer;
5. 新执行的defer(插入在待执行_defer链表的)在最前面,遍历执行的时候则是从前往后执行。所以 defer 注册函数具有 LIFO 后进先出的特性,也就是后注册的先执行;
6. 不同的(defer)函数都在这个链表上,以 _defer.sp 区分;
7. defered 的参数是预计算的,也就是在 defer 关键字执行的时候,参数就确认,赋值在 _defer 的内存块后面。执行的时候,copy 到栈帧对应的位置上;
8. jmpdefer 修改了默认的函数调用行为(修改了压栈指令),实现了一个 defered 链表循环执行,直到执行完成;
9. 在defer之后的return是 对应 3 个动作的复合操作:1)设置返回值;2)执行 defered 函数链表;3)ret 指令跳转;

参考

Go 的 defer 的特性还是有必要要了解下的!!!
Go 使用 defer 计算函数耗时为什么总是很少?
defer 的前世今生
golang中的defer必须掌握的7个知识点
深入剖析 defer 原理篇 —— 函数调用的原理?
解密 defer 原理,究竟背着程序猿做了多少事情?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

love666666shen

谢谢您的鼓励!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值