
Link: http://blog.j2gg0s.com/ 全文依然基于 go1.21.1, GOOS=linux, GOARCH=amd64, 编译和反汇编都运行在 macOS.
当前 Go 实现 defer 机制的方式有三种: open coded, stack allocated 和 heap allocated.
Open coded 指在编译时, 将 defer 直接插入函数返回的位置, 和直接调用相比也基本没有额外的开销.
Stack allocated 和 heap allocated 类似. 首先都是在遇到 defer 时将其保存到当前 goroutine. 随后在函数返回的位置插入对 runtime.deferreturn 的调用, 该函数按照先进后出的顺序执行当前 goroutine 的 defer 函数. 二者的区别在于前者在插入 defer 时使用栈上空间, 性能损失小; 后者使用推上空间, 有巨大的性能成本.
相关设计文档 中有个非常形象的例子.
假设代码如下:
defer f1(a) if cond { defer f2(b) } body... 经过编译后的代码如下:
deferBits |= 1<<0 tmpF1 = f1 tmpA = a if cond { deferBits |= 1<<1 tmpF2 = f2 tmpB = b } body... exit: if deferBits & 1<<1 != 0 { deferBits &^= 1<<1 tmpF2(tmpB) } if deferBits & 1<<0 != 0 { deferBits &^= 1<<0 tmpF1(tmpA) } 即:
//go:noinline func max(a, b int) int { if a > b { defer func() { fmt.Println("max is a") }() return a } defer func() { fmt.Println("max is b") }() return b } 对应的汇编代码:
000000000047ae00 <main.max>: ; func max(a, b int) int { 47ae00: 49 3b 66 10 cmpq 16(%r14), %rsp 47ae04: 0f 86 87 00 00 00 jbe 0x47ae91 <main.max+0x91> 47ae0a: 55 pushq %rbp 47ae0b: 48 89 e5 movq %rsp, %rbp 47ae0e: 48 83 ec 20 subq $32, %rsp 47ae12: 44 0f 11 7c 24 10 movups %xmm15, 16(%rsp) 47ae18: c6 44 24 07 00 movb $0, 7(%rsp) 47ae1d: 48 c7 44 24 08 00 00 00 00 movq $0, 8(%rsp) ; if a > b { 47ae26: 48 39 d8 cmpq %rbx, %rax ; rax - rbx 47ae29: 7e 2b jle 0x47ae56 <main.max+0x56> ; jle -> jump if less or equal ; defer func() { 47ae2b: 48 8d 0d c6 06 02 00 leaq 132806(%rip), %rcx # 0x49b4f8 <go:func.*+0x220> ; 见后续 47ae32: 48 89 4c 24 18 movq %rcx, 24(%rsp) ; 见后续 47ae37: c6 44 24 07 01 movb $1, 7(%rsp) ; deferbits 的第一个 bit 被置为 1, movb 仅移动一个字节 ; return a 47ae3c: 48 89 44 24 08 movq %rax, 8(%rsp) ; 调用 defer 将 rax 中的返回结果暂存到栈中 47ae41: c6 44 24 07 00 movb $0, 7(%rsp) ; 清空 deferbits 的第一个 bit 47ae46: e8 b5 00 00 00 callq 0x47af00 <main.max.func1> 47ae4b: 48 8b 44 24 08 movq 8(%rsp), %rax 47ae50: 48 83 c4 20 addq $32, %rsp 47ae54: 5d popq %rbp 47ae55: c3 retq ; defer func() { 47ae56: 48 8d 05 a3 06 02 00 leaq 132771(%rip), %rax # 0x49b500 <go:func.*+0x228> 47ae5d: 48 89 44 24 10 movq %rax, 16(%rsp) 47ae62: c6 44 24 07 02 movb $2, 7(%rsp) ; 第二个 defer 对应 deferbits 的第二个 bit ; return b 47ae67: 48 89 5c 24 08 movq %rbx, 8(%rsp) 47ae6c: c6 44 24 07 00 movb $0, 7(%rsp) 47ae71: e8 ea 00 00 00 callq 0x47af60 <main.max.func2> 47ae76: 48 8b 44 24 08 movq 8(%rsp), %rax 47ae7b: 48 83 c4 20 addq $32, %rsp 47ae7f: 5d popq %rbp 47ae80: c3 retq 47ae81: e8 5a 47 fb ff callq 0x42f5e0 <runtime.deferreturn> 47ae86: 48 8b 44 24 08 movq 8(%rsp), %rax 47ae8b: 48 83 c4 20 addq $32, %rsp 47ae8f: 5d popq %rbp 47ae90: c3 retq ; func max(a, b int) int { 47ae91: 48 89 44 24 08 movq %rax, 8(%rsp) 47ae96: 48 89 5c 24 10 movq %rbx, 16(%rsp) 47ae9b: 0f 1f 44 00 00 nopl (%rax,%rax) 47aea0: e8 fb fb fd ff callq 0x45aaa0 <runtime.morestack_noctxt.abi0> 47aea5: 48 8b 44 24 08 movq 8(%rsp), %rax 47aeaa: 48 8b 5c 24 10 movq 16(%rsp), %rbx 47aeaf: e9 4c ff ff ff jmp 0x47ae00 <main.max> Open coded 的弊端是可能造成汇编代码的体积膨胀, 所以 Go 会自主判断是否要降级到 stack allocated. 比如说当 defer 的数量超过 8 个时, 就会降级到 stack allocated. 此时:
Go 示例代码:
//go:noinline func add(a, b int) int { defer func() { fmt.Println(1) }() defer func() { fmt.Println(2) }() defer func() { fmt.Println(3) }() defer func() { fmt.Println(4) }() defer func() { fmt.Println(5) }() defer func() { fmt.Println(6) }() defer func() { fmt.Println(7) }() defer func() { fmt.Println(8) }() defer func() { fmt.Println(9) }() return a + b } 通过 deferprocStack 将 defer 保存到 goroutine 的汇编如下.
; defer func() { fmt.Println(1) }() 47ae56: 48 8d 0d 8b 16 02 00 leaq 136843(%rip), %rcx # 0x49c4e8 <go:func.*+0x220> 47ae5d: 48 89 8c 24 d8 01 00 00 movq %rcx, 472(%rsp) 47ae65: 48 8d 84 24 c0 01 00 00 leaq 448(%rsp), %rax 47ae6d: e8 8e 41 fb ff callq 0x42f000 <runtime.deferprocStack> 理解上述汇编代码, 需要结合 runtime 中的 deferprocStack 函数. 其签名为 func deferprocStack(d *_defer) {}, 参数 _defer 的主要结构为:
type _defer struct { started bool heap bool // openDefer indicates that this _defer is for a frame with open-coded // defers. We have only one defer record for the entire frame (which may // currently have 0, 1, or more defers active). openDefer bool sp uintptr // sp at time of defer pc uintptr // pc at time of defer fn func() // can be nil for open-coded defers ... } 此时倒着看这部分汇编会更容易理解:
callq 0x42f000 <runtime.deferprocStack> 调用 deferprocStackleaq 448(%rsp), %rax 在调用前将参数保存到 raxmovq %rcx, 472(%rsp) _defer 的开头在 448, 472 是偏移了 24 字节, 对应字段为 fn, 所以此处的含义是将 rcx 赋值给 _defer.fnleaq 136843(%rip), %rcx # 0x49c4e8 <go:func.*+0x220> 将 defer 函数的地址加载到 rcx此时回头去看 open coded 下的 leaq 也可以理解, 保留的原因是因为 GC?
返回前调用 deferreturn 的汇编代码:
; return a + b 47af8d: 48 8b 84 24 b0 02 00 00 movq 688(%rsp), %rax ; 将暂存在栈上的函数入参 a 和 b 存储到寄存器 rax 和 rcx 47af95: 48 8b 8c 24 a8 02 00 00 movq 680(%rsp), %rcx 47af9d: 48 01 c8 addq %rcx, %rax 47afa0: 48 89 44 24 08 movq %rax, 8(%rsp) ; 将结果暂存到栈上 47afa5: e8 36 46 fb ff callq 0x42f5e0 <runtime.deferreturn> ; 调用 deferreturn, 以 FILO 的顺序执行 defer 47afaa: 48 8b 44 24 08 movq 8(%rsp), %ra ; 将暂存的返回值存储到 rax 47afaf: 48 81 c4 98 02 00 00 addq $664, %rsp # imm = 0x298 ; 释放申请的栈空间 47afb6: 5d popq %rbp ; 恢复 base pointer 47afb7: c3 retq Heap allocated 和 stack allocated 的逻辑基本相似, 区别在于使用堆时, 需要用 deferproc 代替 deferprocStack. PR 指出当 defer 被多次调用时即会触发 heap allocated.
//go:noinline func sum(numbers []int) int { sum := 0 for i := 0; i < len(numbers); i++ { defer func() { fmt.Println(1) }() sum += numbers[i] } return sum } 从汇编中我们可以看到, 相对于 stack allocated 是调用 deferprocStack, 现在调用的是 deferproc. deferproc 会在堆上, 而不是栈上, 构造 _defer.
; defer func() { 47af79: 48 8d 05 e0 15 02 00 leaq 136672(%rip), %rax # 0x49c560 <go:func.*+0x270> 47af80: e8 7b 40 fb ff callq 0x42f000 <runtime.deferproc> 1 Nazz 2023-09-28 10:16:31 +08:00 怎么避免 heap allocated 呢 |
2 GopherDaily OP @Nazz 感觉没必要特别去避免,这本身就是 Go 在无法选择 open coded 或者 stack allocated 时才会进行的一种降级方案。 go defer 和其他的 try catch 之类不同,可以无限嵌套,所以数量不可控,需要一个 heap 的方案兜底 |