@注: 以下内容来自《Go语言底层原理剖析》、《Go 语言设计与实现》书中的摘要信息,本人使用版本(
Go1.18
)与书中不一致,源码路径可能会有出入。
1.介绍
defer
是Go
语言中的关键字,也是Go
语言的重要特性之一。defer
的语法形式为defer 函数
,其后必须紧跟一个函数调用或者方法调用。在很多时候,defer
后的函数以匿名函数或闭包的形式呈现,例如:
|
2. 特性
2.1 延迟执行
defer
后的函数并不会立即执行,而是推迟到了函数结束后执行,示例如下:
|
2.2 先进后出
在函数体内部,可能出现多个defer
函数。这些defer
函数将按照后入先出(last-in first-out,LIFO
)的顺序执行,这与栈的执行顺序是相同的。
|
2.3 参数预计算
defer
的另一个特性是参数的预计算,这一特性时常导致开发者在使用defer
时犯错。因为在大部分时候,我们记住的都是其延迟执行的特性。参数的预计算指当函数到达defer语句时,延迟调用的参数将立即求值,传递到defer函数中的参数将预先被固定,而不会等到函数执行完成后再传递参数到defer中。看下面代码示例:
|
代码分析:
- b = 2: 当函数执行到第一个
defer
时,把a
当参数传递defer
函数后参数将预先被固定,此时的结果已经求出b=1+1
,等待输出。 - c = 100: 当执行到第二个
defer
时,并没有把a
传到defer
函数中,由于defer
的延迟特性,要等函数结束后才能执行,函数结束后时,a
被赋值为99,所以c
计算的结果是99 +1 = 100
3. 常见用途
3.1 资源释放
利用defer
的延迟执行特性,defer
一般用于资源的释放和异常的捕获,作为Go
语言的特性之一,defer
给Go
代码的书写方式带来了很大的变化。下面的CopyFile
函数用于将文件srcName
的内容复制到文件dstName
中。
|
除了上面常见的操作文件资源以外,defer
还常用于锁和锁的释放,实例代码如下:
|
通过上面代码可以看出,使用
defer
后,代码的可读性更好,而且不会因为逻辑复杂而忘了解锁,导致死锁的情况。
3.2 异常捕获
|
程序在运行时,任意地方都可能会发生
panic
异常,例如算术除0错误、内存无效访问、数组越界等,一旦发生panic
异常,就会导致整个程序异常退出,这不是我们想见到的结果,通常我们希望程序能够继续正常执行,其他编程语言会提供try..catch
的语法,但go
不支持try..catch
的语法,只能通过defer + recover
来实现;
4. 返回值陷阱
除了前面提到的参数预计算,defer
还有一种非常容易犯错的场景: 涉及与返回值参数结合。
4.1 先看示例
|
运行输出如下:
|
4.2 defer、return、返回值
在讲三者的执行顺序前,先了解下return
返回值的运行机制,return xx
并非原子操作,在编译后会被分为赋值、返回值两个指令。
当与defer
结合使用时,三者的执行顺序:
return 赋值
最先执行,即先将结果写入返回值中;- 接着
defer
开始执行一些收尾工作; - 最后函数携带当前返回值退出(即返回值)。
所以结论是:第一步先return赋值,第二步再执行defer,第三步执行空的return。但是在有名与无名的函数返回值的情况下会有些区别:
4.3 无名函数返回
如果函数的返回值是无名的(不带命名返回值)如上例中的f2()
,则go
语言会在执行return
指令时,创建一个临时变量保存返回值,最后返回。结合f2
理解如下:
|
a.分析下代码运行:
上例代码一共执行3步操作:
return 赋值:因为返回值没有命名,所以
return
默认指定了一个返回值(假设为s
),实际运行可以理解如下:n := 1
// 这里的s指的是临时变量
s := n
- **`defer`操作:** 后续的`defer`操作都是针对`n`进行的,并不会影响返回值`s`,所以`s`始终都是等于`1`.
- **空return返回**:大部分人都会被`return n`给误导,明明返回的是`n`,为什么最后结果不对呢,实际上最后的返回`rentun n`最后会变成`return`。用代码理解如下:
```go
// 定义临时变量s,s是最终返回值
var s int
// 赋值
n := 1
s = n
return s
4.4 有名函数返回
有名返回值的函数,由于返回值变量已经提前定义,所以运行过程中并不会再创建临时变量,后续defer
操作的变量都是返回值变量,结合f1
理解如下:
|
5. 数据结构
5.1 字段释义
defer
关键字在 Go
语言源代码中对应的数据结构如下:
|
5.2 串成链表
runtime._defer
结构体是延迟调用链表上的一个元素,所有的结构体都会通过link
字段串联成链表。
6. 执行机制
6.1 三种机制
中间代码生成阶段的 cmd/compile/internal/gc.state.stmt
会负责处理程序中的 defer
,该函数会根据条件的不同,使用三种不同的机制处理该关键字:
|
堆分配、栈分配和开放编码是处理
defer
关键字的三种机制,早期的Go
语言会在堆上分配runtime._defer
结构体,不过该实现的性能较差,Go
语言在1.13
中引入栈上分配的结构体,减少了 30% 的额外开销,并在1.14
中引入了基于开放编码的defer
,使得该关键字的额外开销可以忽略不计。
6.2 堆分配
从上述源码可以看出:堆分配是默认的兜底执行方案,当该方案被启用时,编译器不仅将 defer
关键字都转换成 runtime.deferproc
函数,它还会通过以下三个步骤为所有调用 defer
的函数末尾插入 runtime.deferreturn
的函数调用:
- 步骤一: 在遇到
ODEFER
节点时会执行Curfn.Func.SetHasDefer(true)
设置当前函数的hasdefer
属性; 实现代码位置:cmd/compile/internal/gc.walkstmt
- 步骤二: 执行
s.hasdefer = fn.Func.HasDefer()
更新state
的hasdefer
;实现代码位置:cmd/compile/internal/gc.buildssa
- 步骤三: 根据
state
的hasdefer
在函数返回之前插入runtime.deferreturn
的函数调用;实现代码位置:cmd/compile/internal/gc.state.exit
deferproc
和deferreturn
runtime.deferproc
负责创建新的延迟调用;runtime.deferreturn
负责在函数调用结束时执行所有的延迟调用;
6.2.1 申请_defer
机制
runtime.deferproc
中 runtime.newdefer
的作用是想尽办法获得 runtime._defer
结构体,这里包含三种路径:
- 从全局缓存池
sched.deferpool
中取出结构体并将该结构体追加到当前逻辑处理器P
局部缓存池中; - 从逻辑处理器
P
局部缓存池P.deferpool
中取出结构体; - 通过
runtime.mallocgc
在堆上创建一个新的结构体;
- 当
defer
执行完毕被销毁后,会重新回到局部缓存池中;- 当局部缓存池容纳了足够的对象时,会将
_defer
结构体放入全局缓存池。- 存储在全局和局部缓存池中的对象如果没有被使用,则最终在垃圾回收阶段被销毁。
6.3 栈分配
从defer
堆分配的过程可以看出,即便有全局和局部缓存池策略,由于涉及堆与栈参数的复制等操作,堆分配仍然比直接调用效率低下。
Go
语言团队在 1.13
中对 defer
关键字进行了优化,当该关键字在函数体中最多执行一次时,编译期间的 cmd/compile/internal/gc.state.call
会将结构体分配到栈上并调用 runtime.deferprocStack
。
因为在编译期间我们已经创建了 runtime._defer
结构体,所以在运行期间 runtime.deferprocStack
只需要设置一些未在编译期间初始化的字段,就可以将栈上的 runtime._defer
追加到函数的链表上:
|
除了分配位置的不同,栈上分配和堆上分配的
runtime._defer
并没有本质的不同,而该方法可以适用于绝大多数的场景,与堆上分配的runtime._defer
相比,该方法可以将defer
关键字的额外开销降低 ~30%。
6.4 开放编码
Go
语言在 1.14
中通过开放编码(Open Coded
)实现 defer
关键字,该设计使用代码内联优化 defer
关键的额外开销并引入函数数据 funcdata
管理 panic
的调用,该优化可以将 defer
的调用开销从 1.13
版本的 ~35ns
降低至 ~6ns
左右:
|
然而开放编码作为一种优化 defer
关键字的方法,它不是在所有的场景下都会开启的,开放编码只会在满足以下的条件时启用:
- 函数的
defer
数量少于或者等于 8 个; - 函数的
defer
关键字不能在循环中执行; - 函数的
return
语句与defer
语句的乘积小于或者等于15
个;
初看上述几个条件可能会觉得不明所以,但是当我们深入理解基于开放编码的优化就可以明白上述限制背后的原因。
6.4.1 defer <=8
Go
语言会在编译期间就确定是否启用开放编码,在编译器生成中间代码之前,我们会使用 cmd/compile/internal/gc.walkstmt
修改已经生成的抽象语法树,设置函数体上的 OpenCodedDeferDisallowed
属性:
|
通过上述源码可以发现: 如果函数中
defer
关键字的数量多于 8 个或者defer
关键字处于for
循环中,那么我们在这里都会禁用开放编码优化
6.4.2 retun语句 * defer < 15
在 SSA
中间代码生成阶段的 cmd/compile/internal/gc.buildssa
中,我们也能够看到启用开放编码优化的其他条件,也就是返回语句的数量与 defer
数量的乘积需要小于 15:
|