查看原文
其他

Go函数指针是如何让你的程序变慢的?

陈峰 腾讯云开发者 2024-01-11

👉导读

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 (<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




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++ 要大。


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


-End-
原创作者|陈峰


  


除了函数指针以外,你还知道哪些会影响到 Go 程序性能的优化点?欢迎评论分享你的看法。我们将选取1则最价值的评论,送出腾讯云开发者社区定制鼠标垫1个(见下图)。12月19日中午12点开奖。


📢📢欢迎加入腾讯云开发者社群,社群专享券、大咖交流圈、第一手活动通知、限量鹅厂周边等你来~

(长按图片立即扫码)


继续滑动看下一个

Go函数指针是如何让你的程序变慢的?

陈峰 腾讯云开发者

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存