Go-函数指针-潜在的程序性能杀手 (go函数指针参数)
导读
Go 语言的常规优化手段无需赘述,相信大家也能找到大量的经典教程。但基于 Go 的函数值问题,业界还没有太多深度讨论的内容分享。本文作者根据自己对 Go 代码的使用与调优经验,分享了 Go 的函数值对性能影响的原因以及优化方案,值得深度阅读!目录
[背景](背景) [函数调用的实现方式](函数调用的实现方式) [C 语言中的函数指针](C语言中的函数指针) [Go 中的函数及函数指针调用](Go中的函数及函数指针调用) [优化](优化) [结论](结论) [参考资料](参考资料)背景
最近在尝试做一些 Go代码的微观代码优化时,发现由于 Go 中函数调用机制的影响,性能会比 C/C++ 等语言慢一些,而且有指针类型的参数时,影响会更大。本文对其背后的原因进行初步的分析,并提供一些优化建议以便在必要时采用,期望对读者有所帮助。 需要注意的是,在 Go 中本身并没有函数指针的概念,而是称为函数值,但是为了能和其他语言进行相应的比较,以及和直接调用的函数相区别,还是称之为函数指针。函数调用的实现方式
要了解函数的调用机制,需要了解一点点汇编语言,不过无需担心,不会太复杂。为了清晰起见,Go 代码生成的汇编均已去掉了 FUNCDATA 和 PCDATA 等非运行的伪指令。以下均针对 x86-64 平台做分析。C 语言中的函数指针
1. 普通函数
```c int add(int a, int b) { return a + b; } ``` 生成的代码: ```asm add: leaeax, [rdi + rsi] ret 根据 x86-64/下 C 语言的调用约定,前两个整数参数是通过 RDI 和 RSI 寄存器传递的。因此以上代码相当于: ```asm eax = rdi + rsi return eax ``` 非常的简洁直白。2. 生成函数指针
```c int (makeAdd())(int, int) { return add; } ``` 生成的代码: ```asm MakeAdd: moveax, OFFSETFLAT:add ret ``` 以上代码直接通过eax寄存器返回了函数的地址。3. 通过函数指针间接调用
```c int callAdd(int (add)(int, int)) { add(1, 2); add(1, 2); } ``` 生成的代码: ```asm CallAdd: pushrbx movrbx, rdi movesi, 2 movedi, 1 callrbx movrax, rbx movesi, 2 movedi, 1 poprbx jmprax ``` 以上代码中,rdi 为 CallAdd 函数的第一个参数,也就是函数的地址,后来赋值给 rbx 寄存器,后续的调用都是通过 rbx 寄存器进行的,第二次调用时甚至优化掉了调用,直接跳转到了函数的地址。实际上如果只有一次函数调用,那么生成的代码里就只有 jmp 而没有 call 了。详情参见 [。Go 中的函数及函数指针调用
我们再来看一下在 Go 语言中函数调用的方式。1. Go 语言中的函数和函数指针
Go 函数的代码: ```go func add(a, b int) int { return a + b } ``` 生成的代码: ```asm mn.Add: STEXTnosplitsize=4args=0x10locals=0x0funcid=0x0align=0x00 ADDQBX, AX RET ``` 从 Go 1.17 开始,x86-64 下的 Go 编译器开始使用基于寄存器的调用约定,前两个整数参数分别通过 AX,BX 传递,返回值也是通过同样的寄存器序列。可以看出,除了所用的寄存器不一样,和 C 生成的代码还是比较相似的,性能应该也接近。 对于调用 Go 函数的代码: ```go //go:nosplit func callAdd() { add(1, 2) } ``` 生成的代码: ```asm main.CallAdd: STEXTnosplitsize=39args=0x0locals=0x18funcid=0x0align=0x00 SUBQ$24, SP MOVQBP, 16(SP) CALLmn.Add MOVQ16(SP), AX ADDQ$24, SP RET ``` 可以看到,在 Go 中调用函数需要通过 CALL 指令,而 C 语言中则可以通过 jmp 直接跳转到函数地址。这会引入额外的开销。2. 通过函数值间接调用
在 Go 中,可以通过接口类型来实现函数指针。代码如下: ```go type Adder interface { add(a, b int) int } func callAdd(adder Adder) { adder.add(1, 2) } ``` 生成的代码: ```asm main.CallAdd: STEXTnosplitsize=39args=0x8locals=0x18funcid=0x0align=0x00 SUBQ$24, SP MOVQBP, 16(SP) // adder CALLinterfacer00000000002(SB) MOVQ8(SP), AX // adder.add MOVQ16(SP), CX // adder MOVQBP, 0(SP) // 1 MOVQL, 8(SP)// 2 CALLAX // adder.add(1, 2) MOVQ16(SP), AX ADDQ$24, SP RET ``` 可以看到,通过接口调用函数需要经过一层间接调用,这会进一步引入开销。优化
针对 Go 中函数值对性能的影响,可以采取以下优化措施: 避免使用函数值: 如果可能,避免使用函数值。直接调用函数可以避免额外的间接调用开销。 使用内联: 如果函数体很小,可以使用内联来消除函数调用开销。 使用指针类型参数: 如果函数的参数是指针类型,则可以避免传递指针的值,从而减少开销。 使用接口类型: 如果无法避免使用函数值,则可以使用接口类型来实现函数指针。接口类型可以减少间接调用开销。结论
Go 中的函数值对性能的影响是一个值得关注的问题。通过理解函数调用的实现方式,可以采取适当的优化措施来提高性能。在必要时,可以考虑避免使用函数值,使用内联,使用指针类型参数,或使用接口类型。参考资料
[Go 汇编语言文档]([Godbolt 在线汇编分析工具]([函数值性能问题讨论](Go GC:优先考虑低延迟和简单
Go 正在构建的 GC 不仅仅是面向于 2015 年,而是面向于 2025 年甚至更久之后:一个支持当局的软件开发并且可以同未来十年新的软件硬件一同扩展的 GC。
Go 1.5,是看到未来的第一瞥, 成功完成了我们一年前设定的 GC 延迟小于 10ms 的目标 。这篇文章综述了我们在 Go 1.5 的废品回收器上做了什么。
Go 的新 GC 是一个 并发的、三色的、标记清除的废品回收器(a concurrent, tri-color, mark-sweep collector)。我们相信它会非常适合于新时代硬件的性能和新时代软件的低延迟需求。
这些步骤都(?)发生在与应用程序并发阶段,(这个应用程序)被称为mutator (翻译为“赋值器”),是它在废品回收进行时改变了变量的引用。 因为mutator必须保持没有黑色对象指向白色对象,以免 GC 跟丢它在堆上已经访问过的变量。 维持这种不变是 写屏障 (write barrier)的职责,它是mutator在堆中的指针被更新时运行的一个小函数(function)。 如果当前可达对象是白色,Go 的写屏障标记它为灰色,以确保 GC 最终能扫描到它的引用。
确认在什么时候所有的灰色对象被完全标记完了很微妙,而且如果我们想避免 block 应用程序(mutators)代价非常昂贵而且复杂。为了让事情简单, Go 1.5 做足了工作让它可以并行,然后短暂地 STW 去检查所有潜在的灰色对象。找到最终 STW 所需的时间与该 GC 所做的工作总量之间的最佳结合点是在 Go 1.6 主要交付的内容。
魔鬼在于细节。GC 过程的具体细节的低级讨论就不在这里讨论了(可参考原文有哪些问题)。 在更高级别上,解决 Go 性能问题的方法是增加了 GC 旋钮 (knobs),每一个性能问题有一个旋钮。程序员可以旋转这个旋钮来为他们的应用程序寻找合适的设置。不利的一面是,在十年之后你每年使用一个或两个新的旋钮,你终将遇到《GC 旋钮开关使用法》(GC Knobs Turner Employment Act),Go 不会走这条路。转而,Go 提供了一个旋钮,它叫 GOGC。
GOGC控制了涉及到可达对象空间的堆的总大小。默认是 100,意味着在上一次收集之后,堆的大小比可达对象的空间大小大 100% 倍(也就是两倍大)。200 就意味着是 3 倍大。如果你想降低 GC 的总时间,那就增加 GOGC;如果你想用更多的 GC 时间来换取更少的内存,那就降低 GOGC。
更重要得,随着下一代硬件中 RAM 增倍(双倍),简单地设置 GOGC 增倍(双倍)将使用 GC 循环数减半。另一方面,既然 GOGC 是根据可达对象的空间大小来的,将可达对象增倍来让负载增倍不需要重调(retune)。应用自己扩展了。更进一步得,没有众多旋钮(knob)的困扰,团队可以专注于客户应用的反馈提高运行性能。
Go 1.5 指向了一个未来,在未来 STW 的暂停不再是使用安全语言的屏障,应用可以轻松得与硬件扩展,并且随着硬件变得越来越强大,GC 将不会成为更好、更具可扩展性的软件的障碍。
上面提到了这些术语和概念:
下面赋两张 PPT 中的图:
为什么要使用 Go 语言?Go 语言的优势在哪里
1. 保留但大幅度简化指针Go语言保留着C中值和指针的区别,但是对于指针繁琐用法进行了大量的简化,引入引用的概念。 所以在Go语言中,你几乎不用担心会因为直接操作内寸而引起各式各样的错误。 2. 多参数返回还记得在C里面为了回馈多个参数,不得不开辟几段指针传到目标函数中让其操作么?在Go里面这是完全不必要的。 而且多参数的支持让Go无需使用繁琐的exceptions体系,一个函数可以返回期待的返回值加上error,调用函数后立刻处理错误信息,清晰明了。 3. Array,slice,map等内置基本数据结构如果你习惯了Python中简洁的list和dict操作,在Go语言中,你不会感到孤单。 一切都是那么熟悉,而且更加高效。 如果你是C++程序员,你会发现你又找到了STL的vector 和 map这对朋友。 4. InterfaceGo语言最让人赞叹不易的特性,就是interface的设计。 任何数据结构,只要实现了interface所定义的函数,自动就implement了这个interface,没有像Java那样冗长的class申明,提供了灵活太多的设计度和OO抽象度,让你的代码也非常干净。 千万不要以为你习惯了Java那种一条一条加implements的方式,感觉还行,等接口的设计越来越复杂的时候,无数Bug正在后面等着你。 同时,正因为如此,Go语言的interface可以用来表示任何generic的东西,比如一个空的interface,可以是string可以是int,可以是任何数据类型,因为这些数据类型都不需要实现任何函数,自然就满足空interface的定义了。 加上Go语言的type assertion,可以提供一般动态语言才有的duck typing特性, 而仍然能在compile中捕捉明显的错误。 5. OOGo语言本质上不是面向对象语言,它还是过程化的。 但是,在Go语言中, 你可以很轻易的做大部分你在别的OO语言中能做的事,用更简单清晰的逻辑。 是的,在这里,不需要class,仍然可以继承,仍然可以多态,但是速度却快得多。 因为本质上,OO在Go语言中,就是普通的struct操作。 6. Goroutine这个几乎算是Go语言的招牌特性之一了,我也不想多提。 如果你完全不了解Goroutine,那么你只需要知道,这玩意是超级轻量级的类似线程的东西,但通过它,你不需要复杂的线程操作锁操作,不需要care调度,就能玩转基本的并行程序。 在Go语言里,触发一个routine和erlang spawn一样简单。 基本上要掌握Go语言,以Goroutine和channel为核心的内存模型是必须要懂的。 不过请放心,真的非常简单。 7. 更多现代的特性和C比较,Go语言完全就是一门现代化语言,原生支持的Unicode, garbage collection, Closures(是的,和functional programming language类似), function是first class object,等等等等。 看到这里,你可能会发现,我用了很多轻易,简单,快速之类的形容词来形容Go语言的特点。 我想说的是,一点都不夸张,连Go语言的入门学习到提高,都比别的语言门槛低太多太多。 在大部分人都有C的背景的时代,对于Go语言,从入门到能够上手做项目,最多不过半个月。 Go语言给人的感觉就是太直接了,什么都直接,读源代码直接,写自己的代码也直接。
免责声明:本文转载或采集自网络,版权归原作者所有。本网站刊发此文旨在传递更多信息,并不代表本网赞同其观点和对其真实性负责。如涉及版权、内容等问题,请联系本网,我们将在第一时间删除。同时,本网站不对所刊发内容的准确性、真实性、完整性、及时性、原创性等进行保证,请读者仅作参考,并请自行核实相关内容。对于因使用或依赖本文内容所产生的任何直接或间接损失,本网站不承担任何责任。