Go 面试官常问:defer 语句的执行顺序与参数求值机制详解_go defer 参数
1.基本概念和作用
defer
语句的核心作用是延迟一个函数或方法的执行,直至其所在的函数即将返回之时 。
2.来由
1.原因(为什么go中要有defer)
defer
与其他语言的相似之处defer
最常被比作其他语言(Java, Python等)中的 finally
块。finally
块中的代码无论 try
块中的代码是正常执行完成、遇到 return
语句,还是抛出异常,都会被执行。
go语言鼓励采用显式的错误返回值来指示操作的成功或失败,这种显式的错误处理模式,结合Go语言中普遍存在的多返回路径(即函数可能在多个条件分支下提前返回),也带来了一个实际的挑战:在每个可能的返回点之前手动添加资源清理代码会变得异常繁琐且容易出错 。
2.有无defer的影响
没有defer
的情况下,如果一个函数需要打开文件、获取互斥锁或建立网络连接等资源,那么开发者必须在函数中所有可能的退出点(包括正常执行结束或因错误提前返回)手动插入相应的资源关闭或释放代码。
defer
关键字的引入,通过允许开发者在资源获取的代码附近立即声明其清理操作,确保了无论函数执行路径如何,清理代码都将在函数返回前得到执行 。例如,在文件操作中, defer file.Close()
语句紧跟在os.Open()
之后。
3.优势:
1.代码邻近性:
2.保证执行:
3.栈式执行(后进先出):
4.捕获 panic
:
5.减少代码冗余和提高可读性:
4.现象(后面针对这些问题进行讲解):
-
defer
关键字的调用时机以及多次调用defer
时执行顺序是如何确定的; -
defer
关键字使用传值的方式传递参数时会进行预计算,导致不符合预期的结果;
1.预计算参数
1.func main() {startedAt := time.Now()defer fmt.Println(time.Since(startedAt))time.Sleep(time.Second)}2.func main() {startedAt := time.Now()defer func() { fmt.Println(time.Since(startedAt)) }()time.Sleep(time.Second)}
运行结果
1.$ go run main.go 0s2.$ go run main.go 1s
经过分析,我们会发现调用
defer
关键字会立刻拷贝函数中引用的外部参数,所以time.Since(startedAt)
的结果不是在main
函数退出之前计算的,而是在defer
关键字调用时计算的,最终导致上述代码输出 0s。想要解决这个问题的方法非常简单,我们只需要向
defer
关键字传入匿名函数:虽然调用
defer
关键字时也使用值传递,但是因为拷贝的是函数指针,所 以time.Since(startedAt)
会在main
函数返回前调用并打印出符合预期的结果。总的来说就是:
defer
参数立即求值
defer
匿名函数延迟执行
2.值接收者与指针接收者在 defer 方法中的行为差异详解
在 Go 语言中,值接收者与指针接收者在 defer 方法调用中存在显著的行为差异,这些差异主要源于接收者的复制机制和延迟执行的特性
核心差异对比
5.工作机制
1.数据结构
type _defer struct {heap bool //表示是分配在堆上还是栈上。rangefunc bool // true for rangefunc listsp uintptr // 栈指针pc uintptr // 程序计数器fn func() // 表示需要被延迟执行的函数。link *_defer // 指向下一个 _defer 结构体的指针。// If rangefunc is true, *head is the head of the atomic linked list// during a range-over-func execution.head *atomic.Pointer[_defer]}
这是基于go1.23版本的defer结构
runtime._defer 结构体是延迟调用链表上的一个元素,所有的结构体都会通过 link
字段串联成链表。
我们简单介绍一下 runtime._defer 结构体中的几个字段:
sp
和pc
分别代表栈指针和调用方的程序计数器;fn
是defer
关键字中传入的函数;- heap 表示是分配在堆上还是栈上;
- link 指向下一个 _defer 结构体的指针;
2.执行机制
三种执行机制(三种不同的实现策略)
堆分配、栈分配和开放编码
一、堆分配、栈分配
核心:
-
运行时分配
_defer
结构体(堆/栈) -
维护 defer 链表
-
函数退出时遍历链表执行
这些操作导致 defer 有显著性能开销(约 35ns/次)。
区别:
分配位置的不同
获取到runtime_defer结构体,它都会被追加到所在 Goroutine
_defer
链表的最前面。
二、开放编码
执行条件:
在满足以下的条件时启用:
-
函数的
defer
数量少于或者等于 8 个; -
函数的
defer
关键字不能在循环中执行; -
函数的
return
语句与defer
语句的乘积小于或者等于 15 个;
核心:
在编译阶段直接将 defer 调用插入函数返回点,完全消除运行时管理开销
弊端:
开放编码是不支持recover的,因为开放编码没有运行时注册的 defer,因此无法支持 recover。
循环中不能使用:因为循环次数不确定
解决:
当函数包含 recover 时,编译器自动回退到栈分配:
func withRecover() { // 此 defer 使用栈分配 defer func() { if r := recover(); r != nil { fmt.Println(\"Recovered:\", r) } }() // 此 defer 使用开放编码 defer fmt.Println(\"Open-coded defer\") // ...}
实现:
一旦决定使用开放编码,会在编译期间在栈上初始化大小为 8 个比特的 deferBits
变量:延迟比特中的每一个比特位都表示该位对应的 defer
关键字是否需要被执行,如下图所示,其中 8 个比特的倒数第二个比特在函数返回前被设置成了 1,那么该比特位对应的函数会在函数返回前执行:
延迟比特的作用就是标记哪些 defer
关键字在函数中被执行,这样在函数返回时可以根据对应 deferBits
的内容确定执行的函数,而正是因为 deferBits
的大小仅为 8 比特,所以该优化的启用条件为函数中的 defer
关键字少于 8 个。
三、选择
首先考虑开放编码,后栈分配,保底堆分配
这是我在看其他文章中的图片,明确的体现出go中defer的执行机制选择
四、性能比较
基准测试数据
6.实际应用
文件,互斥锁,网络连接等资源的关闭
错误处理与状态恢复:
package mainimport (\"fmt\"\"log\")func g(i int) {if i > 1 {fmt.Println(\"Panicking!\")panic(1)}defer fmt.Println(\"Defer in g\", i)fmt.Println(\"Printing in g\", i)g(i + 1)}func f() {defer func() {if r := recover(); r != nil {log.Println(\"Recovered in f\", r)}}()fmt.Println(\"Calling g.\")g(0)fmt.Println(\"Returned normally from f.\")}func main() {f()}
运行结果:Calling g.Printing in g 0Printing in g 1Panicking!Defer in g 1Defer in g 02025/07/12 20:19:15 Recovered in f 1
这里主要体现了,defer在错误处理中的作用,以及defer的保证执行和栈式执行的特性;
7.小结
defer
关键字的实现主要依靠编译器和运行时的协作,总结一下本文章提到的三种机制(这里机制就不做细致讲解,大家有兴趣的可以去看下面的书,里面有做了详细的讲解):
堆上分配 · 1.1 ~ 1.12
栈上分配 · 1.13
开放编码 · 1.14 ~ 现在
我们在本文章前面提到的两个现象在这里也可以解释清楚了:
- 后调用的
defer
函数会先执行:- 后调用的
defer
函数会被追加到 Goroutine_defer
链表的最前面; - 运行 runtime._defer 时是从前到后依次执行;
- 后调用的
- 函数的参数会被预先计算;
- 调用 runtime.deferproc (go内部执行函数)函数创建新的延迟调用时就会立刻拷贝函数的参数,函数的参数不会等到真正执行时计算;
8.知识来源
很推荐大家去看一下这本关于go语言设计的这本书,文章内容也是我根据这本书和我的理解总结的;