首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

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 + rsireturn 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 (:4) ADDQ BX, AX 0x0003 00003 (:4) RET

从 Go1.17 开始,x86-64 下的 Go 编译器开始使用基于寄存器的调用约定,前两个整数参数分别通过 AX,BX 传递,返回值也是通过同样的寄存器序列。可以看出,除了所用的寄存器不一样,和 C 生成的代码还是比较相似的,性能应该也接近。

对于调用 Go 函数的代码:

//go:nosplitfunc CallAdd() { Add(1, 2)}

生成的代码:

main.CallAdd STEXT nosplit size=39 args=0x0 locals=0x18 funcid=0x0 align=0x0 0x0000 00000 (:9) SUBQ $24, SP 0x0004 00004 (:9) MOVQ BP, 16(SP) 0x0009 00009 (:9) LEAQ 16(SP), BP 0x000e 00014 (:10) MOVL $1, AX 0x0013 00019 (:10) MOVL $2, BX 0x0018 00024 (:10) CALL main.Add(SB) 0x001d 00029 (:11) MOVQ 16(SP), BP 0x0022 00034 (:11) ADDQ $24, SP 0x0026 00038 (:11) RET

除了调用约定不一样外,看起来和 C 的函数调用也差别不大。

但是,我们马上就能看到,通过函数指针调用 Go 函数时,和 C 代码大不一样!

2. 通过函数指针间接调用 Go 函数

源代码:

//go:nosplitfunc 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 (:29) SUBQ $24, SP 0x0004 00004 (:29) MOVQ BP, 16(SP) 0x0009 00009 (:29) LEAQ 16(SP), BP

0x000e 00014 (:30) MOVQ (AX), CX 0x0011 00017 (:30) MOVL $2, BX 0x0016 00022 (:30) MOVQ AX, DX 0x0019 00025 (:30) MOVL $1, AX 0x001e 00030 (:30) NOP 0x0020 00032 (:30) CALL CX

0x0022 00034 (:31) MOVQ 16(SP), BP 0x0027 00039 (:31) ADDQ $24, SP 0x002b 00043 (: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 (:15) LEAQ main.Add·f(SB), AX 0x0007 00007 (: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 (:21) SUBQ $24, SP 0x0004 00004 (:21) MOVQ BP, 16(SP) 0x0009 00009 (:21) LEAQ 16(SP), BP 0x000e 00014 (:22) MOVQ AX, main.n+32(SP) 0x0013 00019 (:22) PCDATA $3, $-1 0x0013 00019 (:22) LEAQ type.noalg.struct { F uintptr; main.n int }(SB), AX 0x001a 00026 (:22) CALL runtime.newobject(SB) 0x001f 00031 (:22) LEAQ main.MakeAddN.func1(SB), CX 0x0026 00038 (:22) MOVQ CX, (AX) 0x0029 00041 (:22) MOVQ main.n+32(SP), CX 0x002e 00046 (:22) MOVQ CX, 8(AX) 0x0032 00050 (:22) MOVQ 16(SP), BP 0x0037 00055 (:22) ADDQ $24, SP 0x003b 00059 (:22) RET

返回值不再指向全局的 ·f 后缀的对象地址,而是指向一块动态分配的 struct,其定义为:

其中 F 指向真正的嵌套函数的代码,n 则是捕获的所属函数的局部变量。

嵌套函数实际上也是一个真正的函数,但是比起普通的函数,多了个从 DX 寄存器读取的值操作:

main.MakeAddN.func1 STEXT nosplit size=8 args=0x10 locals=0x0 funcid=0x0 align=0x0 0x0000 00000 (:23) ADDQ 8(DX), AX 0x0004 00004 (:23) ADDQ BX, AX 0x0007 00007 (: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 (:23) ADDQ 8(DX), AX 0x0004 00004 (:23) ADDQ BX, AX 0x0007 00007 (:23) RET

生成的代码看起来和 C 语言的很像:

main.Set STEXT nosplit size=8 args=0x8 locals=0x0 funcid=0x0 align=0x0 0x0000 00000 (:5) MOVQ $1, (AX) 0x0007 00007 (:6) RET

在调用处:

//go:nosplitfunc CallSet() { a := 0 Set(&a) }

生成的代码为:

main.CallSet STEXT nosplit size=47 args=0x0 locals=0x18 funcid=0x0 align=0x0 0x0000 00000 (:9) SUBQ $24, SP 0x0004 00004 (:9) MOVQ BP, 16(SP) 0x0009 00009 (:9) LEAQ 16(SP), BP 0x000e 00014 (:10) MOVQ $0, main.a+8(SP) 0x0017 00023 (:11) LEAQ main.a+8(SP), AX 0x001c 00028 (:11) NOP 0x0020 00032 (:11) CALL main.Set(SB) 0x0025 00037 (:12) MOVQ 16(SP), BP 0x002a 00042 (:12) ADDQ $24, SP 0x002e 00046 (:12) RET

看起来和 C 中的也很像。

但是当通过函数指针调用时:

//go:nosplitfunc CallSetPtr(set func(*int)) { a := 0 set(&a) }

生成的代码:

main.CallSetPtr STEXT nosplit size=51 args=0x8 locals=0x18 funcid=0x0 align=0x0 0x0000 00000 (:15) TEXT main.CallSetPtr(SB), NOSPLIT|ABIInternal, $24-8 0x0000 00000 (:15) SUBQ $24, SP 0x0004 00004 (:15) MOVQ BP, 16(SP) 0x0009 00009 (:15) LEAQ 16(SP), BP 0x000e 00014 (:15) MOVQ AX, main.set+32(SP) 0x0013 00019 (:16) LEAQ type.int(SB), AX 0x001a 00026 (:16) CALL runtime.newobject(SB) 0x001f 00031 (:17) MOVQ main.set+32(SP), DX 0x0024 00036 (:17) MOVQ (DX), CX 0x0027 00039 (:17) CALL CX 0x0029 00041 (:18) MOVQ 16(SP), BP 0x002e 00046 (:18) ADDQ $24, SP 0x0032 00050 (:18) RET

除了前面看到的多一次内存寻址外,从这段指令:

0x0013 00019 (:16) LEAQ type.int(SB), AX0x001a 00026 (: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...)

格式化不同字段的代码放在不同的 case 里。我在尝试实现 strftime 和 strptime 时一开始觉得如果用函数指针的方式代码会更简单一些,但是实际却发现了性能问题,也选择了采用 switch。

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:nosplitfunc noescape(p unsafe.Pointer) unsafe.Pointer { x := uintptr(p) return unsafe.Pointer(x ^ 0)}

也就是通过对指针进行一次实际不改变结果的位运算,让逃逸分析认为指针不再和原来的变量有关系。正如注释说明的那样,使用时需要谨慎,确保函数内不会把变量的地址保存下来供后续使用。

04

结论

Go 语言实现函数指针的方式,在性能方面,除了在 C/C++ 中也存在的无法被inline 外,还有增加了一次寻址,导致变量逃逸等新的影响,因此其对程序性能的影响要比 C/C++ 要大。

本文并非反对使用函数指针,只是指出在确实需要进行微观层面的深度优化的时候,函数是一个要值得注意的切入点。对于大部分日常代码,从代码的可读性/可维护性选择即可,不需要过于担心。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/Of3E22zUz0shVk2DRFBnz0cA0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券