【GO】深入理解defer

深入理解defer

前言

对于每一个Gopher而言,defer关键字的使用一定是熟悉的不能再熟悉了,那么对于defer的使用场景,实现原理我们更应该深入的了解一番。即时造不了轮子,但可以了解轮子。

1. 使用场景

众所周知,我们经常使用defer做一些优雅的收尾工作,可能包含关闭文件描述符,解锁,关闭网络链接等。
甚至可以利用defer实现一些业务上的清理工作,比如waitGroup的释放,数据库的业务回滚操作等等

2. 如何使用defer

首先我们应该清楚,GO语言作为编译型语言,可执行程序是GO编译器的产物,语法检查,编译会在编译器进行(但是有部分错误是runtime时才会发生)。作为GO语言中的关键字,defer的实现一定是由编译器和运行时共同完成的。

程序的编译是由上向下,由右向左的,所以再使用defer关键字时,首先要注意与error的先后顺序,例如以下语句就会出现一些未知的错误。

response, err := c.httpClient.Do(request)
defer response.Body.Close()
if err != nil {
    log.Errorf("[%s] doRequest(), http client do request error:[%v]", traceID, err)
    return nil, err
}

若response为nil,此时编译器由上向下编译,defer语句被编译入栈,函数执行完成return时,会执行该条语句,此时会触发空指针panic

3. defer的执行顺序

3.1 defer的作用域

不知道有没有很多Gopher和我一样,再刚开始写GO语音的时候,受C++的部分思维影响,想当然的认为defer的作用域是代码块,再踩过坑后,才发现原来其作用域的粒度是函数。
说句题外话,为什么我尽可能的希望一个函数只做一件事,不仅仅是为了方便复用,而且也是减少每个函数的代码行数,减少未知的性能影响的可能性。
例1:在for循环中使用defer,并不会在每次循环结束后执行defer语句;

func main() {
    for i := 0; i < 5; i++ {
        defer fmt.Println(i)
    }
}

例2:函数业务逻辑过重,defer执行过慢,导致资源一直被占用,耗费性能并影响效率,如下例,文件描述符一直被占用,直到函数执行完成后,才会关闭

func bizFunc() {
    file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0666)
    if err != nil {
        fmt.Println("文件打开失败", err)
        return
    }
    //及时关闭file句柄
    defer file.Close()

    // 各种复杂的业务逻辑

    // 业务结束
}

3.2 defer的预计算参数

Go语言中所有的函数调用都是传值的(但是有的值是引用类型的),在调用defer关键字时,会立刻拷贝函数中引用的外部参数,借用GO语音设计与实现中的例子,其中time.Since(startedAt)的结果不是在main函数退出之前计算的,而是在defer关键字调用时计算的,最终导致上述代码输出0s,如何解决这一问题呢,可以看下后面的章节6。

func main() {
    startedAt := time.Now()
    defer fmt.Println(time.Since(startedAt))
    
    time.Sleep(time.Second)
}

stdout:
0s

同时,我们可以带着这个疑问,阅读下去,by the way,如果是执行以下代码,又会输出什么?

package main

import (
    "fmt"
)

type message struct {
    content int
}

func (p *message) set(c int) {
    p.content = c
}

func (p *message) print() int {
    return (*p).content
}

func main() {
    m := message{content: 1}

    defer func() {
        fmt.Println("1", m.print())
    }()
    defer fmt.Println("2", m.print())

    m.set(100)
}

4. defer底层结构

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

type _defer struct {
    siz       int32     // 是参数和结果的内存大小
    started   bool    
    openDefer bool      // 表示当前 defer 是否经过开放编码的优化;
    sp        uintptr   // 栈指针
    pc        uintptr   // 调用方的程序计数器
    fn        *funcval  // defer关键字中传入的函数
    _panic    *_panic   // 触发延迟调用的结构体,可能为空
    link      *_defer   // 下一跳地址
        ...
}

5. defer 执行机制

源码参考:
func (s *state) stmt(n *Node) {
    ...
    switch n.Op {
    case ODEFER:
        if s.hasOpenDefers {
            s.openDeferRecord(n.Left) // 开放编码
        } else {
            d := callDefer // 堆分配
            if n.Esc == EscNever {
                d = callDeferStack // 栈分配
            }
            s.callResult(n.Left, d)
        }
    }
}

// 堆分配、栈分配和开放编码是处理defer关键字的三种方法,早期的Go语言会在堆上分配runtime._defer结构体,不过该实现的性能较差,Go语言在1.13中引入栈上分配的结构体,减少了30%的额外开销,并在1.14中引入了基于开放编码的defer,使得该关键字的额外开销可以忽略不计。

5.1 堆上分配

从上方源码中,我们可以了解到,优先使用开放编码方式,其次使用栈分配方式,堆方式仅仅为兜底方案使用。
当该方案被启用时,编译器会调用 cmd/compile/internal/gc.state.callResult 和 cmd/compile/internal/gc.state.call,这表示 defer 在编译器看来也是函数调用。
其中,cmd/compile/internal/gc.state.call 会负责为所有函数和方法调用生成中间代码,它的工作包括以下内容:

  1. 获取需要执行的函数名、闭包指针、代码指针和函数调用的接收方;
  2. 获取栈地址并将函数或者方法的参数写入栈中;
  3. 使用 cmd/compile/internal/gc.state.newValue1A 以及相关函数生成函数调用的中间代码;
  4. 如果当前调用的函数是 defer,那么会单独生成相关的结束代码块;
  5. 获取函数的返回值地址并结束当前调用;

当运行时将 runtime._defer 分配到堆上时,Go 语言的编译器不仅将 defer 转换成了 runtime.deferproc,还在所有调用 defer 的函数结尾插入了runtime.deferreturn。上述两个运行时函数是 defer 关键字运行时机制的入口,它们分别承担了不同的工作:

  1. runtime.deferproc 负责创建新的延迟调用;
  2. runtime.deferreturn 负责在函数调用结束时执行所有的延迟调用;
创建延迟调用

runtime.deferproc 会为 defer 创建一个新的 runtime._defer 结构体、设置它的函数指针 fn、程序计数器 pc 和栈指针 sp 并将相关的参数拷贝到相邻的内存空间中,最后调用的 runtime.return0 是唯一一个不会触发延迟调用的函数,它可以避免递归 runtime.deferreturn 的递归调用。

runtime.deferproc 中 runtime.newdefer 的作用是想尽办法获得 runtime._defer 结构体,这里包含三种路径:

  1. 从调度器的延迟调用缓存池 sched.deferpool 中取出结构体并将该结构体追加到当前 Goroutine 的缓存池中;
  2. 从 Goroutine 的延迟调用缓存池 pp.deferpool 中取出结构体;
  3. 通过 runtime.mallocgc 在堆上创建一个新的结构体;

无论使用哪种方式,只要获取到 runtime._defer 结构体,它都会被追加到所在 Goroutine _defer 链表的最前面。

执行延迟调用

runtime.deferreturn 会从 Goroutine 的 _defer 链表中取出最前面的 runtime._defer 并调用 runtime.jmpdefer 传入需要执行的函数和参数。
runtime.jmpdefer 是一个用汇编语言实现的运行时函数,它的主要工作是跳转到 defer 所在的代码段并在执行结束之后跳转回 runtime.deferreturn。
runtime.deferreturn 会多次判断当前 Goroutine 的 _defer 链表中是否有未执行的结构体,该函数只有在所有延迟函数都执行后才会返回。

5.2 栈上分配

在默认情况下,我们可以看到 Go 语言中 runtime._defer 结构体都会在堆上分配,如果我们能够将部分结构体分配到栈上就可以节约内存分配带来的额外开销。

Go 语言团队在 1.13 中对 defer 关键字进行了优化,当该关键字在函数体中最多执行一次时,编译期间的 cmd/compile/internal/gc.state.call 会将结构体分配到栈上并调用 runtime.deferprocStack

因为在编译期间我们已经创建了 runtime._defer 结构体,所以在运行期间 runtime.deferprocStack 只需要设置一些未在编译期间初始化的字段,就可以将栈上的 runtime._defer 追加到函数的链表上,除了分配位置的不同,栈上分配和堆上分配的 runtime._defer 并没有本质的不同,而该方法可以适用于绝大多数的场景,与堆上分配的 runtime._defer 相比,该方法可以将 defer 关键字的额外开销降低 30%。

5.3 开放编码

Go 语言在 1.14 中通过开放编码(Open Coded)实现 defer 关键字,该设计使用代码内联优化 defer 关键的额外开销并引入函数数据 funcdata 管理 panic 的调用3,该优化可以将 defer 的调用开销从 1.13 版本的 ~35ns 降低至 ~6ns 左右
然而开放编码作为一种优化 defer 关键字的方法,它不是在所有的场景下都会开启的,开放编码只会在满足以下的条件时启用:

  1. 函数的 defer 数量少于或者等于 8 个;
  2. 函数的 defer 关键字不能在循环中执行;
  3. 函数的 return 语句与 defer 语句的乘积小于或者等于 15 个;
启用优化

Go 语言会在编译期间就确定是否启用开放编码,在编译器生成中间代码之前,我们会使用 cmd/compile/internal/gc.walkstmt 修改已经生成的抽象语法树,设置函数体上的 OpenCodedDeferDisallowed 属性。

const maxOpenDefers = 8

func walkstmt(n *Node) *Node {
    switch n.Op {
    case ODEFER:
        Curfn.Func.SetHasDefer(true)
        Curfn.Func.numDefers++
        if Curfn.Func.numDefers > maxOpenDefers {
            Curfn.Func.SetOpenCodedDeferDisallowed(true)
        }
        if n.Esc != EscNever {
            Curfn.Func.SetOpenCodedDeferDisallowed(true)
        }
        fallthrough
    ...
    }
}

就像我们上面提到的,如果函数中 defer 关键字的数量多于 8 个或者 defer 关键字处于 for 循环中,那么我们在这里都会禁用开放编码优化,使用上两节提到的方法处理 defer。
在 SSA 中间代码生成阶段的 cmd/compile/internal/gc.buildssa 中,我们也能够看到启用开放编码优化的其他条件,也就是返回语句的数量与 defer 数量的乘积需要小于 15。

func buildssa(fn *Node, worker int) *ssa.Func {
    ...
    s.hasOpenDefers = s.hasdefer && !s.curfn.Func.OpenCodedDeferDisallowed()
    ...
    if s.hasOpenDefers &&
        s.curfn.Func.numReturns*s.curfn.Func.numDefers > 15 {
        s.hasOpenDefers = false
    }
    ...
}

中间代码生成的这两个步骤会决定当前函数是否应该使用开放编码优化 defer 关键字,一旦确定使用开放编码,就会在编译期间初始化延迟比特和延迟记录。

延迟记录

延迟比特和延迟记录是使用开放编码实现 defer 的两个最重要结构,一旦决定使用开放编码,cmd/compile/internal/gc.buildssa 会在编译期间在栈上初始化大小为 8 个比特的 deferBits 变量:

func buildssa(fn *Node, worker int) *ssa.Func {
    ...
    if s.hasOpenDefers {
        deferBitsTemp := tempAt(src.NoXPos, s.curfn, types.Types[TUINT8]) // 初始化延迟比特
        s.deferBitsTemp = deferBitsTemp
        startDeferBits := s.entryNewValue0(ssa.OpConst8, types.Types[TUINT8])
        s.vars[&deferBitsVar] = startDeferBits
        s.deferBitsAddr = s.addr(deferBitsTemp)
        s.store(types.Types[TUINT8], s.deferBitsAddr, startDeferBits)
        s.vars[&memVar] = s.newValue1Apos(ssa.OpVarLive, types.TypeMem, deferBitsTemp, s.mem(), false)
    }
}

延迟比特中的每一个比特位都表示该位对应的 defer 关键字是否需要被执行,如下图所示,其中 8 个比特的倒数第二个比特在函数返回前被设置成了 1,那么该比特位对应的函数会在函数返回前执行:
因为不是函数中所有的 defer 语句都会在函数返回前执行,如下所示的代码只会在 if 语句的条件为真时,其中的 defer 语句才会在结尾被执行:

deferBits := 0 // 初始化 deferBits

_f1, _a1 := f1, a1  // 保存函数以及参数
deferBits |= 1 << 0 // 将 deferBits 最后一位置位 1

if condition {
    _f2, _a2 := f2, a2  // 保存函数以及参数
    deferBits |= 1 << 1 // 将 deferBits 倒数第二位置位 1
}
exit:

if deferBits & 1 << 1 != 0 {
    deferBits &^= 1 << 1
    _f2(a2)
}

if deferBits & 1 << 0 != 0 {
    deferBits &^= 1 << 0
    _f1(a1)
}

延迟比特的作用就是标记哪些 defer 关键字在函数中被执行,这样在函数返回时可以根据对应 deferBits 的内容确定执行的函数,而正是因为 deferBits 的大小仅为 8 比特,所以该优化的启用条件为函数中的 defer 关键字少于 8 个。
很多 defer 语句都可以在编译期间判断是否被执行,如果函数中的 defer 语句都会在编译期间确定,中间代码生成阶段就会直接调用cmd/compile/internal/gc.state.openDeferExit 在函数返回前生成判断 deferBits 的代码,也就是上述伪代码中的后半部分。
不过当程序遇到运行时才能判断的条件语句时,我们仍然需要由运行时的 runtime.deferreturn 决定是否执行 defer 关键字:

func deferreturn(arg0 uintptr) {
    gp := getg()
    d := gp._defer
    sp := getcallersp()
    if d.openDefer {
        runOpenDeferFrame(gp, d)
        gp._defer = d.link
        freedefer(d)
        return
    }
    ...
}

该函数为开放编码做了特殊的优化,运行时会调用 runtime.runOpenDeferFrame 执行活跃的开放编码延迟函数,该函数会执行以下的工作:

  1. 从 runtime._defer 结构体中读取 deferBits、函数 defer 数量等信息;
  2. 在循环中依次读取函数的地址和参数信息并通过 deferBits 判断该函数是否需要被执行;
  3. 调用 runtime.reflectcallSave 调用需要执行的 defer 函数;

6. defer & 闭包

简单来说,Go 语言中的闭包就是在函数内引用函数体之外的数据,这样就会产生一种结果,虽然数据定义是在函数外,但是在函数内部操作数据也会对数据产生影响。
虽然调用 defer 关键字时也使用值传递,但是因为拷贝的是函数指针,所以在defer执行时,实际是调用存在defer结构体中的函数指针,执行函数。
那么在此回答章节3的问题,我们利用闭包,解决此类问题,此时代码改写为:

func main() {
    startedAt := time.Now()
    defer func() { fmt.Println(time.Since(startedAt)) }()
    
    time.Sleep(time.Second)
}

$ go run main.go
1s

同时,章节3小测试的结果为:

2 1
1 100

// 后面的defer先执行
defer func() {
        fmt.Println("1", m.print())
    }()
defer fmt.Println("2", m.print())

此时defer预编译参数触发,fmt.Println("2", m.print())结果直接入栈:2 1
而闭包保存的是函数指针,m.set(100)执行后,才会调用,所以会输出:1 100

7. 效率

defer关键字给我们带来了很多便利,尤其是在代码的收尾阶段,那么defer既然这么好,是否会对性能造成一定影响呢?
网上很多资料都在说,频繁的使用defer会对性能带来影响,那么以下两段代码在性能又有什么区别呢?

func F1() {
    RWLock.RLock()
    defer RWLock.RUnlock()
    
    ...
}

func F2() {
    RWLock.RLock()
    ...
    RWLock.RUnlock()   
}

实验是检验真理的唯一手段,直接上结果:

package main

import (
    "sync"
    "testing"
)

var m sync.Mutex

func call() {
    m.Lock()
    m.Unlock()
}

func deferCall() {
    m.Lock()
    defer m.Unlock()
}

func BenchmarkCall(b *testing.B) {
    for i := 0; i < b.N; i++ {
        call()
    }
}

func BenchmarkDeferCall(b *testing.B) {
    for i := 0; i < b.N; i++ {
        deferCall()
    }
}

go test -bench=. -benchtime=5s
goos: darwin
goarch: amd64
pkg: boss.com/nebula/houdezhi-test
BenchmarkCall-8         393701302               14.9 ns/op
BenchmarkDeferCall-8    349210916               17.1 ns/op
PASS
ok      boss.com/nebula/houdezhi-test   15.134s

由实验结果我们可以发现,随着GO语音针对defer关键字的优化,defer带来的性能影响基本可以忽略不计了。
大家放心使用 >_< !!!

8 小结

defer 关键字的实现主要依靠编译器和运行时的协作,我们总结一下本节提到的三种机制:

  1. 堆上分配 · 1.1 ~ 1.12
    编译期将 defer 关键字转换成 runtime.deferproc 并在调用 defer 关键字的函数返回之前插入 runtime.deferreturn;
    运行时调用 runtime.deferproc 会将一个新的 runtime._defer 结构体追加到当前 Goroutine 的链表头;
    运行时调用 runtime.deferreturn 会从 Goroutine 的链表中取出 runtime._defer 结构并依次执行;

  2. 栈上分配 · 1.13
    当该关键字在函数体中最多执行一次时,编译期间的 cmd/compile/internal/gc.state.call 会将结构体分配到栈上并调用 runtime.deferprocStack;

3.开放编码 · 1.14 ~ 现在
编译期间判断 defer 关键字、return 语句的个数确定是否开启开放编码优化;
通过 deferBits 和 cmd/compile/internal/gc.openDeferInfo 存储 defer 关键字的相关信息;
如果 defer 关键字的执行可以在编译期间确定,会在函数返回前直接插入相应的代码,否则会由运行时的 runtime.deferreturn 处理;
我们在本节前面提到的两个现象在这里也可以解释清楚了:

后调用的 defer 函数会先执行:
后调用的 defer 函数会被追加到 Goroutine _defer 链表的最前面;
运行 runtime._defer 时是从前到后依次执行;
函数的参数会被预先计算;
调用 runtime.deferproc 函数创建新的延迟调用时就会立刻拷贝函数的参数,函数的参数不会等到真正执行时计算;

9 参考地址

https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-defer/#53-defer

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,045评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,114评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,120评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,902评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,828评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,132评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,590评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,258评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,408评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,335评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,385评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,068评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,660评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,747评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,967评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,406评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,970评论 2 341