Go函数指针是如何让你的程序变慢的?
2023-12-21 08:53:39 Author: Go语言中文网(查看原文) 阅读量:5 收藏

👉导读

Go 语言的常规优化手段无需赘述,相信大家也能找到大量的经典教程。但基于 Go 的函数值问题,业界还没有太多深度讨论的内容分享。本文作者根据自己对 Go 代码的使用与调优经验,分享了 Go 的函数值对性能影响的原因以及优化方案,值得深度阅读!

👉目录

1 背景
2 函数调用的实现方式
3 优化
4 结论
5 参考资料
背景

最近在尝试做一些 Go 代码的微观代码优化时,发现由于 Go 中函数调用机制的影响,性能会比 C/C++ 等语言慢一些,而且有指针类型的参数时,影响会更大。

本文对其背后的原因进行初步的分析,并提供一些优化建议以便在必要时采用,期望对读者有所帮助。

需要注意的是,在 Go 中本身并没有函数指针的概念,而是称为“函数值”,但是为了能和其他语言进行相应的比较,以及和直接调用的函数相区别,还是称之为“函数指针”。

函数调用的实现方式

要了解函数的调用机制,需要了解一点点汇编语言,不过无需担心,不会太复杂。

为了清晰起见,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 (<source>:4) ADDQ BX, AX    0x0003 00003 (<source>: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 (<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: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 (<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
返回值不再指向全局的 ·f 后缀的对象地址,而是指向一块动态分配的 struct,其定义为:
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:nosplitfunc 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: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 (<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), AX0x001a 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

优化

   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)}

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

结论

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

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

-End-
原创作者|陈峰

推荐阅读

福利
我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。


文章来源: http://mp.weixin.qq.com/s?__biz=MzAxMTA4Njc0OQ==&mid=2651454860&idx=1&sn=3cceb58e18963cbc26fb2597f8dc4c30&chksm=81611df4cf119d4fb607d6040466ec51b9675cf21ab45822c12b1cd5eecc0947406b86fe47d9&scene=0&xtrack=1#rd
如有侵权请联系:admin#unsafe.sh