关于defer坑坑洼洼的使用细节你mark了吗

1,600 阅读12分钟

「第12期」 距离大叔的80期小目标还有68期,搁了一段时间的大叔又回来了~ 今天大叔要跟大家分享基础知识点是 —— defer坑坑洼洼的使用细节。还是那句话,基础抓得狠,不愁没饭碗,最怕的就是面试官的面试记录是这样写:“该同学基础较差,暂不考虑”,扎不扎心,难不难过。所以,建议提倡鼓励倡导表扬大家跟着大叔一起踏踏实实地打好基础,一起进步吧!

文章开始前,想请大家先看一下关于defer用法的两道题,据说这是5年的Gopher都会掉进的坑哦:

func increaseA() int {
    var i int
    defer func() {
        i++
    }()
    return i
}

func increaseB() (r int) {
    defer func() {
        r++
    }()
    return r
}

func main() {
    fmt.Println(increaseA())
    fmt.Println(increaseB())
}

老实说,在不运行代码的前提下,大叔未能够完全回答上来。如果你的情况也跟大叔一样,或者对defer的知识点有点模凌两可,那么大叔这篇文章就有意义了,请接着往下看;如果你能正确回答上了(跪烂的膝盖还收吗大神),那么就当做温故知新吧。

好,在分析上面题目之前,我们先来了解一下defer的今生前世是什么。

defer是什么

defer 是 Go 语言提供的一种用于注册延迟调用的机制,或者你也可以认为 defer 是一个“延迟调用函数”。我们看一下 defer 关键字在 Go 语言源代码中对应的数据结构:

type _defer struct {
   siz       int32   // 参数的长度,函数fn的参数长度
   started   bool   // 该 defer 是否已经执行过
   openDefer bool   // 是否开发编码
   sp        uintptr  // 函数栈指针寄存器,一般指向当前函数栈的栈顶
   pc        uintptr  // 程序计数器,有时称为指令指针(IP),线程利用它来跟踪下一个要执行的指令。在大多数处理器中,PC 指向的是下一条指令,而不是当前指令
   fn        *funcval // 指向传入的函数地址和参数
   _panic    *_panic  // 指向_panic链表
   link      *_defer  // 指向_defer链表 
    ...
}

可以看到,runtime._defer 结构体是延迟调用链表上的一个元素,所有的结构体都会通过 link 字段串联成链表。

那么这个链表是怎么构建的呢?实际上,只要获取到 新的runtime._defer 结构体,它都会被追加到所在 Goroutine _defer链表的最前面,即往 _defer链表的表头追加。

什么时候执行?如何执行?

defer 既然被定义为 “延迟调用”,说明 defer 语句不会立即执行,而是跟上面提到的那样,程序获取到新的 defer 结构体时,会先往延迟调用链表的表头追加该defer结构体。

那么什么时候会执行延迟调用呢?答案是:在函数return前

在函数return前,当前的 Goroutine 会从表头开始遍历延迟调用链表依次执行每个defer(也就是说最先被定义的defer语句会被最后执行,没错,就是先进后出的执行顺序,所以该延迟调用链也可以理解为是一个栈),我们来看一下源码:

func deferreturn(arg0 uintptr) {
 gp := getg()  // 返回当前的goroutine
 d := gp._defer  // 获取g上绑定的第一个defer
 if d == nil {  // 由于是递归调用,这里是一个循环终止条件,d上已经没有绑定的defer了
  return
 }
 sp := getcallersp() // 获取当前调用者的sp
 if d.sp != sp {
        // 判断当前调用者栈是否和defer中保存的一致
        // 举个例子,a()中声明一个defer1,并调用b(),b中也声明一个defer2
        // 然后defer1和defer2都绑定在同一个g上
        // 那么在b()执行return时,只会执行defer2,因为defer2上绑定的才是b()的sp
  return
 }
    // 判断是否是通过开发编码实现
 if d.openDefer {
  done := runOpenDeferFrame(gp, d)
  if !done {
   throw("unfinished open-coded defers in deferreturn")
  }
  gp._defer = d.link
  freedefer(d)
  return
 }

 // Moving arguments around.
 //
 // Everything called after this point must be recursively
 // nosplit because the garbage collector won't know the form
 // of the arguments until the jmpdefer can flip the PC over to
 // fn.
 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 // g中的defer指向下一个defer
 freedefer(d)  // 进行释放,归还到相应的缓冲区或者让gc回收
 // If the defer function pointer is nil, force the seg fault to happen
 // here rather than in jmpdefer. gentraceback() throws an error if it is
 // called with a callback on an LR architecture and jmpdefer is on the
 // stack, because the stack trace can be incorrect in that case - see
 // issue #8153).
 _ = fn.fn
 jmpdefer(fn, uintptr(unsafe.Pointer(&arg0))) // // 执行defer中绑定的func
}

整个执行流程可以理解为:

  • 判断当前goroutine上是否还有绑定的defer,若没有,直接return;
  • 获取 goroutine 绑定的 defer 链表头部的defer;
  • 判断当前 defer 中存储的sp是否和调用者的sp一致,若不一致,也直接return,证明当前defer不是在此调用函数中声明的;
  • 进行参数的拷贝;
  • 释放当前要执行的fn关联的defer;
  • 执行 jmpdefer 函数,这里执行完fn的逻辑后会递归调用 deferreturn 函数。

坑坑洼洼的使用细节

上面整体介绍了defer 数据结构以及调用原理,接下来我们继续看一下 defer 的使用细节。

一号坑:变量引用

在使用 defer 时,不可避免地会涉及到变量引用的问题,实际上,defer 语句定义时对外部变量引用的方式有以下两种:

作为函数参数

外部变量作为函数参数时,在 defer 定义时就把值传递给 defer 函数体缓存起来,相当于将变量值复制一份(注意:如果参数是值类型,那么将复制值,如果参数是指针,那么将复制指针而不是复制指针指向的值),看个例子:

func main() {
    
 i := 0
    
    // 作为函数参数
 defer func(x int) { 
        fmt.Printf("复制值:%v\n", x) 
    }(i)

 defer func(x *int) { 
        fmt.Printf("复制指针:%v\n", *x)
    }(&i)

 i++
}

运行输出:

复制指针:1
复制值:0

作为闭包引用

外部变量作为闭包引用时,则会在 defer 函数体真正被调用是根据整个上下文确定当前的值。看例子:

func main() {
    
    i := 0
    
    // 作为闭包引用
    defer func() { 
        fmt.Println(i) // 打印 1
    }() 

 i++
}

二号坑:和返回值被命名的函数一起使用

使用 defer 最容易踩坑的地方是和带命名返回参数的函数一起使用,这种方式又称为函数显式返回。比如这样:

func test() (r int) {
    defer func() {
        // todo
    }()
    return 0
}

避免掉坑的关键是要正确理解下面这条语句:

return xxxx

没错,就是这个return语句,实际上 return xxxx 并不是一个原子指令,经过编译后,它会变成三条指令(return “三步曲”):

返回值 = xxxx
调用 defer 函数体
直接 return , 结束当前函数

第一、三步才是 return 语句的真正命令,第二步是 defer 定义的语句,这里有可能会操作返回值

实践是检验真理的唯一标准!结合上面的知识点我们来盘几道题吧。

func f1() (r int) {
    defer func() {
        r++
    }()
    return 0
}

func main() {
    fmt.Println(f1())
}

首先,我们可以看到,defer 定义时对外部变量引用的方式是 闭包引用的方式,既然是闭包引用的方式,那么 defer 函数体内对返回值操作将会影响返回值;接着我们根据 return 的“三步曲”对上面的代码进行拆解:

func f1() (r int) {

    // 1.赋值
    r = 0

    // 2.闭包引用,返回值被修改
    defer func() {
        r++ 
    }()

    // 3.直接 return
    return
}


func main() {
    fmt.Println(f1())
}

经过拆解后,整个操作已经非常清晰了,defer 是闭包引用,返回值被修改,所以函数 f1() 返回 1。

再来:

func f2() (r int) {
    t := 1
    defer func() {
        t = t + 5
    }()
    return t
}

func main() {
    fmt.Println(f2())
}

同样,先看 defer 定义时对外部变量引用的方式,明显也是闭包引用;接着根据 return 的“三步曲”对上面的代码进行拆解:

func f2() (r int) {
    t := 1
    // 1.赋值
    r = t
    // 2.闭包引用,但是没有修改返回值 r,修改的是变量 t
    defer func() {
        t = t + 5
    }()
    // 3.直接 return
    return
}
func main() {
    fmt.Println(f2())
}

通过上面的拆解分析,我们可以看到,即使 defer 是闭包引用,但是 defer 函数体内修改的是变量 t,跟返回值 r 没有一毛钱关系,所以函数 f2() 返回 1。

趁热打铁,继续搞:

func f3() (r int) {
    defer func(r int) {
        r = r + 5
    }(r)
    return 1
}


func main() {
    fmt.Println(f3())
}

直接来吧,“三步曲” 拆起来:

func f3() (r int) {

    // 1.赋值
    r = 1

    // 2.r 作为函数参数,不会修改要返回的那个 r 值
    defer func(r int) {
        r = r + 5
    }(r)

    // 3.直接 return
    return
}


func main() {
    fmt.Println(f3())
}

这里注意一下,在第二步中,r 作为函数参数使用,是值的复制,因此 defer 函数体内的 r 和 外部的 r 是两个变量,defer 函数体内变量 r 的改变不会影响 外部变量 r ,所以函数 f3() 的返回值应该是 1。

问:如果和匿名返回值的函数使用呢?

上面的例子我们讨论的是函数显示返回值方式(带命名返回参数),那如果函数匿名返回值呢?我们再看看文章开头的例子:

func increaseA() int {
    var i int
    defer func() {
        i++
    }()
    return i
}

我们可以看到 函数increaseA() 是匿名返回值,直接返回局部变量,同时 defer 函数对局部变量是闭包引用,defer 函数也会操作这个局部变量。

同样的,我们依然是根据 return “三步曲” 走,对于匿名返回值,我们可以假设有一个变量存储返回值,假设返回值变量为 dashu,那么上面代码是不是可以拆解为:

dashu = i
i++
return

所以当程序编译 return i 时,首先会把 变量 i 的值拷贝给 变量 dashu,尽管 defer 是闭包引用的方式,并且在 defer 函数中修改了变量 i 的值,但对返回值 dashu 不造成影响,所以最终函数 increaseA() 返回0。

三号坑:defer 函数参数含有函数

最后一个细节,如果defer的函数的参数中又以某个函数的返回值为当作参数,那么 defer 又是怎么表现的呢?可以参考大叔公众号文章:传送门

好了,以上就是关于 defer 使用细节的全部内容。有收获的小伙伴点个赞呗,3Q~

关注公众号「大叔说码」 跟大叔一起打基础,我们下期见~