导读|Go的函数调用时参数是通过栈传递还是寄存器传递?使用哪个版本的Go语言能让程序运行性能提升5%?腾讯后台开发工程师涂明光将带你由浅入深了解函数调用,并结合不同版本Go进行实操解答。
函数调用基本概念
1)调用者caller与被调用者callee
如果一个函数调用另外一个函数,那么该函数被称为调用者函数,也叫做caller,而被调用的函数称为被调用者函数,也叫做callee。比如函数main中调用sum函数,那么main就是caller,而sum函数就是callee。
2)函数栈和函数栈帧
函数执行时需要有足够的内存空间,供它存放局部变量、参数等数据,这段空间对应到虚拟地址空间的栈,也即函数栈。在现代主流机器架构上(例如x86)中,栈都是向下生长的。栈的增长方向是从高位地址到地位地址向下进行增长。
分配给一个个函数的栈空间被称为“函数栈帧”。Go语言中函数栈帧布局是这样的:先是调用者caller栈基地址,然后是调用者函数caller的局部变量、接着是被调用函数callee的返回值和参数。然后是被调用者callee的栈帧。
注意,栈和栈帧是不一样的。在一个函数调用链中,比如函数A调用B,B调用C,则在函数栈上,A的栈帧在上面,下面依次是B、C的函数栈帧。Go1.17以前的版本,函数栈空间布局如下:
函数调用分析
通过在centos8上安装gvm,可以方便切换多个Go版本测试不同版本的特性。
gvm地址:https://github.com/moovweb/gvm
执行:
显示gvm安装的go版本列表:
gvm gos (installed)
1)Go15版本函数调用分析
执行
切换到 go1.15.14版本,我们定义一个函数调用:
使用命令打印出main.go汇编:
接下来我们分析main函数的汇编代码:
从汇编代码的注释中,我们可以清楚的看到,main函数调用A函数的局部变量、入参在栈中的存储位置。main函数通过 ADDQ $-128, SP 指令,一共在栈上分配了128字节的内存空间:
SP+64~SP+112 指向的56个栈空间,存储的是r1~r7这7个main函数的局部变量;SP+56 该地址接收函数A的返回值;SP~SP+48 指向的56个字节空间,用来存放A函数的 7 个入参。
综上,在Go1.15.14版本的函数调用中:参数完全通过栈传递;参数列表从右至左依次压栈。当程序准备好函数的入参之后,会调用汇编指令CALL "".A(SB),这个指令首先会将 main 的返回地址 (8 bytes) 存入栈中,然后改变当前的栈指针 SP 并执行 A 函数的汇编指令。栈空间变为:
下面分析 A 函数:
需要注意的是,"".~r7+64(SP)是上图中,main函数用来接收A函数返回值的地址SP+56,因为CALL "".A(SB)将main返回地址压栈后,SP向下移动了8字节。
从A函数的汇编分析,可以得到结论:Go1.17.1之前版本,callee函数返回值通过caller栈传递;如果我们让main接收A函数的返回值,会发现callee的返回值也是通过caller的栈空间传递。
2)Go17版本函数调用分析
执行
切换到 go1.17.1版本,修改main.go代码结构如下:
使用命令打印出main.go汇编:
分析main函数的汇编代码:
通过上面汇编代码的注释,我们可以看到:main函数调用A函数的参数个数为11个,其中前 9 个参数分别是通过寄存器 AX、BX、CX、DI、SI、R8、R9、R10、R11传递,后面两个通过栈顶的SP,SP+8地址传递。
下面看 A 函数在Go1.17.1的汇编代码:
在A函数栈中,我们可以看到:程序先把r1~r9参数分别从寄存器赋值到main栈帧的入参地址部分,即当前的SP+48~SP+112位。其实这跟GO1.15.14的函数调用参数传递过程差不多,只不过一个是在caller中做参数从寄存器拷贝到栈上,一个是在callee中做参数从寄存器拷贝到栈上。而且前者只使用了AX一个寄存器,后者使用了9个不同的寄存器。
很多开发者看到这里,估计会有一个疑问:Go1.15 与 Go1.17 在寄存器访问次数上和栈访问次数上,没有区别。只是寄存器上的参数拷贝到栈上的发生时机不同?那么为什么Go1.17会有较高的性能优势?
我们把打印汇编的命令GOOS=linux GOARCH=amd64 go tool compile -S -N -L main.go 中的-N -L禁用内联优化去掉(这才是性能对比的状态),我们再看,会发现Go17 的 A 函数会直接执行寄存器之间的加法,Go15版本的 A 函数不会。
对2.1节程序执行命令:
Go1.15优化后的汇编代码是:
gvm 切换到Go1.17.1版本。对2.1节程序执行命令:
Go1.17优化后的汇编代码是:
对比发现:寄存器传参和栈传参,在编译器实际优化后的执行代码中,前者直接会在寄存器之间做加法,后者多了从栈拷贝数据到寄存器到动作,因此前者效率更高。
通过分析Go1.17.1函数调用过程,我们发现:
参数传递使用了多个寄存器,并且被调用方callee的返回值由callee本身的栈帧负责存放,而不是放在caller的栈帧上;当callee的栈帧被销毁时,其返回值通过AX,BX等寄存器传递给调用方caller。
9个以内的参数通过寄存器传递,9个以外的通过栈传递。如果将 A 函数的返回值个数设置大于9个,同样会发现,9个以内的返回值通过寄存器传递,9个以外的通过栈传递。
为何高版本Go要改用寄存器传参?
至于为什么Go1.17.1函数调用的参数传递开始基于寄存器进行传递,原因无外乎。
第一,CPU访问寄存器比访问栈要快的多。函数调用通过寄存器传参比栈传参,性能要高5%。
第二,早期Go版本为了降低实现的复杂度,统一使用栈传递参数和返回值,不惜牺牲函数调用的性能。
第三,Go从1.17.1版本,开始支持多ABI(application binary interface 应用程序二进制接口,规定了程序在机器层面的操作规范,主要包括调用规约calling convention),主要是两个ABI:一个是老版本Go采用的平台通用ABI0,一个是Go独特的ABIInternal,前者遵循平台通用的函数调用约定,实现简单,不用担心底层cpu架构寄存器的差异;后者可以指定特定的函数调用规范,可以针对特定性能瓶颈进行优化,在多个Go版本之间可以迭代,灵活性强,支持寄存器传参提升性能。
所谓“调用规约(calling convention)”是调用方和被调用方对于函数调用的一个明确的约定,包括:函数参数与返回值的传递方式、传递顺序。只有双方都遵守同样的约定,函数才能被正确地调用和执行。如果不遵守这个约定,函数将无法正确执行。
总结
综合上面的分析,我们得出结论:
Go1.17.1之前的函数调用,参数都在栈上传递;Go1.17.1以后,9个以内的参数在寄存器传递,9个以外的在栈上传递;Go1.17.1之前版本,callee函数返回值通过caller栈传递;Go1.17.1以后,函数调用的返回值,9个以内通过寄存器传递回caller,9个以外在栈上传递。
在Go 1.17的版本发布说明文档中有提到:切换到基于寄存器的调用惯例后,一组有代表性的Go包和程序的基准测试显示,Go程序的运行性能提高了约5%,二进制文件大小减少约2%。
由于CPU访问寄存器的速度要远高于栈内存,参数在栈上传递会增加栈内存空间,并且影响栈的扩缩容和垃圾回收,改为寄存器传递,这些缺点都得到了优化,Go程序在从低版本升级到17版本后,性能有一定的提升。在业务允许的情况下,这里建议各位开发者可以把自己程序的Go版本升级到17及以上。
领取专属 10元无门槛券
私享最新 技术干货