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 (:4) ADDQ BX, AX
    0x0003 00003 (: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 (: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: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 (: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,其定义为:
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 (: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:nosplit
func 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: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 (: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), AX
0x001a 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:nosplit
func noescape(p unsafe.Pointer) unsafe.Pointer {
  x := uintptr(p)
  return unsafe.Pointer(x ^ 0)
}

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

04

结论

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

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

-End-

原创作者|陈峰

推荐阅读:

2024线下活动全面启动,1月6日深圳站不见不散~

Kubernetes创始人发声!K8s在被反噬!

Go函数指针是如何让你的程序变慢的?_第1张图片

快点击阅读原文报名吧~~

你可能感兴趣的:(golang,算法,开发语言,后端)