Go函数指针是如何让你的程序变慢的?
👉导读
Go 语言的常规优化手段无需赘述,相信大家也能找到大量的经典教程。但基于 Go 的函数值问题,业界还没有太多深度讨论的内容分享。本文作者根据自己对 Go 代码的使用与调优经验,分享了 Go 的函数值对性能影响的原因以及优化方案,值得深度阅读!👉目录
1 背景2 函数调用的实现方式3 优化4 结论5 参考资料01
最近在尝试做一些 Go 代码的微观代码优化时,发现由于 Go 中函数调用机制的影响,性能会比 C/C++ 等语言慢一些,而且有指针类型的参数时,影响会更大。
本文对其背后的原因进行初步的分析,并提供一些优化建议以便在必要时采用,期望对读者有所帮助。
需要注意的是,在 Go 中本身并没有函数指针的概念,而是称为“函数值”,但是为了能和其他语言进行相应的比较,以及和直接调用的函数相区别,还是称之为“函数指针”。
02
要了解函数的调用机制,需要了解一点点汇编语言,不过无需担心,不会太复杂。
为了清晰起见,Go 代码生成的汇编均已去掉了 FUNCDATA 和 PCDATA 等非运行的伪指令。
以下均针对 x86-64 平台做分析。
2.1 C 语言中的函数指针
1.普通函数
源代码:
int Add(int a, int b) { return a + b; }
生成的代码:
Add:
lea eax, [rdi+rsi]
ret
根据 x86-64/Linux 下 C 语言的调用约定,前两个整数参数是通过 RDI 和 RS 寄存器传递的。因此以上代码相当于:
eax = rdi + rsi
return eax
非常的简洁直白。
2.生成函数指针
源代码:
int (*MakeAdd())(int, int) { return Add; }
生成的代码:
MakeAdd:
mov eax, OFFSET FLAT:Add
ret
以上代码直接通过 eax 寄存器返回了函数的地址。
3.通过函数指针间接调用
源代码:
int CallAdd(int(*add)(int, int)) {
add(1, 2);
add(1, 2);
}
生成的代码:
CallAdd:
push rbx
mov rbx, rdi
mov esi, 2
mov edi, 1
call rbx
mov rax, rbx
mov esi, 2
mov edi, 1
pop rbx
jmp rax
以上代码中,rdi 为 CallAdd 函数的第一个参数,也就是函数的地址,后来赋值给 rbx 寄存器,后续的调用都是通过 rbx 寄存器进行的,第二次调用时甚至优化掉了调用,直接跳转到了函数的地址。实际上如果只有一次函数调用,那么生成的代码里就只有 jmp 而没有 call 了。
详情参见 https://godbolt.org/z/GTbjv5o9G
2.2 Go 中的函数及函数指针调用
我们再来看一下在 Go 语言中函数调用的方式。
1.Go 语言中的函数和函数指针
Go 函数的代码:
func Add(a, b int) int {
return a + b
}
生成的代码:
main.Add STEXT nosplit size=4 args=0x10 locals=0x0 funcid=0x0 align=0x0
0x0000 00000 (<source>:4) ADDQ BX, AX
0x0003 00003 (<source>:4) RET
从 Go1.17 开始,x86-64 下的 Go 编译器开始使用基于寄存器的调用约定,前两个整数参数分别通过 AX,BX 传递,返回值也是通过同样的寄存器序列。可以看出,除了所用的寄存器不一样,和 C 生成的代码还是比较相似的,性能应该也接近。
对于调用 Go 函数的代码:
//go:nosplit
func CallAdd() {
Add(1, 2)
}
生成的代码:
main.CallAdd STEXT nosplit size=39 args=0x0 locals=0x18 funcid=0x0 align=0x0
0x0000 00000 (<source>:9) SUBQ $24, SP
0x0004 00004 (<source>:9) MOVQ BP, 16(SP)
0x0009 00009 (<source>:9) LEAQ 16(SP), BP
0x000e 00014 (<source>:10) MOVL $1, AX
0x0013 00019 (<source>:10) MOVL $2, BX
0x0018 00024 (<source>:10) CALL main.Add(SB)
0x001d 00029 (<source>:11) MOVQ 16(SP), BP
0x0022 00034 (<source>:11) ADDQ $24, SP
0x0026 00038 (<source>:11) RET
除了调用约定不一样外,看起来和 C 的函数调用也差别不大。
但是,我们马上就能看到,通过函数指针调用 Go 函数时,和 C 代码大不一样!
2. 通过函数指针间接调用 Go 函数
源代码:
//go:nosplit
func CallAddPtr(add func(int, int) int) {
add(1, 2)
}
生成的代码:
main.CallAddPtr STEXT nosplit size=44 args=0x8 locals=0x18 funcid=0x0 align=0x0
0x0000 00000 (<source>:29) SUBQ $24, SP
0x0004 00004 (<source>:29) MOVQ BP, 16(SP)
0x0009 00009 (<source>:29) LEAQ 16(SP), BP
0x000e 00014 (<source>:30) MOVQ (AX), CX
0x0011 00017 (<source>:30) MOVL $2, BX
0x0016 00022 (<source>:30) MOVQ AX, DX
0x0019 00025 (<source>:30) MOVL $1, AX
0x001e 00030 (<source>:30) NOP
0x0020 00032 (<source>:30) CALL CX
0x0022 00034 (<source>:31) MOVQ 16(SP), BP
0x0027 00039 (<source>:31) ADDQ $24, SP
0x002b 00043 (<source>:31) RET
第一眼就能看到的是,比C的复杂多了(注意C版本里有两次函数调用,一次调用只有3条指令)。
CALL 指令前的2字节 NOP 指令可以忽略,有兴趣参见
https://github.com/teh-cmc/go-internals/issues/4 及
https://stackoverflow.com/questions/25545470/long-multi-byte-nops-commonly-understood-macros-or-other-notation
即使忽略了 NOP 指令,也有5条指令。在 Go 的版本中,真正的函数地址是从 AX 寄存器指向的地址读取到后放到 CX 寄存器中,然后还要把函数值的地址设置到 DX 寄存器中。但是从上面的 Add 函数的代码看,DX 寄存器并没有用到,这个无用功是为了什么呢?
我们先看一下函数是如何返回函数指针的:
func MakeAdd() func(int, int) int {
return func(a, b int) int {
return a+b
}
}
生成的代码:
main.MakeAdd STEXT nosplit size=8 args=0x0 locals=0x0 funcid=0x0 align=0x0
0x0000 00000 (<source>:15) LEAQ main.Add·f(SB), AX
0x0007 00007 (<source>:15) RET
看起来和 C 的差不多是不是?仔细看却不一样,比起真正的 Add 函数名,多了个 ·f 后缀。
找到,main.Add·f,发现其代码是:
main.Add·f SRODATA dupok size=8
0x0000 00 00 00 00 00 00 00 00 ........
rel 0+8 t=1 main.Add+0
可以看出,在 Go 中,函数指针并不直接指向函数所在的地址,而是指向一段数据,这里放着的才是真正的函数地址。
那么为什么 Go 要这么绕呢?
Go 函数和 C 函数最大的区别是,Go 支持内嵌匿名函数,并且在匿名函数中可以访问到所在函数的局部变量,例如下面这个返回闭包的函数:
func MakeAddN(n int) func(int, int) int {
return func(a, b int) int {
return n + a + b
}
}
对于 C 函数,在其返回后,n 就应该已经被销毁了。但是对于 Go 函数,拿到 Go 返回的函数时,在次调用时,n 还是可以访问的。
main.MakeAddN STEXT nosplit size=60 args=0x8 locals=0x18 funcid=0x0 align=0x0
0x0000 00000 (<source>:21) SUBQ $24, SP
0x0004 00004 (<source>:21) MOVQ BP, 16(SP)
0x0009 00009 (<source>:21) LEAQ 16(SP), BP
0x000e 00014 (<source>:22) MOVQ AX, main.n+32(SP)
0x0013 00019 (<source>:22) PCDATA $3, $-1
0x0013 00019 (<source>:22) LEAQ type.noalg.struct { F uintptr; main.n int }(SB), AX
0x001a 00026 (<source>:22) CALL runtime.newobject(SB)
0x001f 00031 (<source>:22) LEAQ main.MakeAddN.func1(SB), CX
0x0026 00038 (<source>:22) MOVQ CX, (AX)
0x0029 00041 (<source>:22) MOVQ main.n+32(SP), CX
0x002e 00046 (<source>:22) MOVQ CX, 8(AX)
0x0032 00050 (<source>:22) MOVQ 16(SP), BP
0x0037 00055 (<source>:22) ADDQ $24, SP
0x003b 00059 (<source>:22) RET
type.noalg.struct { F uintptr; main.n int }
其中 F 指向真正的嵌套函数的代码,n 则是捕获的所属函数的局部变量。
嵌套函数实际上也是一个真正的函数,但是比起普通的函数,多了个从 DX 寄存器读取的值操作:
main.MakeAddN.func1 STEXT nosplit size=8 args=0x10 locals=0x0 funcid=0x0 align=0x0
0x0000 00000 (<source>:23) ADDQ 8(DX), AX
0x0004 00004 (<source>:23) ADDQ BX, AX
0x0007 00007 (<source>:23) RET
其中 AX、BX 和 Add 中的用途一样,分别是 a、b 两个参数,而 DX 就是函数指针对象自身的地址,8(DX) 就是其源代码中的 n。
在非正式的文档中,DX 被称为上下文寄存器(context register)。
https://stackoverflow.com/questions/41067095/what-is-a-context-register-in-golang
因此可以知道,返回函数时,如果函数捕获了变量,也会导致内存分配。
Go 代码 https://godbolt.org/z/TdKW9eaTT
2.3 逃逸分析对性能的影响
除了为了统一支持闭包所需要付出的开销外,对 Go 的函数指针的调用还会影响到逃逸分析,会导致本来可以分配在栈上的对象不得不逃逸到堆上。这种情况出现在函数的参数有指针类型时。
对于使用指针函数:
main.MakeAddN.func1 STEXT nosplit size=8 args=0x10 locals=0x0 funcid=0x0 align=0x0
0x0000 00000 (<source>:23) ADDQ 8(DX), AX
0x0004 00004 (<source>:23) ADDQ BX, AX
0x0007 00007 (<source>:23) RET
生成的代码看起来和 C 语言的很像:
main.Set STEXT nosplit size=8 args=0x8 locals=0x0 funcid=0x0 align=0x0
0x0000 00000 (<source>:5) MOVQ $1, (AX)
0x0007 00007 (<source>:6) RET
在调用处:
//go:nosplit
func CallSet() {
a := 0
Set(&a)
}
生成的代码为:
main.CallSet STEXT nosplit size=47 args=0x0 locals=0x18 funcid=0x0 align=0x0
0x0000 00000 (<source>:9) SUBQ $24, SP
0x0004 00004 (<source>:9) MOVQ BP, 16(SP)
0x0009 00009 (<source>:9) LEAQ 16(SP), BP
0x000e 00014 (<source>:10) MOVQ $0, main.a+8(SP)
0x0017 00023 (<source>:11) LEAQ main.a+8(SP), AX
0x001c 00028 (<source>:11) NOP
0x0020 00032 (<source>:11) CALL main.Set(SB)
0x0025 00037 (<source>:12) MOVQ 16(SP), BP
0x002a 00042 (<source>:12) ADDQ $24, SP
0x002e 00046 (<source>:12) RET
看起来和 C 中的也很像。
但是当通过函数指针调用时:
//go:nosplit
func CallSetPtr(set func(*int)) {
a := 0
set(&a)
}
生成的代码:
main.CallSetPtr STEXT nosplit size=51 args=0x8 locals=0x18 funcid=0x0 align=0x0
0x0000 00000 (<source>:15) TEXT main.CallSetPtr(SB), NOSPLIT|ABIInternal, $24-8
0x0000 00000 (<source>:15) SUBQ $24, SP
0x0004 00004 (<source>:15) MOVQ BP, 16(SP)
0x0009 00009 (<source>:15) LEAQ 16(SP), BP
0x000e 00014 (<source>:15) MOVQ AX, main.set+32(SP)
0x0013 00019 (<source>:16) LEAQ type.int(SB), AX
0x001a 00026 (<source>:16) CALL runtime.newobject(SB)
0x001f 00031 (<source>:17) MOVQ main.set+32(SP), DX
0x0024 00036 (<source>:17) MOVQ (DX), CX
0x0027 00039 (<source>:17) CALL CX
0x0029 00041 (<source>:18) MOVQ 16(SP), BP
0x002e 00046 (<source>:18) ADDQ $24, SP
0x0032 00050 (<source>:18) RET
除了前面看到的多一次内存寻址外,从这段指令:
0x0013 00019 (<source>:16) LEAQ type.int(SB), AX
0x001a 00026 (<source>:16) CALL runtime.newobject(SB)
还可以看到,变量 a 逃逸到了堆上。
至于原因,想想也很容易理解。当直接调用函数时,由于编译器可以看得到函数的实现,知道函数是否会把 a 的地址存下来供后续使用;但是当通过函数指针间接调用时,就无法判断,因此为了避免出现野指针,只能保守起见,把 a 分配到堆上。而堆分配比栈分配慢得多。
通过编译选项“-m”也可以查看逃逸分析情况。而且逃逸对性能的影响往往更大,有兴趣可以阅读《通过实例理解 Go 逃逸分析》一文。
https://tonybai.com/2021/05/24/understand-go-escape-analysis-by-example/
相应的代码详情:https://godbolt.org/z/Khs8E1M6h
03
3.1 switch 语句
当函数指针的数量不多时,通过 switch 语句直接调用,可以消除闭包和变量逃逸的开销。
比如在 time 包的时间解析和格式化库中就用了这种方式:
https://github.com/golang/go/blob/go1.19/src/time/format.go#L648
switch std & stdMask {
case stdYear:
y := year
if y < 0 {
y = -y
}
b = appendInt(b, y%100, 2)
case stdLongYear:
b = appendInt(b, year, 4)
case stdMonth:
b = append(b, month.String()[:3]...)
case stdLongMonth:
m := month.String()
b = append(b, m...)
3.2 noescape
要在函数指针上避免变量逃逸,Go 源代码中提供了一种方案:
https://github.com/golang/go/blob/go1.19/src/runtime/stubs.go#L213-L223
// noescape hides a pointer from escape analysis. noescape is
// the identity function but escape analysis doesn't think the
// output depends on the input. noescape is inlined and currently
// compiles down to zero instructions.
// USE CAREFULLY!
//
//go:nosplit
func noescape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}
也就是通过对指针进行一次实际不改变结果的位运算,让逃逸分析认为指针不再和原来的变量有关系。正如注释说明的那样,使用时需要谨慎,确保函数内不会把变量的地址保存下来供后续使用。
04
Go 语言实现函数指针的方式,在性能方面,除了在 C/C++ 中也存在的无法被inline 外,还有增加了一次寻址,导致变量逃逸等新的影响,因此其对程序性能的影响要比 C/C++ 要大。
本文并非反对使用函数指针,只是指出在确实需要进行微观层面的深度优化的时候,函数是一个要值得注意的切入点。对于大部分日常代码,从代码的可读性/可维护性选择即可,不需要过于担心。