深入理解 Go defer 版本差异与底层特性

defer 简介

defer 的作用是使某个/某些执行延时执行,常用在资源使用后的释放等场景,例如:

func DbInit() (sql.DB, error) {
    db, err := sql.Open()
    if err != nil {
        return nil, err
    }
    defer db.Close()
    ...
}

defer 有一些熟知的特性:

  • “先进后出”,也就是说如果使用了多个 defer,最先使用 的 defer 后的函数体 最后执行
  • 一定会执行,即 panic 后依然有效。
  • 执行体参数在注册 defer 时确定。

我们先来看看为什么。

结构定义

先来看看标准库中 runtime/runtime2.defer 的定义。

type _defer struct {
	siz     int32	// 函数参数和返回值的大小
	started bool
	heap    bool	// 是否分配在堆上

	openDefer bool	// 是否经过开放编码的优化
	sp        uintptr  // defer函数体的栈顶指针
	pc        uintptr  // 下一条指令地址
	fn        *funcval // 执行函数地址,如果是开放编码可以为空
	_panic    *_panic  // 与之相关的panic链表
	link      *_defer	// defer链表

	...
}

除堆分配和开放编码相关字段之外,其余字段很容易理解。这两个特性是在 1.13 与 1.14 引入的,重点解决 defer 性能问题,放在下文。还有一些字段是关于 gc 的,理解较为复杂,暂不讨论。

除此之外,有两个字段比较重要,link_panic。前者将注册的许多 defer 串成链表,在执行的时候按预先定义的顺序访问,先恢复执行体函数所需的栈信息等操作,跳转到函数地址处执行;后者将 defer 与 panic 进行关联,确保发生 panic 后按序执行所有注册过的 defer 函数。

编译阶段会把 defer 关键字分为三种状态,分别是开放编码,栈分配,堆分配。

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)
			}
        ...
    }
    ...
}

早起的 Go (1.13 之前)会把 defer 所需内存全部分配在堆上,性能较差,在 1.13 时通过调用分析可选择分配在栈上以增加性能,提升了大约 30%,1.14 又增加了开放编码方式,使 defer 的调用损耗可以忽略不计(<10ms)。

现象背后的原因

先讨论表象背后的底层原理,即

版本差异

总结