【ArchSummit架构师峰会】探讨数据与人工智能相互驱动的关系>>> 了解详情
写点什么

理解 Go 语言 defer 关键字的原理

  • 2019-12-03
  • 本文字数:4735 字

    阅读完需:约 16 分钟

理解 Go 语言 defer 关键字的原理

4.3 defer

现在很多现代的编程语言中其实都有用于在作用域结束之后执行函数的关键字,Go 语言中的 defer 就可以用来实现这一功能,它的主要作用就是在当前函数或者方法返回之前调用一些用于收尾的函数,例如关闭文件描述符、关闭数据库连接以及解锁资源。


在这一节中我们就会深入 Go 语言的源代码介绍 defer 关键字的实现原理,相信阅读完这一节的读者都会对 defer 的结构、实现以及调用过程有着非常清晰的认识和理解。

__1. 概述

作为一个编程语言中的关键字,defer 的实现一定是由编译器和运行时共同完成的,不过在深入源码分析它的实现之前我们还是需要了解一些 defer 关键字的常见使用场景以及一些使用时的注意事项。

__1.1. 常见使用

首先要介绍的就是使用 defer 最常见的场景,也就是在 defer 关键字中完成一些收尾的工作,例如在 defer 中回滚一个数据库的事务:


func createPost(db *gorm.DB) error {    tx := db.Begin()    defer tx.Rollback()
if err := tx.Create(&Post{Author: "Draveness"}).Error; err != nil { return err }
return tx.Commit().Error}
复制代码


在使用数据库事务时,我们其实可以使用如上所示的代码在创建事务之后就立刻调用 Rollback 保证事务一定会回滚,哪怕事务真的执行成功了,那么在调用 tx.Commit() 之后再执行 tx.Rollback() 其实也不会影响已经提交的事务。

__1.2. 作用域

当我们在一个 for 循环中使用 defer 时也会在退出函数之前执行其中的代码,下面的代码总共调用了五次 defer 关键字:


func main() {    for i := 0; i < 5; i++ {        defer fmt.Println(i)    }}
$ go run main.go43210
复制代码


运行上述代码时其实会倒序执行所有向 defer 关键字中传入的表达式,最后一次 defer 调用其实使用了 fmt.Println(4) 表达式,所以会被优先执行并打印;我们可以通过另一个简单的例子,来强化理解一下 defer 执行的时机:


func main() {    {        defer fmt.Println("defer runs")        fmt.Println("block ends")    }
fmt.Println("main ends")}
$ go run main.goblock endsmain endsdefer runs
复制代码


从上述代码的输出我们会发现,defer 并不是在退出当前代码块的作用域时执行的,defer 只会在当前函数和方法返回之前被调用

__1.3. 传值

Go 语言中所有的函数调用其实都是值传递的,defer 虽然是一个关键字,但是也继承了这个特性,假设我们有以下的代码,在运行这段代码时会打印出 0


type Test struct {    value int}
func (t Test) print() { println(t.value)}
func main() { test := Test{} defer test.print() test.value += 1}
$ go run main.go0
复制代码


这其实表明当 defer 调用时其实会对函数中引用的外部参数进行拷贝,所以 test.value += 1 操作并没有修改被 defer 捕获的 test 结构体,不过如果我们修改 print 函数签名的话,其实结果就会稍有不同:


type Test struct {    value int}
func (t *Test) print() { println(t.value)}
func main() { test := Test{} defer test.print() test.value += 1}
$ go run main.go1
复制代码


这里再调用 defer 关键字时其实也是进行的值传递,只是发生复制的是指向 test 的指针,我们可以将 test 变量理解成 print 函数的第一个参数,在上一段代码中这个参数的类型是结构体,所以会复制整个结构体,而在这段代码中,拷贝的其实是指针,所以当我们修改 test.value 时,defer 捕获的指针其实就能够访问到修改后的变量了。

__2. 实现原理

作者相信各位读者哪怕之前对 defer 毫无了解,到了这里也应该对它的使用、作用域以及常见问题有了一些基本的了解,这一节中我们将从三个方面介绍 defer 关键字的实现原理,它们分别是 defer 关键字对应的数据结构、编译器对 defer 的处理和运行时函数的调用。

__2.1. 结构

在介绍 defer 函数的执行过程与实现原理之前,我们首先来了解一下 defer 关键字在 Go 语言中存在的结构和形式,


type _defer struct {    siz     int32    started bool    sp      uintptr    pc      uintptr    fn      *funcval    _panic  *_panic    link    *_defer}
复制代码


_defer 结构中的 sppc 分别指向了栈指针和调用方的程序计数器,fn 存储的就是向 defer 关键字中传入的函数了。

__2.2. 编译期间

defer 关键字是在 Go 语言编译期间的 SSA 阶段才被 stmt 函数处理的,我们能在 stmt 中的 switch/case 语句中找到处理 ODEFER 节点的相关逻辑,可以看到这段代码其实调用了 call 函数,这表示 defer 在编译器看来也是一次函数调用,它们的处理逻辑其实也是差不多的。


func (s *state) stmt(n *Node) {    switch n.Op {    case ODEFER:        s.call(n.Left, callDefer)    }}
复制代码


被调用的 call 函数其实负责了 Go 语言中所有函数和方法调用的 中间代码生成,它的工作主要包括以下内容:


  1. 获取需要执行的函数名、闭包指针、代码指针和函数调用的接收方;

  2. 获取栈地址并将函数或者方法的参数写入栈中;

  3. 使用 newValue1A 以及相关函数生成函数调用的中间代码;

  4. 如果当前调用的『函数』是 defer,那么就会单独生成相关的结束代码块;

  5. 最后会获取函数的返回值地址并结束当前方法的调用;


由于我们在这一节中主要关注的内容其实就是 defer 最终调用了什么方法,所以在这里删除了函数中不相关的内容:


func (s *state) call(n *Node, k callKind) *ssa.Value {    //...    var call *ssa.Value    switch {    case k == callDefer:        call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, deferproc, s.mem())    // ...    }    call.AuxInt = stksize    s.vars[&memVar] = call    // ...}
复制代码


deferproc 就是 defer 关键字在运行期间会调用的函数,这个函数接收了两个参数,分别是参数的大小和闭包所在的地址。


除了将所有 defer 关键字的调用都转换成 deferproc 的函数调用之外,Go 语言的编译器其实还在 SSA 中间代码生成期间,为所有调用 defer 的函数末尾插入了调用 deferreturn 的语句,这一过程的实现其实分成三个部分


  1. 首先 walkstmt 函数在遇到 ODEFER 节点时会通过 Curfn.Func.SetHasDefer(true) 表达式设置当前函数的 hasdefer 属性;

  2. SSA 中间代码生成阶段调用的 buildssa 函数其实会执行 s.hasdefer = fn.Func.HasDefer() 语句更新 statehasdefer 属性;

  3. 最后在 exit 中会插入 deferreturn 的函数调用;


func (s *state) exit() *ssa.Block {    if s.hasdefer {        s.rtcall(Deferreturn, true, nil)    }
// ...}
复制代码


在 Go 语言的编译期间,编译器不仅将 defer 转换成了 deferproc 的函数调用,还在所有调用 defer 的函数结尾(返回之前)插入了 deferreturn,接下来我们就需要了解 Go 语言的运行时都做了什么。

__2.3. 运行时

每一个 defer 关键字都会被转换成 deferproc,在这个函数中我们会为 defer 创建一个新的 _defer 结构体并设置它的 fnpcsp 参数,除此之外我们会将 defer 相关的函数都拷贝到紧挨着结构体的内存空间中:


func deferproc(siz int32, fn *funcval) {    sp := getcallersp()    argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)    callerpc := getcallerpc()
d := newdefer(siz) if d._panic != nil { throw("deferproc: d.panic != nil after newdefer") } d.fn = fn d.pc = callerpc d.sp = sp switch siz { case 0: case sys.PtrSize: *(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp)) default: memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz)) }
return0()}
复制代码


上述函数最终会使用 return0 返回,这个函数的主要作用就是避免在 deferproc 函数中使用 return 返回时又会导致 deferreturn 函数的执行,这也是唯一一个不会触发 defer 的函数了。


deferproc 中调用的 newdefer 主要作用就是初始化或者取出一个新的 _defer 结构体:


func newdefer(siz int32) *_defer {    var d *_defer    sc := deferclass(uintptr(siz))    gp := getg()    if sc < uintptr(len(p{}.deferpool)) {        pp := gp.m.p.ptr()        if len(pp.deferpool[sc]) == 0 && sched.deferpool[sc] != nil {            lock(&sched.deferlock)            for len(pp.deferpool[sc]) < cap(pp.deferpool[sc])/2 && sched.deferpool[sc] != nil {                d := sched.deferpool[sc]                sched.deferpool[sc] = d.link                d.link = nil                pp.deferpool[sc] = append(pp.deferpool[sc], d)            }            unlock(&sched.deferlock)        }        if n := len(pp.deferpool[sc]); n > 0 {            d = pp.deferpool[sc][n-1]            pp.deferpool[sc][n-1] = nil            pp.deferpool[sc] = pp.deferpool[sc][:n-1]        }    }    if d == nil {        total := roundupsize(totaldefersize(uintptr(siz)))        d = (*_defer)(mallocgc(total, deferType, true))    }    d.siz = siz    d.link = gp._defer    gp._defer = d    return d}
复制代码


从最后的一小段代码我们可以看出,所有的 _defer 结构体都会关联到所在的 Goroutine 上并且每创建一个新的 _defer 都会追加到协程持有的 _defer 链表的最前面。



deferreturn 其实会从 Goroutine 的链表中取出链表最前面的 _defer 结构体并调用 jmpdefer 函数并传入需要执行的函数和参数:


func deferreturn(arg0 uintptr) {    gp := getg()    d := gp._defer    if d == nil {        return    }    sp := getcallersp()
switch d.siz { case 0: 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) jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))}
复制代码


jmpdefer 其实是一个用汇编语言实现的函数,在不同的处理器架构上的实现稍有不同,但是具体的执行逻辑都差不太多,它们的工作其实就是跳转到并执行 defer 所在的代码段并在执行结束之后跳转回 defereturn 函数。


TEXT runtime·jmpdefer(SB), NOSPLIT, $0-8    MOVL    fv+0(FP), DX    // fn    MOVL    argp+4(FP), BX    // caller sp    LEAL    -4(BX), SP    // caller sp after CALL#ifdef GOBUILDMODE_shared    SUBL    $16, (SP)    // return to CALL again#else    SUBL    $5, (SP)    // return to CALL again#endif    MOVL    0(DX), BX    JMP    BX    // but first run the deferred function
复制代码


defereturn 函数会多次判断当前 Goroutine 中是否有剩余的 _defer 结构直到所有的 _defer 都执行完毕,这时当前函数才会返回。

__3. 总结

defer 关键字会在编译阶段被转换成 deferproc 的函数调用并在函数返回之前插入 deferreturn 指令;在运行期间,每一次 deferproc 的调用都会将一个新的 _defer 结构体追加到当前 Goroutine 持有的链表头,而 deferreturn 会从 Goroutine 中取出 _defer 结构并依次执行,所有 _defer 结构执行成功之后当前函数才会返回。

__4. Reference

__5. 其他

__5.1. 关于图片和转载

文章未经许可均禁止转载,图片在使用时请保留图片中的全部内容,可适当缩放并在引用处附上图片所在的文章链接,图片使用 Sketch 进行绘制。


**本文转载自 Draveness 技术博客。


原文链接:https://draveness.me/golang/keyword/golang-defer.html


2019-12-03 15:102177

评论

发布
暂无评论
发现更多内容

围绕应用的云原生转型建设

穿过生命散发芬芳

云原生 三周年连更

MySQL面试八股文:索引篇

Java你猿哥

Java MySQL ssm 索引 八股文

运行第一个程序Hello world

鸿蒙之旅

OpenHarmony 三周年连更

Python的常见数据结构

timerring

Python

Redis数据类型及底层大剖析

做梦都在改BUG

Java 数据库 redis 缓存

面试官:一千万的数据,你是怎么查询的?

做梦都在改BUG

Bash脚本中的Sleep命令到底有何妙用?

wljslmz

三周年连更

程序性能提高了5倍!开源阿里500页的Java程序优化笔记

Java你猿哥

Java 面试 性能优化 Java虚拟机 Java性能优化

分布式事务的21种武器 - 4

俞凡

架构 云原生

基于Alpine制作Tomcat的Docker镜像

清康

声网 Token 鉴权机制,以及常见的问题

声网

Java8 lambda 表达式 forEach 如何提前终止?

Java你猿哥

Java ssm java8 foreach

我懵了,如果要存 IP 地址,用什么数据类型比较好?

Java你猿哥

Java MySQL 数据库 ssm IP

SpringBoot核心配置全面总结

小小怪下士

Java 程序员 后端 springboot

太强了!牛客网开源1240页字节算法实录,无意中掀起GitHub刷题热潮

做梦都在改BUG

Java 数据结构 算法 LeetCode

分布式事务的21种武器 - 5

俞凡

架构 云原生

速通字节!阿里p8耗时6个月打造的java面试手册,看到直呼“面试有手就行”

Java你猿哥

Java spring 微服务 JVM 多线程

Java面试题大全(2023最新版)大厂面试题附答案详解

采菊东篱下

Java 面试

2023年互联网Java面试题最新整理附答案

架构师之道

Java 面试

为什么《魂斗罗》只有128KB却可以实现那么长的剧情?

Java你猿哥

Java 编程 程序员 汇编语言 优化技术

字节速通!这份软件测试八股文还怕不能拿offer?你值得拥有

Java你猿哥

MySQL 面试 软件测试 计算机 八股文

MySQL深分页问题原理与三种解决方案

做梦都在改BUG

Java MySQL 数据库

一文了解获得 Zebec Labs 投资的 Coral Finance,空投计划或在不久推出

西柚子

面试不再慌!苦学这份2023最全的Java性能优化实战笔记,已涨17k

Java你猿哥

Java 面试 JVM 多线程 Java性能优化

MATLAB与Simulink

袁袁袁袁满

三周年连更

https和http有什么区别?

海拥(haiyong.site)

三周年连更

程序员如何应对失业? | 社区征文

石云升

职业规划 三周年征文 三周年连更

变量,常量以及与其他语言的差异

乌龟哥哥

三周年连更

什么是 Intelligence Enterprise 的 Business network?

Jerry Wang

人工智能 机器学习 数字化转型 三周年连更

自动化与DevOps

阿泽🧸

自动化 三周年连更

Alibaba内网“疯狂”传阅的P8开源出的SpringBoot入门到进阶小册

做梦都在改BUG

Java spring 微服务 Spring Boot 框架

理解 Go 语言 defer 关键字的原理_文化 & 方法_Draveness_InfoQ精选文章