5.3 defer

Kesa...大约 12 分钟golang

5.3.1 现象

使用defer时会出现两个常见的问题:

  1. defer的调用时机和调用顺序
  2. defer会使用值传递的方式传参时进行预计算,导致预期结果不符

作用域

defer语句会在函数返回之前执行,并按照后进先出的顺序执行。

func main() {
	for i := 0; i < 5; i++ {
		defer println(i)
	}
}

$ go run main.go
4
3
2
1
0

上述代码可以看出,defer的执行顺序是按照后进先出的顺序的。

func main() {
	{
		defer println("defer runs")
		println("block ends")
	}

	println("func ends")
}

$ go run main.go
block ends
main ends
defer runs

可以看出,defer是在函数返回之前被调用。

预计算参数

func main() {
	startedAt := time.Now()
	defer fmt.Println(time.Since(startedAt))
	
	time.Sleep(time.Second)
}

$ go run main.go
0s

调用 defer 关键字会立刻拷贝函数中引用的外部参数time.Since(startedAt) 的结果不是在 main 函数退出之前计算的,而是在 defer 关键字调用时计算的,最终导致上述代码输出 0s。

若使用defer调用匿名函数:

func main() {
	startedAt := time.Now()
	defer func() { fmt.Println(time.Since(startedAt)) }()
	
	time.Sleep(time.Second)
}

$ go run main.go
1s

此时调用defer时,拷贝的是函数的指针,对函数内部的参数没有影响,可得到预期的结果。

5.3.2 数据结构

defer的数据结构用runtime._deferopen in new window表示,主要字段如下:

type _defer struct {
	siz       int32
	started   bool
	openDefer bool
	sp        uintptr
	pc        uintptr
	fn        *funcval
	_panic    *_panic
	link      *_defer
    ...
}
  • siz:参数和结果的内存大小
  • sp:栈指针
  • pc:程序计数器
  • fndefer关键字中传入的函数
  • _panic:触发延迟调用的结构体
  • openDefer:当前defer是否经过开放编码优化
  • link:下一个_defer节点

runtime._deferopen in new window之间使用链表的形式串联起来:

golang-defer-link
golang-defer-link

5.3.3 执行机制

中间代码生成阶段会使用cmd/compile/internal/gc.state.stmtopen in new window根据三种不同的情况进行处理:

  1. 堆分配
  2. 栈分配
  3. 开放编码
func (s *state) stmt(n *Node) {
	...
	switch n.Op {
	case ODEFER:
		if s.hasOpenDefers {
			s.openDeferRecord(n.Left) // 开放编码
		} else {
			d := callDefer // 堆分配
			if n.Esc == EscNever {
				d = callDeferStack // 栈分配
			}
			s.callResult(n.Left, d)
		}
	}
}

5.3.4 堆上分配

当使用堆分配时,编译器会调用 cmd/compile/internal/gc.state.callResultopen in new windowcmd/compile/internal/gc.state.callopen in new window进行处理(实际上defer是被按照函数调用进行处理的)。

cmd/compile/internal/gc.state.callopen in new window负责为所有函数和方法调用生成中间代码,流程如下:

  1. 获取需要执行的函数名、闭包指针、代码指针和函数调用的接收方
  2. 获取栈地址并将函数或者方法的参数写入栈中
  3. 使用 cmd/compile/internal/gc.state.newValue1Aopen in new window 以及相关函数生成函数调用的中间代码
  4. 若当前调用的函数是 defer,那么会单独生成相关的结束代码块
  5. 获取函数的返回值地址并结束当前调用
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)
			call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, aux, s.mem())
		...
		}
		call.AuxInt = stksize
	}
	s.vars[&memVar] = call
	...
}

编译器将 defer 关键字都转换成 runtime.deferprocopen in new window 函数,并通过以下三个步骤为所有调用 defer 的函数末尾插入 runtime.deferreturnopen in new window 的函数调用:

  1. cmd/compile/internal/gc.walkstmtopen in new window 在遇到 ODEFER 节点时会执行 Curfn.Func.SetHasDefer(true) 设置当前函数的 hasdefer 属性
  2. cmd/compile/internal/gc.buildssaopen in new window 会执行 s.hasdefer = fn.Func.HasDefer() 更新 statehasdefer
  3. cmd/compile/internal/gc.state.exitopen in new window 会根据 statehasdefer 在函数返回之前插入 runtime.deferreturnopen in new window 的函数调用
func (s *state) exit() *ssa.Block {
	if s.hasdefer {
		...
		s.rtcall(Deferreturn, true, nil)
	}
	...
}

上述两个运行时函数是 defer 关键字运行时机制的入口,分别承担了不同的工作:

创建延迟调用

runtime.deferprocopen in new windowdefer 创建新的 runtime._deferopen in new window 结构体、设置函数指针 fn、程序计数器 pc 和栈指针 sp 并将相关的参数拷贝到相邻的内存空间中:

func deferproc(siz int32, fn *funcval) {
	sp := getcallersp()
	argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
	callerpc := getcallerpc()

	d := newdefer(siz)
	if d._panic != nil {
		throw("deferproc: d.panic != nil after newdefer")
	}
	d.fn = fn
	d.pc = callerpc
	d.sp = sp
	switch siz {
	case 0:
	case sys.PtrSize:
		*(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
	default:
		memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
	}

	return0()
}

最后调用的 runtime.return0open in new window 是唯一一个不会触发延迟调用的函数,可以避免递归 runtime.deferreturnopen in new window 的递归调用。

runtime.deferprocopen in new windowruntime.newdeferopen in new window 的作用是获得 runtime._deferopen in new window 结构体,包含三种路径:

  1. 从调度器的延迟调用缓存池 sched.deferpool 中取出结构体并将该结构体追加到当前 Goroutine 的缓存池中
  2. 从 Goroutine 的延迟调用缓存池 pp.deferpool 中取出结构体
  3. 通过 runtime.mallocgcopen in new window 在堆上创建一个新的结构体
func newdefer(siz int32) *_defer {
	var d *_defer
	sc := deferclass(uintptr(siz))
	gp := getg()
	if sc < uintptr(len(p{}.deferpool)) {
		pp := gp.m.p.ptr()
		if len(pp.deferpool[sc]) == 0 && sched.deferpool[sc] != nil {
			for len(pp.deferpool[sc]) < cap(pp.deferpool[sc])/2 && sched.deferpool[sc] != nil {
				d := sched.deferpool[sc]
				sched.deferpool[sc] = d.link
				pp.deferpool[sc] = append(pp.deferpool[sc], d)
			}
		}
		if n := len(pp.deferpool[sc]); n > 0 {
			d = pp.deferpool[sc][n-1]
			pp.deferpool[sc][n-1] = nil
			pp.deferpool[sc] = pp.deferpool[sc][:n-1]
		}
	}
	if d == nil {
		total := roundupsize(totaldefersize(uintptr(siz)))
		d = (*_defer)(mallocgc(total, deferType, true))
	}
	d.siz = siz
	d.link = gp._defer
	gp._defer = d
	return d
}

三种方式最终都会将获取到 runtime._deferopen in new window 结构体追加到所在 Goroutine _defer 链表的最前面

golang-new-defer
golang-new-defer

可以看出defer 关键字的插入顺序从后向前的,而 defer 关键字执行从前向后的。

执行延迟调用

runtime.deferreturnopen in new window 会从 Goroutine 的 _defer 链表取出最前面runtime._deferopen in new window 并调用 runtime.jmpdeferopen in new window 传入需要执行的函数和参数:

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

	switch d.siz {
	case 0:
	case sys.PtrSize:
		*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
	default:
		memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
	}
	fn := d.fn
	gp._defer = d.link
	freedefer(d)
	jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}

runtime.jmpdeferopen in new window主要工作是跳转到 defer 所在的代码段并在执行结束之后跳转回 runtime.deferreturnopen in new window

TEXT runtime·jmpdefer(SB), NOSPLIT, $0-8
	MOVL	fv+0(FP), DX	// fn
	MOVL	argp+4(FP), BX	// caller sp
	LEAL	-4(BX), SP	// caller sp after CALL
#ifdef GOBUILDMODE_shared
	SUBL	$16, (SP)	// return to CALL again
#else
	SUBL	$5, (SP)	// return to CALL again
#endif
	MOVL	0(DX), BX
	JMP	BX	// but first run the deferred function

runtime.deferreturnopen in new window 会多次判断当前 Goroutine 的 _defer 链表中是否有未执行的结构体,该函数只有在所有延迟函数都执行后才会返回。

5.3.5 栈上分配

当使用上分配时,可以节约内存分配带来的额外开销,与堆上分配的 runtime._deferopen in new window 相比,该方法可以将 defer 关键字的额外开销降低 ~30%。。

go v1.13 中,对 defer 关键字进行了优化,当该关键字在函数体中最多执行一次时,编译期间的 cmd/compile/internal/gc.state.callopen in new window 会将结构体分配到栈上并调用 runtime.deferprocStackopen in new window

func (s *state) call(n *Node, k callKind) *ssa.Value {
	...
	var call *ssa.Value
	if k == callDeferStack {
		// 在栈上创建 _defer 结构体
		t := deferstruct(stksize)
		...

		ACArgs = append(ACArgs, ssa.Param{Type: types.Types[TUINTPTR], Offset: int32(Ctxt.FixedFrameSize())})
		aux := ssa.StaticAuxCall(deferprocStack, ACArgs, ACResults) // 调用 deferprocStack
		arg0 := s.constOffPtrSP(types.Types[TUINTPTR], Ctxt.FixedFrameSize())
		s.store(types.Types[TUINTPTR], arg0, addr)
		call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, aux, s.mem())
		call.AuxInt = stksize
	} else {
		...
	}
	s.vars[&memVar] = call
	...
}

在编译期间已经创建了 runtime._deferopen in new window 结构体,所以在运行期间 runtime.deferprocStackopen in new window 只需要设置一些未在编译期间初始化的字段,就可以将栈上的 runtime._deferopen in new window 追加到函数的链表上:

func deferprocStack(d *_defer) {
	gp := getg()
	d.started = false
	d.heap = false // 栈上分配的 _defer
	d.openDefer = false
	d.sp = getcallersp()
	d.pc = getcallerpc()
	d.framepc = 0
	d.varp = 0
	*(*uintptr)(unsafe.Pointer(&d._panic)) = 0
	*(*uintptr)(unsafe.Pointer(&d.fd)) = 0
	*(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
	*(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))

	return0()
}

5.3.5 开放编码

go v1.14之后,通过开放编码(Open Coded)实现defer关键字,该设计使用代码内联优化 defer 关键的额外开销并引入函数数据 funcdata 管理 panic 的调用,该优化可以将 defer 的调用开销从 1.13 版本的 ~35ns 降低至 ~6ns 左右:

With normal (stack-allocated) defers only:         35.4  ns/op
With open-coded defers:                             5.6  ns/op
Cost of function call alone (remove defer keyword): 4.4  ns/op

开放编码只会在满足以下的条件时启用:

  1. 函数的 defer 数量少于或者等于 8 个
  2. 函数的 defer 关键字不能在循环中执行
  3. 函数的 return 语句与 defer 语句的乘积小于或者等于 15 个

启用优化

程序在编译期间会确定是否启用开放编码,编译器生成中间代码之前,会使用 cmd/compile/internal/gc.walkstmtopen in new window 修改已经生成的抽象语法树,设置函数体上的 OpenCodedDeferDisallowed 属性:

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 {
			Curfn.Func.SetOpenCodedDeferDisallowed(true)
		}
		if n.Esc != EscNever {
			Curfn.Func.SetOpenCodedDeferDisallowed(true)
		}
		fallthrough
	...
	}
}

在 SSA 中间代码生成阶段的 cmd/compile/internal/gc.buildssaopen in new window 中,能够看到启用开放编码优化的其他条件,也就是返回语句的数量与 defer 数量的乘积需要小于 15:

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 {
		s.hasOpenDefers = false
	}
	...
}

中间代码生成的这两个步骤会决定当前函数是否应该使用开放编码优化 defer 关键字,一旦确定使用开放编码,就会在编译期间初始化延迟比特和延迟记录。

延迟记录

延迟比特和延迟记录是使用开放编码实现 defer 的两个最重要结构,一旦决定使用开放编码,cmd/compile/internal/gc.buildssaopen in new window 会在编译期间在栈上初始化大小为 8 个比特的 deferBits 变量:

func buildssa(fn *Node, worker int) *ssa.Func {
	...
	if s.hasOpenDefers {
		deferBitsTemp := tempAt(src.NoXPos, s.curfn, types.Types[TUINT8]) // 初始化延迟比特
		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)
	}
}

延迟比特中的每一个比特位都表示该位对应的 defer 关键字是否需要被执行,如下图所示,其中 8 个比特的倒数第二个比特在函数返回前被设置成了 1,那么该比特位对应的函数会在函数返回前执行:

golang-defer-bits
golang-defer-bits

因为不是函数中所有的 defer 语句都会在函数返回前执行,如下所示的代码只会在 if 语句的条件为真时,其中的 defer 语句才会在结尾被执行

deferBits := 0 // 初始化 deferBits

_f1, _a1 := f1, a1  // 保存函数以及参数
deferBits |= 1 << 0 // 将 deferBits 最后一位置位 1

if condition {
    _f2, _a2 := f2, a2  // 保存函数以及参数
    deferBits |= 1 << 1 // 将 deferBits 倒数第二位置位 1
}
exit:

if deferBits & 1 << 1 != 0 {
    deferBits &^= 1 << 1
    _f2(a2)
}

if deferBits & 1 << 0 != 0 {
    deferBits &^= 1 << 0
    _f1(a1)
}

延迟比特的作用就是标记哪些 defer 关键字在函数中被执行,这样在函数返回时可以根据对应 deferBits 的内容确定执行的函数,而正是因为 deferBits 的大小仅为 8 比特,所以该优化的启用条件为函数中的 defer 关键字少于 8 个。

上述伪代码展示了开放编码的实现原理,但是仍然缺少了一些细节,例如:传入 defer 关键字的函数和参数都会存储在如下所示的 cmd/compile/internal/gc.openDeferInfoopen in new window 结构体中:

type openDeferInfo struct {
	n           *Node
	closure     *ssa.Value
	closureNode *Node
	rcvr        *ssa.Value
	rcvrNode    *Node
	argVals     []*ssa.Value
	argNodes    []*Node
}

当编译器在调用 cmd/compile/internal/gc.buildssaopen in new window 构建中间代码时会通过 cmd/compile/internal/gc.state.openDeferRecordopen in new window 方法在栈上构建结构体,该结构体的 closure 中存储着调用的函数,rcvr 中存储着方法的接收者,而最后的 argVals 中存储了函数的参数。

很多 defer 语句都可以在编译期间判断是否被执行,若函数中的 defer 语句都会在编译期间确定,中间代码生成阶段就会直接调用 cmd/compile/internal/gc.state.openDeferExitopen in new window 在函数返回前生成判断 deferBits 的代码,也就是上述伪代码中的后半部分。

不过当程序遇到运行时才能判断的条件语句时,我们仍然需要由运行时的 runtime.deferreturnopen in new window 决定是否执行 defer 关键字:

func deferreturn(arg0 uintptr) {
	gp := getg()
	d := gp._defer
	sp := getcallersp()
	if d.openDefer {
		runOpenDeferFrame(gp, d)
		gp._defer = d.link
		freedefer(d)
		return
	}
	...
}

该函数为开放编码做了特殊的优化,运行时会调用 runtime.runOpenDeferFrameopen in new window 执行活跃的开放编码延迟函数,该函数会执行以下的工作:

  1. runtime._deferopen in new window 结构体中读取 deferBits、函数 defer 数量等信息;
  2. 在循环中依次读取函数的地址和参数信息并通过 deferBits 判断该函数是否需要被执行;
  3. 调用 runtime.reflectcallSaveopen in new window 调用需要执行的 defer 函数;
func runOpenDeferFrame(gp *g, d *_defer) bool {
	fd := d.fd

	...
	deferBitsOffset, fd := readvarintUnsafe(fd)
	nDefers, fd := readvarintUnsafe(fd)
	deferBits := *(*uint8)(unsafe.Pointer(d.varp - uintptr(deferBitsOffset)))

	for i := int(nDefers) - 1; i >= 0; i-- {
		var argWidth, closureOffset, nArgs uint32 // 读取函数的地址和参数信息
		argWidth, fd = readvarintUnsafe(fd)
		closureOffset, fd = readvarintUnsafe(fd)
		nArgs, fd = readvarintUnsafe(fd)
		if deferBits&(1<<i) == 0 {
			...
			continue
		}
		closure := *(**funcval)(unsafe.Pointer(d.varp - uintptr(closureOffset)))
		d.fn = closure

		...

		deferBits = deferBits &^ (1 << i)
		*(*uint8)(unsafe.Pointer(d.varp - uintptr(deferBitsOffset))) = deferBits
		p := d._panic
		reflectcallSave(p, unsafe.Pointer(closure), deferArgs, argWidth)
		if p != nil && p.aborted {
			break
		}
		d.fn = nil
		memclrNoHeapPointers(deferArgs, uintptr(argWidth))
		...
	}
	return done
}

5.3.7 小结

defer 关键字的实现有三种机制:

  1. 堆上分配 · 1.1 ~ 1.12
  2. 栈上分配 · 1.13
  3. 开放编码 · 1.14

defer执行顺序和预先计算的原理:

  1. 后调用的 defer 函数会先执行:
  2. 函数的参数会被预先计算;

Reference

  1. https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-defer/open in new window
上次编辑于:
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.2