2.defer

defer

go 里的 defer 可以将函数延迟至返回前再执行。相比手动在 return 前执行函数来说,一是可以使开启和结束的代码放在一起,清晰的同时避免忘写结束代码;另外即便 panic 时函数也可以继续执行,这是手动写做不到的。

1. 基本原理

其基本原理是,编译器会检查 defer 关键字, 把 defer 部分的函数插入到返回之前。

对于多个 defer,则将其串成链表,后进先出

1.1 数据结构

结构体通过 link 字段串联成链表:

// src/runtime/runtime2.go
type _defer struct {
    siz       int32    // 参数和结果的大小
    heap      bool     // 是否是堆上分配
    openDefer bool     // 是否经过开放编码的优化
    sp        uintptr  // 栈指针
    pc        uintptr  // 调用方的程序计数器 program counter
    fn        *funcval // 传入的函数
    _panic    *_panic  // panic that is running defer
    link      *_defer  // defer链表
}

2. 执行机制

中间代码生成阶段的 cmd/compile/internal/gc.state.stmt 会负责处理程序中的 defer,该函数会根据条件的不同,使用三种不同的机制处理该关键字:

func (s *state) stmt(n *Node) {
	...
	switch n.Op {
	case ODEFER:
		if s.hasOpenDefers {
			s.openDeferRecord(n.Left) // openDeferRecord:开放编码
		} else {
			d := callDefer            // callDefer:堆分配,默认兜底方案
			if n.Esc == EscNever {    // defer 不处于 for循环 或 goto语句中
				d = callDeferStack      // callDeferStack:栈分配
			}
			s.callResult(n.Left, d)
		}
	}
}

堆分配、栈分配和开放编码是处理 defer 关键字的三种方法,早期的 Go 语言会在堆上分配 runtime._defer 结构体,不过该实现的性能较差,Go 语言在 1.13 中引入栈上分配的结构体,减少了 30% 的额外开销1,并在 1.14 中引入了基于开放编码的 defer,使得该关键字的额外开销可以忽略不计2。

2.1 堆上分配 (go1.13 之前)

从上面方法可以看出,堆上分配是默认的兜底方案。该方案会调用 cmd/compile/internal/gc.state.callResultcmd/compile/internal/gc.state.call,在 defer 位置生成一个 runtime.deferproc 调用,并在函数退出时生成一个 runtime.deferreturn 调用

  • deferproc 负责构造一个用来保存 函数地址 以及 函数参数_defer 结构体对象,并把该对象入栈,即插入 G 结构体对象的 _defer 链表表头

  • deferreturn 负责从栈里读取函数及参数,并依次执行

// 生成 runtime.deferproc 调用
func (s *state) call(n *Node, k callKind, returnResultAddr bool) *ssa.Value {
	...
	var call *ssa.Value
	if k == callDeferStack {
		// 在栈上初始化 defer 结构体
		...
	} else {
		...
		switch {
		case k == callDefer:
			aux := ssa.StaticAuxCall(deferproc, ACArgs, ACResults) // 这里调用 deferproc。个函数接收了参数的大小和闭包所在的地址两个参数。
			call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, aux, s.mem())
		...
		}
		call.AuxInt = stksize
	}
	s.vars[&memVar] = call
	...
}

编译器不仅将 defer 关键字都转换成 runtime.deferproc 函数,它还会通过以下三个步骤为所有调用 defer 的函数末尾插入 runtime.deferreturn 的函数调用:

  1. cmd/compile/internal/gc.walkstmt 在遇到 ODEFER 节点时会执行 Curfn.Func.SetHasDefer(true) 设置当前函数的 hasdefer 属性;

  2. cmd/compile/internal/gc.buildssa 会执行 s.hasdefer = fn.Func.HasDefer() 更新 statehasdefer

  3. cmd/compile/internal/gc.state.exit 会根据 statehasdefer 在函数返回之前插入 runtime.deferreturn 的函数调用;

// 生成 Deferreturn 调用
func (s *state) exit() *ssa.Block {
	if s.hasdefer {
		...
		s.rtcall(Deferreturn, true, nil) // 这里调用 Deferreturn
	}
	...
}

2.2 栈上分配 (go1.13)

go1.13 之前所有 defer 都是在堆上分配。go1.13 版本新加入 deferprocStack 实现了在栈上分配 defer

相比堆上分配,栈上分配在函数返回后 _defer 便得到释放,省去了内存分配时产生的性能开销,只需适当维护 _defer 的链表即可。按官方文档的说法,这样做提升了约 30% 左右的性能。

除了分配位置的不同,栈上分配和堆上分配并没有本质的不同。

另外,1.13 版本中并不是所有 defer 都能够在栈上分配。循环中的 defer,无论是显示的 for 循环,还是 goto 形成的隐式循环,都只能使用堆上分配。

2.3 开放编码 (go1.14)

go 1.14 版本加入了开放编码(open coded),该机制会把 defer 调用的代码直接内联到返回之前,省去了 runtime 的操作。

还拿上面那个例子举例,编译器将生成如下的代码:

fmt.Println("hello")

bar() // generated for line 5
foo() // generated for line 5

然而开放编码作为一种优化 defer 关键字的方法,它不是在所有的场景下都会开启的,开放编码只会在满足以下的条件时启用:

  1. 函数的 defer 数量 <= 8 个;

  2. 函数的 defer 关键字不能在循环中执行;

  3. 函数的 return 语句与 defer 语句的乘积小于或者等于 15 个;

  4. 其他等条件(略

// cmd/compile/internal/gc.walkstmt
const maxOpenDefers = 8

func walkstmt(n *Node) *Node {
	switch n.Op {
	case ODEFER:
		Curfn.Func.SetHasDefer(true)
		Curfn.Func.numDefers++
		if Curfn.Func.numDefers > maxOpenDefers {       // defer 大于 8 个
			Curfn.Func.SetOpenCodedDeferDisallowed(true)  // 禁用开放编码优化
		}
		if n.Esc != EscNever {                          // defer 在循环中
			Curfn.Func.SetOpenCodedDeferDisallowed(true)
		}
		fallthrough
	...
	}
}

// cmd/compile/internal/gc.buildssa
func buildssa(fn *Node, worker int) *ssa.Func {
	...
	s.hasOpenDefers = s.hasdefer && !s.curfn.Func.OpenCodedDeferDisallowed()
	...
	if s.hasOpenDefers &&
		s.curfn.Func.numReturns*s.curfn.Func.numDefers > 15 { // defer 与 return 语句数量的乘积 > 15
		s.hasOpenDefers = false
	}
	...
  if s.hasOpenDefers {
		deferBitsTemp := tempAt(src.NoXPos, s.curfn, types.Types[TUINT8]) // 初始化延迟比特,大小为8 bit,因此才限制 defer 数量最多8个
		s.deferBitsTemp = deferBitsTemp
		startDeferBits := s.entryNewValue0(ssa.OpConst8, types.Types[TUINT8])
		s.vars[&deferBitsVar] = startDeferBits
		s.deferBitsAddr = s.addr(deferBitsTemp)
		s.store(types.Types[TUINT8], s.deferBitsAddr, startDeferBits)
		s.vars[&memVar] = s.newValue1Apos(ssa.OpVarLive, types.TypeMem, deferBitsTemp, s.mem(), false)
	}
}

一旦决定使用开放编码,cmd/compile/internal/gc.buildssa 会在编译期间在栈上初始化大小为 8 个比特的 deferBits 变量,通过二进制的01标记哪些 defer 关键字在函数中被执行。正是因为 deferBits 的大小仅为 8 比特,所以该优化的启用条件为函数中的 defer 关键字少于 8 个。

源码

deferproc

主要做的事情就是把 defer 后的函数、参数写入 _defer 结构体里,并将其插入到 Gdefer 链表头部

// src/runtime/panic.go
func deferproc(siz int32, fn *funcval) {  
    sp := getcallersp()                                      // 获取栈指针
    argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn) // fn 函数后紧跟的就是参数列表
    callerpc := getcallerpc()
    
    d := newdefer(siz) 	// 新建一个 _defer(会优先使用空闲缓存,没有空闲的才新建
    d.link = gp._defer  // 新 defer 做头元素,原链表挂在新 defer 后面。因此 defer 的执行顺序是后进先出
    gp._defer = d       // 把链表写入G里
  
    d.fn = fn
    d.pc = callerpc
    d.sp = sp
    
    // 进行参数拷贝
    switch siz {
    // 如果defered函数的参数只有指针大小则直接通过赋值来拷贝参数
    case sys.PtrSize:
        // 将 argp 所对应的值 写入到 deferArgs 返回的地址中
        *(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
    default:
        // 如果参数大小不是指针大小,那么进行数据拷贝
        memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
    }
    return0() 
}

deferreturn

// src/runtime/panic.go
func deferreturn(arg0 uintptr) {
    gp := getg()
    d := gp._defer
    
    sp := getcallersp() // 确定 defer 的调用方是不是当前 deferreturn 的调用方
    if d.sp != sp {
      return
    }

    switch d.siz {
    case sys.PtrSize:
        // 将 defer 保存的参数复制出来
        // arg0 实际上是 caller SP 栈顶地址值,所以这里实际上是将参数复制到 caller SP 栈顶地址值
        *(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
    default:
        // 如果参数大小不是 sys.PtrSize,那么进行数据拷贝
        memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
    }
  
    gp._defer = d.link // 将 defer 从链表移出
    freedefer(d)       // 将 defer 对象放入到 defer 池中,后面可以复用

    fn := d.fn
    _ = fn.fn
  
    // 这里是实际执行函数的部分,jumpdefer 是汇编代码
    jmpdefer(fn, uintptr(unsafe.Pointer(&arg0))) // 传入需要执行的函数和参数
}

panic

顺带一提,panic 的实现和 defer 息息相关,也写在这里。

先来看 G 的结构。G 的字段里分别有两个链表,储存 panicdefer

type g struct {
    _panic       *_panic   // panic链表
    _defer       *_defer   // defer链表
}

编译器将 panic 翻译成 gopanic 函数调用。它会将错误信息打包成 _panic 对象,并挂到 G._panic 链表的头部。然后遍历 G._defer 链表,检查是否有 recover。如被 recover,则终止遍历执行,跳转到正常的 deferreturn 环节;否则终止进程。

正因为 panicG 相关,因此在主协程里的 recover, 是无法捕获子协程的 panic 的。

参考

深入理解defer(下)defer实现机制

draveness - 5.3 defer

Last updated