详解go中的defer链如何被遍历执行

 更新时间:2024年01月16日 10:45:02   作者:余尘雨晨  
为了在退出函数前执行一些资源清理的操作,例如关闭文件、释放连接、释放锁资源等,会在函数里写上多个defer语句,多个_defer 结构体形成一个链表,G 结构体中某个字段指向此链表,那么go中的defer链如何被遍历执行,本文将给大家详细的介绍,感兴趣的朋友可以参考下

go中的defer 链如何被遍历执行

为了在退出函数前执行一些资源清理的操作,例如关闭文件、释放连接、释放锁资源等,会在函数里写上多个 defer 语句,被 defered 的函数,以“先进后出”的顺序,在 RET 指令前得以执行。

在一条函数调用链中,多个函数中会出现多个 defer 语句。例如:a()→b()→c() 中,每个函数里都有 defer 语句,而这些 defer 语句会创建对应个数的 _defer 结构体,这些结构体以链表的形式“挂”在 G 结构体下。看起来像这样,如图 2-1 所示。

多个 _defer 结构体形成一个链表,G 结构体中某个字段指向此链表。在编译器的“加持下”,defer 语句会先调用deferporc 函数,new 一个_defer 结构体,挂到 G 上。当然,调用 new 之前会优先从当前 G 所绑定的 P 的 defer pool 里取,没取到则会去全局的defer pool 里取,实在没有的话才新建一个。这是 Go runtime 里非常常见的操作,即设置多级缓存,提升运行效率。

在执行 RET 指令之前(注意不是 return 之前),调用 deferreturn 函数完成 _defer 链表的遍历,执行完这条链上所有被 defered 的函数(如关闭文件、释放连接、释放锁资源等)。在deferreturn 函数的最后,会使用 jmpdefer 跳转到之前被 defered 的函数,这时控制权从 runtime 转移到了用户自定义的函数。这只是执行了一个被 defered 的函数,那这条链上其他的被 defered 的函数,该如何得到执行?

答案就是控制权会再次交给 runtime,并再次执行 deferreturn 函数,完成 defer 链表的遍历。那这一切是如何完成的?
这就要从 Go 汇编的栈帧说起了。先看一个汇编函数的声明:

TEXT runtime·gogo(SB), NOSPLIT, $16-8

最后两个数字分别表示:①gogo 函数的栈帧大小为 16B,即函数的局部变量和为调用子函数准备的参数、返回值共需要 16B 的栈空间;②参数和返回值的大小加起来是 8B。实际上,gogo 函数的声明是这样的:

// func gogo(buf *gobuf)

参数及返回值的大小是给调用者“看”的,调用者根据这个数字可以构造栈:准备好被调函数需要的参数及返回值。

典型的函数调用场景下参数布局图如图 2-2 所示:

● 图 2-2 函数调用参数布局

图 2-2 中左半部分,主调函数准备好调用子函数的参数及返回值,执行 CALL 指令,将返回地址 return address 压入栈顶,相当于执行了 PUSH IP。之后,将 BP 寄存器的值入栈,相当于执行了 PUSH BP,再 JMP 到被调函数。BP 指的是栈基址指针,SP 是指栈顶指针。

图中 return address 表示子函数执行完毕后,返回到上层函数中调用子函数语句的下一条要执行的指令,它属于 caller 的栈帧;而调用者的 BP 则属于被调函数的栈帧。

子函数执行完毕后,执行 RET 指令:首先将子函数栈底部的值(图 2-2 中的调用者的 BP)赋到 CPU 的 BP 寄存器中,于是 BP 指向上层函数的 BP;再将 return address 赋到 IP 寄存器中,这时 SP 回到左图所示的位置。相当于还原了整个调用子函数的现场,好像一切都没发生过,而实际上“调用子函数的返回值”已经被填充上了正确的值,例如两个数相加的结果;接着,CPU 继续执行 IP 寄存器里的下一条指令。

再回到 defer 上来,其实在构造 _defer 结构体的时候,需要将当前函数的 SP、被 defered 的函数指针保存到 _defer 结构体中。并且会将被 defered 的函数所需要的参数复制到和 _defer 结构体相邻的位置。最终在调用被 defered 的函数的时候,用的就是这时被复制的值,相当于使用了它的一个“快照”,如果此参数不是指针或引用类型的话,会产生一些意料之外的 bug。

最后,在 deferreturn 函数里,遍历 _defer 链表,这些被 defered 的函数得以执行,_defer 链表也会被逐渐“消耗”完。

来看一个例子:

package main

import "fmt"

func sum(a, b int) {
	c := a + b
	fmt.Println("sum:", c)
}
func f(a, b int) {
	defer sum(a, b)
	fmt.Printf("a: %d, b: %d\n", a, b)
}
func main() {
	a, b := 1, 2
	f(a, b)
}

执行完 f 函数时,最终会进入 deferreturn 函数:

// src/runtime/panic.go
func deferreturn(arg0 uintptr) {
	gp := getg()
	d := gp._defer
	if d == nil {
	return
	}

......

	switch d.siz {
		case 0:
		// Do nothing.
		case sys.PtrSize:
		*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
		default:
		memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz)) // 移动参数
	}

	fn := d.fn
	d.fn = nil
	gp._defer = d.link
	freedefer(d)

	_ = fn.fn
	jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}

因为是在遍历 _defer 链表,所以得加入一个终止的条件:

d := gp._defer 
if d == nil { 
   return 
}

当 _defer 链表为空的时候,终止遍历。在后面的代码里会看到,每执行完一个被 defered 的函数后,都会将 _defer 结构体从链表中删除并回收,所以 _defer 链表会越来越短,直至为空。

函数中 switch 语句里要做的就是准备好被 defered 的函数(例子中就是 sum 函数)所需要的a、b 两个 int 型参数。参数从哪来?从 _defer 结构体相邻的位置,还记得吗,这是在 deferproc 函数里复制过去的。deferArgs(d) 返回的就是当时复制的目的地址。那要复制到哪去?答案是:unsafe.Pointer(&arg0)。因为,arg0 是 deferreturn 函数的参数,而在 Go 汇编中,一个函数的参数是由它的主调函数准备的。因此 arg0 的地址实际上就是它的上层函数(在这里就是 f 函数)的栈上放调用子函数参数的位置,回忆一下前面讲函数栈帧的图。函数的最后,通过 jmpdefer 跳转到被 defered 的 sum 函数:

jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))

这里 fn 就是 sum 函数。核心在于 jmpdefer 所做的事:

// src/runtime/asm_amd64.s 
TEXT runtime·jmpdefer(SB), NOSPLIT, $0-16 
 MOVQ fv+0(FP), DX // fn,defer 的函数的地址
 MOVQ argp+8(FP), BX 
 LEAQ -8(BX), SP // caller sp after CALL 
 MOVQ -8(SP), BP // restore BP as if deferreturn returned (harmless if framepointers not in use) 
 SUBQ $5, (SP) // return to CALL again 
 MOVQ 0(DX), BX 
 JMP BX // but first run the deferred function

首先将 sum 函数的地址放到 DX 寄存器中,最后通过 JMP 指令去执行。

MOVQ argp+8(FP), BX 
LEAQ -8(BX), SP // 执行 CALL 指令后 f 函数的栈顶

这两行实际上是调整了下当前 SP 寄存器的值,因为 argp+8(FP) 实际上是 jmpdefer 的第二个参数(它在 deferreturn 函数中),它指向 f 函数栈帧中的刚被复制过来的 sum 函数的参数。而 -8(BX) 就代表了 f 函数调用 deferreturn 的返回地址,实际上就是 f 调用 deferreturn 函数的下一条指令地址。

接着,MOVQ -8(SP), BP 这条指令重置了 BP 寄存器,使它指向了 f 栈帧 的 BP。这样,SP、BP 寄存器回到了 f 函数调用 deferreturn 之前的状态:f 刚准备好调用 deferreturn 的参数,并且把返回值压栈了。也就相当于抛弃了 deferreturn 函数的栈帧。

接着 SUBQ $5, (SP) 把返回地址减少了 5B,刚好是一个 CALL 指令的长度。什么意思?当执行完 deferreturn 函数之后,执行流程会返回到 CALL deferreturn 的下一条指令,将这个值减少 5B,也就又回到了 CALL deferreturn 指令,从而实现了“递归地”调用 deferreturn 函数的效果。当然,栈却不会再增长。

上面所描述的整个过程,结合图 2-3 会更容易理解: 

jmpdefer 函数的最后会执行 sum 函数,看起来就像是 f 函数直接调用 sum 函数一样,参数、返回值都是就绪的。

● 图 2-3 执行 jmpdefer

等到 sum 函数执行完,执行流程就会跳转到 call deferreturn 指令处重新进入 deferreturn 函数,遍历完所有的_defer 结构体,执行完所有的被 defered 的函数,才真正执行完 deferretrun 函数,如图 2-4 所示。

● 图 2-4 重新调用 deferreturn

综上所述,实现遍历 defer 链表的关键就是 jmpdefer 函数所做的一些“见不得人”的工作,将调用 deferreturn 函数的返回地址减少了 5 个字节,使得被 defered 的函数执行完后,又回到CALL deferreturn 指令处,从而实现“递归地”调用 deferreturn 函数,完成 _defer 链表的遍历。

以上就是详解go中的defer链如何被遍历执行的详细内容,更多关于go defer链遍历执行的资料请关注脚本之家其它相关文章!

相关文章

  • Go语言指针使用分析与讲解

    Go语言指针使用分析与讲解

    这篇文章主要介绍了Go语言指针使用分析与讲解,本篇文章通过简要的案例,讲解了该项技术的了解与使用,以下就是详细内容,需要的朋友可以参考下
    2021-07-07
  • 详解Go语言中调度器的原理与使用

    详解Go语言中调度器的原理与使用

    这篇文章主要介绍了Go语言运行时调度器的实现原理,其中包含调度器的设计与实现原理、演变过程以及与运行时调度相关的数据结构,希望对大家有所帮助
    2023-07-07
  • Go语言题解LeetCode35搜索插入位置示例详解

    Go语言题解LeetCode35搜索插入位置示例详解

    这篇文章主要为大家介绍了Go语言题解LeetCode35搜索插入位置示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-12-12
  • golang有用的库及工具 之 zap.Logger包的使用指南

    golang有用的库及工具 之 zap.Logger包的使用指南

    这篇文章主要介绍了golang有用的库及工具 之 zap.Logger包的使用指南,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-12-12
  • Go框架三件套Gorm Kitex Hertz基本用法与常见API讲解

    Go框架三件套Gorm Kitex Hertz基本用法与常见API讲解

    这篇文章主要为大家介绍了Go框架三件套Gorm Kitex Hertz的基本用法与常见API讲解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪<BR>
    2023-02-02
  • Golang小数操作指南之判断小数点位数与四舍五入

    Golang小数操作指南之判断小数点位数与四舍五入

    这篇文章主要给大家介绍了关于Golang小数操作指南之判断小数点位数与四舍五入的相关资料,文中通过实例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2022-03-03
  • go语言在请求http时加入自定义http header的方法

    go语言在请求http时加入自定义http header的方法

    这篇文章主要介绍了go语言在请求http时加入自定义http header的方法,实例分析了Go语言http请求的原理与操作技巧,需要的朋友可以参考下
    2015-03-03
  • 一文搞懂Go语言中条件语句的使用

    一文搞懂Go语言中条件语句的使用

    这篇文章主要介绍了Go语言中五个常用条件语句的使用,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2022-04-04
  • go语言中http超时引发的事故解决

    go语言中http超时引发的事故解决

    我们使用的是golang标准库的http client,对于一些http请求,我们在处理的时候,会考虑加上超时时间,如果超时可能会引起报错,本文就记一次超时引发的事故
    2021-06-06
  • Golang 数据库操作(sqlx)和不定字段结果查询

    Golang 数据库操作(sqlx)和不定字段结果查询

    本文主要介绍了Golang 数据库操作(sqlx)和不定字段结果查询,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-09-09

最新评论