专栏首页码农桃花源defer 链如何被遍历执行

defer 链如何被遍历执行

去年开始写文章的第一篇就是关于 defer,名字比较文艺:《Golang 之轻松化解 defer 的温柔陷阱》,还被吐槽了。因为这篇文章,到《Go 夜读》讲了一期。不过当时纯粹是应用层面的,也还没有跳进 Go 源码这个大坑,文章看着比较清新,也没有大段的源码解析。

自从听了曹大在《Go 夜读》分享的 Go 汇编,以及研读了阿波张的 Go 调度器源码分析的文章后,各种源码、汇编满天飞……

上次欧神写了一篇《Go GC 20 问》,全文也没有一行源码,整体读下来很畅快。今天这篇也来尝试一下这种写法,不过,我们先从一个小的主题开始:defer 链表是如何被遍历并执行的。

关于 defer 的源码分析文章,网络上也有很多。不过,很少有能完全说明白这个话题的,除了阿波张的。

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

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

defer 挂在 g 上

在编译器的加持下,defer 语句会先调用 deferporc 函数,new 一个 _defer 结构体,挂到 g 上。当然,这里的 new 会优先从当前绑定的 P 的 defer pool 里取,没取到会去全局的 defer pool 里取,实在没有的话就新建一个,很熟悉的套路。

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

答案就是控制权会再次交给 runtime,并再次执行 deferreturn 函数,完成 defer 链表的遍历。那这一切是如何完成的呢?

这就要从 Go 汇编的栈帧说起了。先看一个汇编函数的声明:

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

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

// func gogo(buf *gobuf)

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

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

函数调用参数布局

左图中,主调函数准备好调用子函数的参数及返回值,执行 CALL 指令,将返回地址压入栈顶,相当于执行了 PUSH IP,之后,将 BP 寄存器的值入栈,相当于执行了 PUSH BP,再 jmp 到被调函数。

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

子函数执行完毕后,执行 RET 指令:首先将子函数栈底部的值赋到 CPU 的 BP 寄存器中,于是 BP 指向上层函数的 BP;再将 return address 赋到 IP 寄存器中,这时 SP 回到左图所示的位置。相当于还原了整个调用子函数的现场,像是一切都没发生过;接着,CPU 继续执行 IP 寄存器里的下一条指令。

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

最后,在 deferreturn 函数里,这些被 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 函数:

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 函数里 copy 过去的。deferArgs(d) 返回的就是当时 copy 的目的地址。那现在要拷贝到哪去呢?答案是:unsafe.Pointer(&arg0)。我们知道,arg0 是 deferreturn 函数的参数,我们又知道,在 Go 汇编中,一个函数的参数是由它的主调函数准备的。因此 arg0 的地址实际上就是它的上层函数(在这里就是 f 函数)的栈上放参数的位置。

函数的最后,通过 jmpdefer 跳转到被 defered 的 sum 函数:

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

核心在于 jmpdefer 所做的事:

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	// caller sp after CALL // 执行 CALL 指令后 f 函数的栈顶

这两行实际上是调整了下当前 SP 寄存器的值,因为 argp+8(FP) 实际上是 jmpdefer 的第二个参数(它在 deferreturn 函数中),它指向 f 函数栈帧中的刚被 copy 过来的 sum 函数的参数。而 -8(BX) 就代表了 f 函数调用 deferreturn 的返回地址,实际上就是 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 函数的效果。当然,栈却不会在增长!

执行 jmpdefer

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

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

重新调用 deferreturn

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

参考资料

【阿波张 defer 源码分析】https://mp.weixin.qq.com/s/iEtMbRXW4yYyCG0TTW5y9g

【阿波张 panic&recover】https://mp.weixin.qq.com/s/0JTBGHr-bV4ikLva-8ghEw

【阿波张 defer 基础】https://mp.weixin.qq.com/s/QmeQTONUuWlr_sRNP8b5Tw

【汇编分析】https://segmentfault.com/a/1190000019804120?utm_medium=referral&utm_source=tuicool

【曹大 Go 汇编分享】https://github.com/cch123/asmshare/blob/master/layout.md

【曹大 Go 汇编】https://xargin.com/plan9-assembly

【曹大利用汇编写的 goid 获取】https://github.com/cch123/goroutineid

本文分享自微信公众号 - 码农桃花源(CoderPark),作者:饶全成

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-03-23

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • defer 的前世今生

    延迟语句 defer 在最早期的 Go 语言设计中并不存在,后来才单独增加了这一特性,由 Robert Griesemer 完成语言规范的编写 [Griesem...

    梦醒人间
  • Golang之轻松化解defer的温柔陷阱

    defer是Go语言提供的一种用于注册延迟调用的机制:让函数或语句可以在当前函数执行完毕后(包括通过return正常结束或者panic导致的异常结束)执行。深受...

    梦醒人间
  • Go Modules 的智障版本选择

    之前 go mod 用的比较少,而且一直听社区有各种抱怨,所以也兴趣寥寥。新公司的项目直接使用了 go mod,本来觉得无非是个简单的工具,不需要学习,结果在一...

    梦醒人间
  • 箭头函数

    箭头函数不绑定this关键字,箭头函数中的this,指向的是函数定义位置的上下文this

    清出于兰
  • 中国台湾大学林轩田机器学习基石课程学习笔记5 -- Training versus Testing

    上节课,我们主要介绍了机器学习的可行性。首先,由NFL定理可知,机器学习貌似是不可行的。但是,随后引入了统计学知识,如果样本数据足够大,且hypothesis个...

    红色石头
  • Spring AOP

    Spring AOP 使用场景蛮多的属性检查、日志等,所有拦截下来可以在切面共同做的事儿似乎都可以用AOP(面向切面)的方式解决。在面试的过程中AOP 也是除I...

    邹志全
  • 谷歌提出新分类损失函数:将噪声对训练结果影响降到最低

    训练数据集里的标签通常不会都是正确的,比如图像分类,如果有人错误地把猫标记成狗,将会对训练结果造成不良的影响。

    代码医生工作室
  • Java每日一题_关于SpringAOP

    AOP的概念是Aspected Oriented Programming 面向方切编程。

    Java学习
  • AOP结构图(术语图解)

  • 如何解释Event Loop面试官才满意?

    想要了解JavaScript引擎,首先我们从它的运行机制Event Loop来说起。

    童欧巴

扫码关注云+社区

领取腾讯云代金券