Golang 最细节篇 — 解密 defer 原理,究竟背着程序猿做了多少事情?

890次阅读  |  发布于3年以前

大纲

前文提要

前面从使用姿势 ([Go 的 defer 的特性还是有必要要了解下的!!!] ) 和特性上分析了 defer 关键字,让我们对此有个形象的概念,然后剖析了函数调用的本质原理 ( [深入剖析 defer 原理篇 —— 函数调用的原理?] ),接下来剖析就是真正 defer 这个关键字背后的原理了。

思考几个问题:

  1. 编译器怎么编译 defer 关键字?
  2. defer 语句怎么传递参数?
  3. 一个函数内多个 defer 语句的时候,会发生什么?
  4. defer 和 return 返回值运行顺序究竟是怎样的?

编译器怎么编译 defer

defer dosomething(x)

通俗来讲,执行 defer 语句之后,是注册记录一个稍后执行的函数。把函数名和参数确定下来,不会立即调用,而是等到从当前函数 return 出去的时候。

如果要知道 defer 做了什么,就要我们先从最基本的数据结构和内部函数讲起。

struct _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 结构只是一个 header ,结构紧跟的是延迟函数的参数和返回值的空间,大小由 _defer.siz 指定。这块内存的值在 defer 关键字执行的时候填充好。这里引出一个下面重点的概念:延迟函数的参数是预计算的。

struct _defer 内存分配

以此为例:

package main

func doDeferFunc(x int) {
 println(x)
}

func doSomething() int {
 var x = 1
 defer doDeferFunc(x)
 x += 2
 return x
}

func main() {
 x := doSomething()
 println(x)
}

编译命令,故意去除优化:

go build -gclfags "-N -l"

我们看下编译成的二进制代码:

从汇编指令我们看到,编译器在遇到 defer 关键字的时候,添加了一些“用户不可见”的函数:

  1. deferprocStack
  2. deferreturn

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

go 1.13 release note

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

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

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

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

那么什么时候分配在栈上,什么时候分配在堆上呢?

在编译器相关的文件(src/cmd/compile/internal/gc/ssa.go )里,有个条件判断:


func (s *state) stmt(n *Node) {

 case ODEFER:
  d := callDefer
  if n.Esc == EscNever {
   d = callDeferStack
  }
}

n.Escast.Node 的逃逸分析的结果,那么什么时候 n.Esc 会被置成 EscNever 呢?

这个在逃逸分析的函数 esc 里(src/cmd/compile/internal/gc/esc.go ) :

func (e *EscState) esc(n *Node, parent *Node) {

 case ODEFER:
  if e.loopdepth == 1 { // top level
   n.Esc = EscNever // force stack allocation of defer record (see ssa.go)
   break
  }
}

这里 e.loopdepth 等于 1的时候,才会设置成 EscNevere.loopdepth 字段是用于检测嵌套循环作用域的,换句话说,defer 如果在嵌套作用域的上下文中,那么就可能导致 struct _defer 分配在堆上,如下:

package main

func main() {
 for i := 0; i < 2; i++ {
  defer func() {
   _ = i
  }()
 }
}

编译器生成的则是 deferproc

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

小结:小心了,defer 放在循环嵌套的上下文中,可能导致性能问题。

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

栈上分配 <span style="font-size: 14px;">deferprocStack

deferprocStack 函数做了哪些事情?其实非常简单:

// 进到这个函数之前,就已经在栈上分配好了内存结构(编译器分配的,rsp 往下伸展即可)
func deferprocStack(d *_defer) {
 gp := getg()

 // siz 和 fn 在进到这个函数之前已经赋值;
 d.started = false
 // 标名是栈的内存
 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()
}

小结:

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

堆上分配 deferproc

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

func deferproc(siz int32, fn *funcval) { // arguments of fn follow fn
 // 获取 caller 函数的 rsp 寄存器值
 sp := getcallersp()
 argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
 // 获取 caller 函数的 pc(rip) 寄存器值
 callerpc := getcallerpc()

 // 分配 struct _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()
}

小结:

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

执行 defer 函数链( deferreturn )

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

  1. 分配函数:deferproc 或者 deferprocStack
  2. 执行函数: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)))
}

小结

  1. 遍历 defer 链表,一个个执行,顺序链表从前往后执行,执行一个摘除一个,直到链表为空;
  2. jmpdefer 负责跳转到延迟回调函数执行指令,执行结束之后,跳转回 deferreturn 里执行;
  3. _defer.sp 的值可以用来判断哪些是当前 caller 函数注册的,这样就能保证只执行自己函数注册的延迟回调函数;

jmpdefer 是一段非常简短的汇编代码,但是非常重要,我们仔细品一品。

jmpdefer

jmpdefer 这个是一小段纯汇编的代码,实现的功能是:跳转到 defered 延迟函数执行指令。这里要完全按理解 jmpdefer 做了什么,一定要理解函数调用的基础知识(这个也是我上篇专门复习函数调用的原有)。当 golang 的 return 关键字执行的时候,触发 call 调用函数 deferreturn ,在函数 deferreturn 中摘取 _defer 节点,然后逐个执行,执行的入口就是调用 jmpdefer 来实现。所以,你以为的调用关系是:

-> defered function (延迟回调函数)
-> jmpdefer  (汇编代码)
-> deferreturn (执行调用链)
-> caller (defer 所在的 caller 函数)

但其实,真实的栈帧是只有两个:

-> defered function
-> caller

为什么栈帧只有两个?因为这里是特殊的、巧妙的实现。

TEXT runtime·jmpdefer(SB), NOSPLIT, $0-16
    MOVQ    fv+0(FP), DX    // 取出延迟回调函数 fn 地址 
    MOVQ    argp+8(FP), BX  // 取出 caller  函数的 rsp 值
    LEAQ    -8(BX), SP      // rsp 的值设置成 caller 的 rsp 值往下 8 字节(也就是 after CALL,压榨的 8 字节算在栈帧以内)
    MOVQ    -8(SP), BP      // 还原 caller 栈帧寄存器(restore BP as if deferreturn returned (harmless if framepointers not in use))
    SUBQ    $5, (SP)        // 重要操作,这个把压栈在栈顶的值修改了。之前压得是 caller 函数内,调用 deferreturn 之后下一行指令,现在压的是 call runtime.deferreturn 这行值。
    MOVQ    0(DX), BX       // 取出 fn 函数指令地址,存到 rbx 寄存器
    JMP BX                  // 跳到延迟回调函数执行

二进制反汇编指令:

(gdb) disassemble 
Dump of assembler code for function runtime.jmpdefer:
   0x0000000000452dc0 <+0>: mov    0x8(%rsp),%rdx
   0x0000000000452dc5 <+5>: mov    0x10(%rsp),%rbx
   0x0000000000452dca <+10>: lea    -0x8(%rbx),%rsp
   0x0000000000452dce <+14>: mov    -0x8(%rsp),%rbp
   0x0000000000452dd3 <+19>: subq   $0x5,(%rsp)
=> 0x0000000000452dd8 <+24>: mov    (%rdx),%rbx
   0x0000000000452ddb <+27>: jmpq   *%rbx

指令解析:

  1. 汇编语句 $0-16 说明,数字0 这个函数栈帧为 0 (也就是说没有栈帧,不需要,因为没有局部变量或者其他的需要保存的),数字 16 说明入参数为 16 个字节。参数和返回值的大小是声明给调用者看的,调用者根据这个数字可以构造栈,caller 为 callee 准备需要的参数,callee 设置返回值到对应的位置;
  2. 上来最前两行 mov 指令,就是把入参取出来而已,第一个参数是延迟函数(defered func)地址,保存到 rdx 寄存器,第二个参数是 caller 函数的 rsp 值,保存到 rbx 寄存器;
  3. 然后,恢复 rbp 的值(恢复成 caller 的栈基);
  4. 然后,恢复 rsp 的值,恢复成 caller 的栈顶值(没有调用 deferreturn 之前的值 ),并且(重点来了),要显式把 rsp 往下扩展 8 字节,类似 call 指令压栈的效果,而压栈的值要手动修改成 caller 里面 call deferreturn 的指令地址;
  5. 然后,使用 jmp 指令跳转到延迟函数的指令地址执行(注意了,jmp 指令和 call 的最重要的区别就是:jmp 指令只会跳转,不会压栈从而导致 rsp 变化);

调用顺序:

caller -> deferreturn -> defered() -> caller

重点注意这行指令:

     0x0000000000452dd3 <+19>: subq   $0x5,(%rsp)

  1. 当初 caller 的调用 deferreturn 之后压栈的值是 0x48e8c1 ,这个很容易理解,0x48e8c1 这个是 call deferreturn 的下一行指令,call 调用的时候就是会把下一行指令压栈的。
  2. 然后,因为在 subq $0x5,(%rsp) 指令之前,$rsp 是恢复到 caller 函数的栈顶值(并且往下减 0x8 了,模拟压栈往下扩展),那么 [$rsp] 里存储的值刚好就是 call deferreturn 的时候压栈的值,就是 0x48e8c1
  3. 然后,把 0x48e8c10x5 ,结果是 0x48e8bc ,这个地址就好玩了,刚好指向了 call runtime.deferreturn 这行指令;

再解释一下 jmp 指令之后发生的事情:

   0x0000000000452ddb <+27>: jmpq   *%rbx

这行 jmp 指令跳转到 defered func 函数执行去,执行完延迟回调函数之后,ret 指令弹栈得到的地址是 0x48e8c1 ,这样就又会调用 call deferreturn 函数;

这样就实现了 caller -> [ deferreturn -> jmpdefer -> defered func -> deferreturn ] 的递归循环,而当 gp._defer == nil 的时候则结束递归循环。

func deferreturn(arg0 uintptr) {
    // ....
    d := gp._defer
    if d == nil {
        return
    }
    // ....
}

图示如下:

这个相当于编译器手动管理了函数栈帧,通过修改栈上的值,让延迟调用函数执行完调用 ret 的时候,重新跳转到 deferreturn 函数进行循环执行。(旁白:这个也是早年黑客常常使用的一种 hack 手段,修改函数压栈的值,跳转到一些 hack 的指令上去执行代码。)

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 println(x)
 x += 2
 return
}

这个程序标准输出是什么呢?是 1 ,还是 3 ?答案是:1 呢,你猜对了吗。defer 执行的函数是 printlnprintln 参数是 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 是一个链表,表头是 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) {
  r = r + 5
 }(r)
 return 1
}

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

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

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

我逐个解释下:

函数 f1 执行 return t 语句之后:

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

所以,f1() 的返回值是 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 ;

函数 f3 执行 return 1 语句之后:

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

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

总结

  1. defer 关键字执行对应 _defer 数据结构,在 go1.1 - go1.12 期间一直是堆上分配,在 go1.13 之后优化成栈上分配 _defer 结构,性能提升明显(go1.14之后,还有一个开放编码的优化,类似于内联,此处不表);

2 . _defer 数据结构大部分场景是分配在栈上,但是遇到循环嵌套的场景会导致结构分配在堆上。所以,程序员在使用 defer 的时候要注意场景,否则可能出现性能问题;

3 . _defer 对应一个注册的延迟回调函数(defered),defered 函数的参数和返回值紧跟 _defer,可以理解成 header,_defer 和函数参数,返回值所在内存是一块连续的空间,其中 _defer.siz 指明参数和返回值的所占空间大小;

4 . 同一个协程里 defer 注册的函数,都挂在一个链表中,表头为 goroutine._defer

a . 新元素插入在最前面,遍历执行的时候则是从前往后执行。所以 defer 注册函数具有 LIFO 的特性,也就是后注册的先执行;

b. 不同的函数都在这个链表上,以 _defer.sp 区分;

6 . defered 的参数是预计算的,也就是在 defer 关键字执行的时候,参数就确认,赋值在 _defer 的内存块后面。执行的时候,copy 到栈帧对应的位置上;

7 . jmpdefer 修改了默认的函数调用行为(修改了压栈指令),实现了一个 defered 链表循环执行,直到执行完成;

8 . return 对应 3 个动作的复合操作,1)设置返回值;2)执行 defered 函数链表;3)ret 指令跳转;

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8