Defer 原理及面试题整理

2024/05/21 Go 共 3996 字,约 12 分钟

https://mp.weixin.qq.com/s/gaC2gmFhJezH-9-uxpz07w

https://www.bilibili.com/video/BV1hv411x7we?p=9

https://www.bilibili.com/video/BV1hv411x7we?p=10

要点总结

defer的工作原理和倒序执行原因

  • 工作原理defer 通过 deferprocdeferreturn 两个运行时函数实现。deferproc 在遇到 defer 语句时调用,负责保存要执行的函数信息,包括函数参数和返回值的大小,以及函数的入口地址。deferreturn 在函数返回前被调用,执行所有已注册的 defer 函数。

  • 倒序执行:由于 defer 信息存储在链表中,并且新注册的 defer 总是被添加到链表的头部,所以当执行 deferreturn 时,会从链表头部开始执行,导致 defer 函数按照注册的逆序执行。

defer链表和结构体

  • 链表:每个 goroutine 都有一个与之对应的 runtime.g 结构体,其中的 _defer 字段指向一个链表,用于存储 defer 信息。
  • 结构体_defer 结构体包含多个字段,其中 siz 表示参数和返回值的总大小,started 标识函数是否已经开始执行,sppc 分别记录了 defer 时的栈指针和下一条要执行的指令地址,fn 是指向被注册函数的指针,_panic 用于处理 panic 时的 deferlink 指向链表中的前一个 _defer 结构体。
type _defer struct {
    siz       int32    // 参数和返回值的总大小
    started   bool     // 标识 defer 函数是否已经开始执行
    sp        uintptr // 在 defer 时的栈指针
    pc        uintptr // deferproc 函数返回后要继续执行的指令地址
    fn        *funcval // 被注册的 defer 函数
    _panic    *_panic // 触发 defer 函数执行的 panic 指针,正常情况下为 nil
    link      *_defer  // 指向链表中前一个 _defer 结构的指针
}

defer传参机制

  • 参数传递:当 defer 一个函数时,其参数在 deferproc 调用时被复制到堆上分配的空间中。当 defer 函数实际执行时,这些参数再从堆复制到调用者函数的栈上。
  • 优化:对于没有捕获列表的函数,编译器会在只读数据段分配一个共用的 funcval 结构体,以优化性能。

defer与闭包

  • 闭包形成:当 defer 的函数是一个闭包,即捕获了外层函数的变量时,闭包中的函数值和捕获的变量都会被存储在堆上。
  • 变量处理:捕获的变量如果被修改过,会在堆上分配空间,而栈上只存储变量的地址。闭包对象在创建时会包含指向闭包函数入口的指针和捕获变量的地址列表。

defer的性能问题(Go 1.12)

  • 性能问题:在 Go 1.12 中,defer 的性能问题主要源于两个方面:
    • 堆分配_defer 结构体需要在堆上分配,即使有预分配的 deferpool,也需要进行堆上的获取与释放。
    • 参数复制defer 函数的参数需要在注册时从栈复制到堆,在执行时再从堆复制到栈,增加了额外的开销。

Go 1.13 的优化

  • 栈分配优化:在 Go 1.13 中,引入了 deferprocStack 函数,它允许将 _defer 结构体直接分配在函数的栈帧中,而不是在堆上。这减少了堆分配和回收的开销。

  • 限制:尽管 1.13 版本减少了堆分配,但这种优化并不适用于所有情况。特别是循环中的 defer,包括显式的 for 循环和通过 goto 形成的隐式循环,仍然需要在堆上分配。

Go 1.14 的优化(open coded defer)

  • 内联调用:Go 1.14 进一步优化了 defer 的性能,通过所谓的”open coded defer”,即直接在函数内部展开并调用 defer 函数,而不是通过 _defer 结构体和链表。
  • 条件执行:对于可能不会执行的 defer 函数,Go 1.14 引入了一个标识变量 df,用于跟踪哪些 defer 函数需要被执行。这允许编译器在编译时就确定是否需要创建 _defer 结构体。
  • 性能提升:通过这种方式,Go 1.14 显著提高了 defer 的性能,因为它避免了创建和维护 _defer 链表的开销。

性能测试

  • 基准测试:文章中提供了一个基准测试函数 BenchmarkDefer,用于比较不同 Go 版本中 defer 的性能。
  • 结果:基准测试结果显示,从 Go 1.12 到 Go 1.14,defer 的性能有了显著的提升。Go 1.14 的”open coded defer”相比于 Go 1.12 的实现,性能提升了近一个数量级。

总结

  • 性能提升:Go 语言的 defer 机制在 1.12 版本中存在性能问题,但在 1.13 和 1.14 版本中得到了显著的优化。
  • 理解重要性:理解 defer 的注册与执行逻辑对于编写高效的 Go 代码至关重要,尤其是在涉及到性能敏感的应用时。
  • panic 处理:尽管 defer 的性能得到了提升,但在处理 panic 时,Go 1.14 版本可能会稍微慢一些,因为需要通过栈扫描来找到并执行那些使用”open coded defer”优化的函数。

面试题

当然可以,以下是整理好的三道面试题及其答案:

https://mp.weixin.qq.com/s/iiOr6IlwDiwMRgXFMWiGxw

面试题 1:defer 的执行顺序

  • 问题:在 Go 语言中,defer 语句的执行顺序是怎样的?
  • 答案defer 语句按照它们在函数中出现的逆序执行,即最后一个声明的 defer 会第一个执行,这遵循了后进先出(LIFO)的原则。

面试题 2:defer 编程题

  • 问题:以下代码的输出结果是什么?

package main import “fmt”

func main() { a := 1 b := 3 defer func() { CalNum(“1”, a, CalNum(“123”, a, b)) }() a = 2 defer func() { defer CalNum(“1234”, a, CalNum(“12345”, a, b)) }() b = 4 } func CalNum(index string, a, b int) int { ret := a + b fmt.Println(index, a, b, ret) return ret }


- **答案**:代码的输出结果是:
	```
12345 2 4 6
1234 2 6 8
1 1 3 4
解释:最内层的`CalNum`("12345")首先执行,打印参数值(2, 4),然后是外层的`CalNum`("1234"),依此类推。注意`a`和`b`的值在`defer`调用之间发生了变化,因为`defer`表达式的参数在`defer`语句执行时就已经确定。

题目

https://mp.weixin.qq.com/s/4sCYf3icR6R-gHBNhGDxuw

题目 1

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

题目 2

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

题目 3

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

答案

第一题:输出结果为 1

第二题:输出结果为 5

第三题:输出结果为 1

看一下你答对了几题,是不是和你心中所想不一样?

接下来详细去分析在 Go 语言中 defer 关键字和 return 关键字的执行顺序以及上面的答案为什么是这样。

defer

defer 是 Go 语言里面提供的关键字,用于声明一个延迟函数,一般用于资源的释放,例如文件资源和网络连接等,标记了 defer 的语句一般在 return 语句之前执行,如果有多个 defer 语句,则遵循栈的调用规则,越后面的语句越先执行。

代码示例:

f, err := os.Open(filename)
if err != nil {
    log.Println("open file error: ", err)
}
defer f.Close()

上面简单概括了 defer 的执行顺序和机制,但为什么前面三道题的答案和我们想的都不一样呢?

deferreturn 之前执行这个肯定的,在官方文档中也有说到,那么可能存在问题的就是 return 语句,这也是最重要的一点, return** 语句不是原子操作!**

这是什么意思呢?就是说 return 语句实际上是分为两步完成的,第一步给返回值赋值,第二步返回值。那么 defer 语句就可能在赋值和返回值之间修改返回值,使最终的函数返回值与你想象的不一致。

可以把 return 拆分来写,就会更加清楚的明白这个原理。

func f1() (result int) {
    return = 0
    defer ...
    return result
}

答案解析

我们可以把三个题目的代码分别拆开来写,就能够明白为什么。

题目 1

func f1() (result int) {
    result = 0  // 先给 result 赋值
    func() {     // defer 插入到赋值和返回之间,修改了 result 的值
        result++
    }()    
    return        // 最后返回的就是被修改了的值
}

题目 2

func f2() (result int) {
    tmp := 5
    result = tmp  // 赋值
    func() {      // defer 被插入到赋值与返回之间执行,但是并没有改动到 result
        tmp = tmp + 5
    }    
    return         // 最后返回的 return 就是 5
}

题目 3

func f3() (result int) {
    result = 1        // 给返回值赋值
    func(result int) {  // 这里改的 result 是传值传进去的 result,不会改变前面赋值的 result
        result = result + 5
    }(result)
    return               // 最后 return 的是 1
}

结论

defer 确实是在 return 前面调用的,但是由于 return** 并不是一个原子操作,**defer** 语句的执行是在 **return** 的赋值和返回之间**,所以如果 defer 语句涉及到了修改返回值,那么就会改变最后 return 的值。

文档信息

Search

    Table of Contents