Go 中 Defer 原理分析

defer 是什么?

defer 修饰的函数是一个延迟函数,在包含它的函数返回时运行。

defer 执行时机

A "defer" statement invokes a function whose execution is deferred to the moment the surrounding function returns, either because the surrounding function executed a return statement, reached the end of its function body, or because the corresponding goroutine is panicking

defer 触发时机是:

  1. 函数执行到函数体末端
  2. 函数执行return语句
  3. 当前协程panic

defer 实现原理

defer 数据结构源码在 src/runtime/runtime2.go

在这里插入图片描述

type _defer struct {
   siz     int32 // 由deferproc第一个参数传入,参数和结果的内存大小
   started bool // 标识defer函数是否已经开始执行
   heap    bool // 堆分配、栈分配
   openDefer bool //表示当前 defer 是否经过开放编码的优化
   sp        uintptr  // 栈指针程序计数器,注册defer函数的函数栈指针
   pc        uintptr  // 调用方程序计数器,deferproc函数返回后要继续执行的指令地址
   fn        *funcval // 由deferproc的第二个参数传入,也就是被注册的defer函数
   _panic    *_panic  // 是触发defer函数执行的panic指针,正常流程执行defer时它就是nil
   link      *_defer //结构体是延迟调用链表上的一个元素,所有的结构体都会通过 link 字段串联成链表。
}

defer 执行机制

在中间代码生成阶段, 有三种不同的机制处理 defer 关键字

  • 堆上分配(Go 版本1.1-1.12)默认兜底方案
  • 栈分配(Go版本 1.13 )相比堆分配能够减少 30%堆额外开销。
  • 开放编码(Go 版本 1.14) 额外开销可以忽略不计。

堆上分配

  1. defer 语句堆位置插入 runtime.deferproc, 在被执行时,延迟调用会被保存为一个 _defer 记录,并将被延迟调用的入口地址与参数复制保存,存入 Gorountine 的调用链表中。
  2. 在函数返回之前的位置插入 runtime.deferreturn,当被执行时,会将延迟调用从 Goroutine 链表中取出并执行,多个延迟调用则以 jmpdefer 尾递归调用方式连续执行

runtime.deferproc 负责注册, runtime.deferreturn 负责执行。

在这里插入图片描述

derfer 关键字最重要的三个函数

  1. deferproc。在每遇到一个defer关键字时,实际上都会转换为deferproc函数,deferproc函数的作用是将defer函数存入链表中。(go关键字是使用newproc函数,它两的实现有着不少相似之处)
  2. deferreturn。在return指令前调用,从链表中取出defer函数并执行。
  3. deferprocStack。go1.13后对defer做的优化,通过利用栈空间提高效率。

call 函数

cmd/compile/internal/gc.state.call 会负责为所有函数和方法调用生成中间代码:

  1. 获取需要执行的函数名,代码制作和函数调用的接收方
  2. 需要获取栈地址并将函数或者方法的参数写入栈中
  3. 使用 cmd/compile/internal/gc.state.newValue1A 函数生成函数调用的中间代码
  4. 如果当前调用的函数是 defer, 那么会单独生成相关的结束代码块。
  5. 获取函数的返回值,并结束当前调用。
// Calls the function n using the specified call type.
// Returns the address of the return value (or nil if none).
func (s *state) call(n *Node, k callKind) *ssa.Value {
    var call *ssa.Value
    if k == callDeferStack {
        // 在栈上初始化 defer 结构体
        call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, deferprocStack, s.mem())
        ...
    } else {
        ...
        switch {
        case k == callDefer:
            call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, deferproc, s.mem())
            ...
        }
        call.AuxInt = stksize
    }
    s.vars[&memVar] = call
    ...
}

deferproc 函数

runtime.deferproc 的功能时注册延迟函数,会为 defer 创建一个新的 runtime._defer的结构体、设置它的函数指针 fn、程序计数器 pc 和 栈制作 sp 并将相关的函数参数拷贝到相邻的内存空间。

  • deferproc 函数有两个参数, 第一个是被注册的 defer 函数的参数返回值占多少字节。第二个参数是一个 runtime.funcval 结构体的指针
  • runtime.newdefer通过 runtime.mallocgc 在堆上创建一个新的结构体,并添加到link字段上形成链表。
  • 最后调用的 runtime.return0 是唯一一个不会触发延迟调用的函数,它可以避免递归 runtime.deferreturn 的递归调用。
func deferproc(siz int32, fn *funcval) { 
    // 这里的g就是gorouutine,详看golang的调度模型
    // 只有用户使用的goroutine可以应用defer
    if getg().m.curg != getg() {
        throw("defer on system stack")
    }
    sp := getcallersp() // 调用deferproc之前的rsp寄存器的值
    argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
    callerpc := getcallerpc() // deferproc函数的返回地址,也就是执行完defer后应当跳回哪段代码上
    d := newdefer(siz)
    if d._panic != nil {
        throw("deferproc: d.panic != nil after newdefer")
    }
    d.fn = fn
    d.pc = callerpc
    d.sp = sp
    // 对defer函数参数进行处理
    switch siz {
    case 0:
        // Do nothing.
    case sys.PtrSize:
        *(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
    default:
        memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
    }
    return0()
}

先通过 newdefer 获取一个 defer 关键字的插入顺序是从后向前的,而 defer 关键字执行是从前向后的,后调用的 defer 会优先执行。

newdefer追加新等延迟调用

deferreturn 函数

runtime.deferreturn 是触发延迟函数链表的执行,会从 Goroutine_defer 链表中取出最前面的 runtime._defer 并调用 runtime.jmpdefer 传入需要执行的函数和参数。

  • runtime.jmpdefer 是一个用汇编语言实现的运行时函数,它的主要工作是跳转到 defer 所在的代码段并在执行结束之后跳转回 runtime.deferreturn。
  • runtime.deferreturn 会多次判断当前 Goroutine 的 _defer 链表中是否有未执行的结构体,该函数只有在所有延迟函数都执行后才会返回
  • 最后调用的 runtime.return0 是唯一一个不会触发延迟调用的函数,它可以避免递归 runtime.deferreturn 的递归调用。
func deferreturn(arg0 uintptr) {
    gp := getg()
    d := gp._defer
    if d == nil {
        // 结束条件1,没有defer函数了,也就是所有defer函数都执行完成了
        // 还记得defer的链式结构吗,其实就是一个递归函数不断调用
        // 为nil的话就代表这条链遍历完成了
        return
    }
    sp := getcallersp()
    if d.sp != sp {
        // 结束条件2,如果保存在_defer对象中的sp值与调用deferretuen时的栈顶位置不一样,直接返回
        // 因为sp不一样表示d代表的是在其他函数中通过defer注册的延迟调用函数,比如:
        // a()->b()->c()它们都通过defer注册了延迟函数,那么当c()执行完时只能执行在c中注册的函数
        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)
    jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}

包含如下几个步骤:

  1. 判断执行条件
  2. 参数拷贝
  3. 释放_defer对象
  4. 执行函数

值得一提的是,defer具有即时传值的特点,defer也同样满足闭包和匿名函数的特性。

以代码为例子,讲解 defer 注册和执行流程

// 源程序
func A1(a int) {
    fmt.Println(a)
}
func A() {
    a, b := 1, 2
    defer A1(a)
    
    a = a + b
    fmt.Println(a, b)
}

//函数A编译后的伪指令
func A() {
    a, b := 1, 2
    runtime.deferproc(8, A1,1)    // siz=8, A1=延迟函数入口, 1=A1函数入参
      
    a = a + b
    fmt.Println(a, b)//3,2

    runtime.deferreturn()//执行defer链表
    return
}
  1. 函数A定义局部变量a=1,b=2,存储在A函数的栈中
  1. deferproc函数注册defer函数A1时,
  • func deferproc(siz int32, fn *funcval)
  • siz:A1没有返回值,64位下一个整型参数占用8字节。
  • fn:A1函数入口地址,addr1

deferproc函数调用时,编译器会在它自己的两个参数后面,开辟一段空间,用于存放defer函数A1的返回值和参数。这一段空间会在注册defer时,直接拷贝到_defer结构体的后面。

在这里插入图片描述

  1. 在堆上分配存储空间,并存放_defer结构体
  • A1的参数加返回值共占8字节
  • defer函数尚未执行,所以started=false
  • sp就是调用者A的栈指针
  • pc就是deferproc函数的返回地址return addr
  • 被注册的function value为A1
  • defer结构体后面的8字节用来保存传递给A1的参数。然后这个_defer结构体就被添加到defer链表头,deferproc注册结束。

频繁的堆分配势必影响性能,所以Go语言会预分配不同规格的deferpool,执行时从空闲_defer中取一个出来用。没有空闲的或者没有大小合适的,再进行堆分配。用完以后,再放回空闲_defer池。这样可以避免频繁的堆分配与回收。

在这里插入图片描述

  1. deferreturn执行defer链表:从当前goroutine找到链表头上的这个_defer结构体,通过_defer.fn找到defer函数的funcval结构体,进而拿到函数A1的入口地址。接下来就可以调用A1了。调用A1时,会把_defer后面的参数与返回值整个拷贝到A1的调用者栈上。然后A1开始执行,输入参数值a=1。

在这里插入图片描述

欢迎关注公众号:程序员财富自由之路

公众号:程序员财富自由之路

博客:CSDN 王小明

关注我们,了解更多

关注后:回复 “AI” 或者 “内推”, 有惊喜

参考资料

  • https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-defer/#fn:1
  • https://sanyuesha.com/2017/07/23/go-defer/
  • https://zhuanlan.zhihu.com/p/261153919

本文分享自微信公众号 - 程序员财富自由之路(gh_016ffe40d550),作者:猿星人

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

原始发表时间:2021-08-31

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

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Go 中 defer 关键字原理

    runtime.deferproc 负责注册, runtime.deferreturn 负责执行。

    王小明_HIT
  • Go语言defer分析

    defer语句是专门在函数结束以后做一些清理工作的。我们先举一个例子来更好的理解,现在有一个函数,它的作用是把一个文件内容拷贝到另一个文件。

    平也
  • 2020-11-19:go中,defer原理是什么?

    福大大架构师每日一题
  • Golang语言--资源自动回收技术

    Go语言作为一个现代化的编程语言以及支持垃圾内存的自动回收特性(GC). 我们现在关注的是非内存资源的自动回收技术. 局部资源的管理 在讨论Go语言解决方案之前...

    李海彬
  • 深入 Go 语言 defer 实现原理

    在上面的例子中,使用 for 循环将字符串 Naveen进行遍历后调用 defer,这些 defer调用仿佛就像被压栈一样,最后被推入堆栈的defer调用将被拉...

    luozhiyun
  • <Go语言学习笔记>【异常处理】

    通常 panic 和 recover 是用来处理异常问题的。我们来综述下,他们各自的特点:

    秦穆之
  • Go语言错误处理

    错误指的是可能出现问题的地方出现了问题,比如打开一个文件时可能失败,这种情况在人们的意料之中。

    Steve Wang
  • Go 语言基础--错误&异常浅析

    如果go是你的第一门语言,go的异常和错误体系可能比较容易接受,但如果你有一定的Java或者c++基础,go的异常和错误体系可能会比较不适应。 go的错误及异...

    邹志全
  • golang中的for range语义的理解

    golang中for range经常会被用来遍历slice、map、chan、array,但是由于在某些情况下,其内部实现并不是你想的那样,所以使用时还是需要特...

    刘斌
  • Go 语言错误及异常处理篇(二):defer 语句

    Go 语言中的类没有构造函数和析构函数的概念,处理错误和异常时也没有提供 try...catch...finally 之类的语法,那当我们想要在某个资源使用完毕...

    学院君
  • Golang语言延迟函数defer用法分析

    本文实例讲述了GO语言延迟函数defer用法。分享给大家供大家参考。具体分析如下: defer 在声明时不会立即执行,而是在函数 return 后,再按照 FI...

    李海彬
  • defer 链如何被遍历执行

    去年开始写文章的第一篇就是关于 defer,名字比较文艺:《Golang 之轻松化解 defer 的温柔陷阱》,还被吐槽了。因为这篇文章,到《Go 夜读》讲了一...

    梦醒人间
  • Go 面试系列(六) - err shadow 是什么鬼?

    在日常工作中,我们经常使用 err != nil 来判断程序或函数是否报错,或者使用 defer {recover = err} 来判断是否有 panic 严重...

    astraw99
  • golang面试基础系列-defer(一)

    go语言的语法学起来还是比较快的,但在实战过程中总会遇到这样或那样的错误,逐个解决领悟之后,才能真正掌握go语言的细枝末节,成为一名合格的gopher。

    astraw99
  • Go起步及创建WEB项目

    Go语言在多核并发上拥有原生的设计优势,Go语言从底层原生支持并发,无须第三方库、开发者的编程技巧和开发经验。

    剑行者
  • defer 的前世今生

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

    梦醒人间
  • Go语言panic/recover的实现

    本文主要分析Go语言的panic/recover在AMD64 Linux平台下的实现,包括:

    阿波张
  • 深入理解defer(上)defer基础

    f() 函数首先通过调用 getResource() 获取了某种资源(比如打开文件,加锁等),然后进行了一些我们不太关心的操作,但这些操作可能会导致 f() ...

    阿波张
  • Golang语言--细节汇总

    slice和数组在声明时的区别:声明数组时,方括号内写明了数组的长度或使用...自动 计算长度,而声明slice时,方括号内没有任何字符。 对于slice有几个...

    李海彬

扫码关注云+社区

领取腾讯云代金券