導(dǎo)讀
Go 語(yǔ)言的常規(guī)優(yōu)化手段無(wú)需贅述,相信大家也能找到大量的經(jīng)典教程。但基于 Go 的函數(shù)值問(wèn)題,業(yè)界還沒(méi)有太多深度討論的內(nèi)容分享。本文作者根據(jù)自己對(duì) Go 代碼的使用與調(diào)優(yōu)經(jīng)驗(yàn),分享了 Go 的函數(shù)值對(duì)性能影響的原因以及優(yōu)化方案,值得深度閱讀!
目錄
1 背景
2 函數(shù)調(diào)用的實(shí)現(xiàn)方式
3 優(yōu)化
4 結(jié)論
5 參考資料
01
背景
最近在嘗試做一些 Go 代碼的微觀代碼優(yōu)化時(shí),發(fā)現(xiàn)由于 Go 中函數(shù)調(diào)用機(jī)制的影響,性能會(huì)比 C/C++ 等語(yǔ)言慢一些,而且有指針類型的參數(shù)時(shí),影響會(huì)更大。
本文對(duì)其背后的原因進(jìn)行初步的分析,并提供一些優(yōu)化建議以便在必要時(shí)采用,期望對(duì)讀者有所幫助。
需要注意的是,在 Go 中本身并沒(méi)有函數(shù)指針的概念,而是稱為“函數(shù)值”,但是為了能和其他語(yǔ)言進(jìn)行相應(yīng)的比較,以及和直接調(diào)用的函數(shù)相區(qū)別,還是稱之為“函數(shù)指針”。
02
函數(shù)調(diào)用的實(shí)現(xiàn)方式
要了解函數(shù)的調(diào)用機(jī)制,需要了解一點(diǎn)點(diǎn)匯編語(yǔ)言,不過(guò)無(wú)需擔(dān)心,不會(huì)太復(fù)雜。
為了清晰起見,Go 代碼生成的匯編均已去掉了 FUNCDATA 和 PCDATA 等非運(yùn)行的偽指令。
以下均針對(duì) x86-64 平臺(tái)做分析。
2.1 C 語(yǔ)言中的函數(shù)指針
1.普通函數(shù)
源代碼:
int Add(int a, int b) { return a + b; }
生成的代碼:
Add:
lea eax, [rdi+rsi]
ret
根據(jù) x86-64/linux 下 C 語(yǔ)言的調(diào)用約定,前兩個(gè)整數(shù)參數(shù)是通過(guò) RDI 和 RS 寄存器傳遞的。因此以上代碼相當(dāng)于:
eax = rdi + rsi
return eax
非常的簡(jiǎn)潔直白。
2.生成函數(shù)指針
源代碼:
int (*MakeAdd())(int, int) { return Add; }
生成的代碼:
MakeAdd:
mov eax, OFFSET FLAT:Add
ret
以上代碼直接通過(guò) eax 寄存器返回了函數(shù)的地址。
3.通過(guò)函數(shù)指針間接調(diào)用
源代碼:
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 函數(shù)的第一個(gè)參數(shù),也就是函數(shù)的地址,后來(lái)賦值給 rbx 寄存器,后續(xù)的調(diào)用都是通過(guò) rbx 寄存器進(jìn)行的,第二次調(diào)用時(shí)甚至優(yōu)化掉了調(diào)用,直接跳轉(zhuǎn)到了函數(shù)的地址。實(shí)際上如果只有一次函數(shù)調(diào)用,那么生成的代碼里就只有 jmp 而沒(méi)有 call 了。
詳情參見
https://godbolt.org/z/GTbjv5o9G
2.2 Go 中的函數(shù)及函數(shù)指針調(diào)用
我們?cè)賮?lái)看一下在 Go 語(yǔ)言中函數(shù)調(diào)用的方式。
1.Go 語(yǔ)言中的函數(shù)和函數(shù)指針
Go 函數(shù)的代碼:
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 編譯器開始使用基于寄存器的調(diào)用約定,前兩個(gè)整數(shù)參數(shù)分別通過(guò) AX,BX 傳遞,返回值也是通過(guò)同樣的寄存器序列。可以看出,除了所用的寄存器不一樣,和 C 生成的代碼還是比較相似的,性能應(yīng)該也接近。
對(duì)于調(diào)用 Go 函數(shù)的代碼:
//go:nosplit
func 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
除了調(diào)用約定不一樣外,看起來(lái)和 C 的函數(shù)調(diào)用也差別不大。
但是,我們馬上就能看到,通過(guò)函數(shù)指針調(diào)用 Go 函數(shù)時(shí),和 C 代碼大不一樣!
2. 通過(guò)函數(shù)指針間接調(diào)用 Go 函數(shù)
源代碼:
//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 (<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的復(fù)雜多了(注意C版本里有兩次函數(shù)調(diào)用,一次調(diào)用只有3條指令)。
CALL 指令前的2字節(jié) 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 的版本中,真正的函數(shù)地址是從 AX 寄存器指向的地址讀取到后放到 CX 寄存器中,然后還要把函數(shù)值的地址設(shè)置到 DX 寄存器中。但是從上面的 Add 函數(shù)的代碼看,DX 寄存器并沒(méi)有用到,這個(gè)無(wú)用功是為了什么呢?
我們先看一下函數(shù)是如何返回函數(shù)指針的:
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
看起來(lái)和 C 的差不多是不是?仔細(xì)看卻不一樣,比起真正的 Add 函數(shù)名,多了個(gè) ·f 后綴。
找到,main.Add·f,發(fā)現(xiàn)其代碼是:
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 中,函數(shù)指針并不直接指向函數(shù)所在的地址,而是指向一段數(shù)據(jù),這里放著的才是真正的函數(shù)地址。
那么為什么 Go 要這么繞呢?
Go 函數(shù)和 C 函數(shù)最大的區(qū)別是,Go 支持內(nèi)嵌匿名函數(shù),并且在匿名函數(shù)中可以訪問(wèn)到所在函數(shù)的局部變量,例如下面這個(gè)返回閉包的函數(shù):
func MakeAddN(n int) func(int, int) int {
return func(a, b int) int {
return n + a + b
}
}
對(duì)于 C 函數(shù),在其返回后,n 就應(yīng)該已經(jīng)被銷毀了。但是對(duì)于 Go 函數(shù),拿到 Go 返回的函數(shù)時(shí),在次調(diào)用時(shí),n 還是可以訪問(wè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 后綴的對(duì)象地址,而是指向一塊動(dòng)態(tài)分配的 struct,其定義為:
type.noalg.struct { F uintptr; main.n int }
其中 F 指向真正的嵌套函數(shù)的代碼,n 則是捕獲的所屬函數(shù)的局部變量。
嵌套函數(shù)實(shí)際上也是一個(gè)真正的函數(shù),但是比起普通的函數(shù),多了個(gè)從 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 兩個(gè)參數(shù),而 DX 就是函數(shù)指針對(duì)象自身的地址,8(DX) 就是其源代碼中的 n。
在非正式的文檔中,DX 被稱為上下文寄存器(context register)。
https://stackoverflow.com/questions/41067095/what-is-a-context-register-in-golang
因此可以知道,返回函數(shù)時(shí),如果函數(shù)捕獲了變量,也會(huì)導(dǎo)致內(nèi)存分配。
Go 代碼
https://godbolt.org/z/TdKW9eaTT
2.3 逃逸分析對(duì)性能的影響
除了為了統(tǒng)一支持閉包所需要付出的開銷外,對(duì) Go 的函數(shù)指針的調(diào)用還會(huì)影響到逃逸分析,會(huì)導(dǎo)致本來(lái)可以分配在棧上的對(duì)象不得不逃逸到堆上。這種情況出現(xiàn)在函數(shù)的參數(shù)有指針類型時(shí)。
對(duì)于使用指針函數(shù):
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
生成的代碼看起來(lái)和 C 語(yǔ)言的很像:
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
在調(diào)用處:
//go:nosplit
func 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
看起來(lái)和 C 中的也很像。
但是當(dāng)通過(guò)函數(shù)指針調(diào)用時(shí):
//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 (<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
除了前面看到的多一次內(nèi)存尋址外,從這段指令:
0x0013 00019 (<source>:16) LEAQ type.int(SB), AX
0x001a 00026 (<source>:16) CALL runtime.newobject(SB)
還可以看到,變量 a 逃逸到了堆上。
至于原因,想想也很容易理解。當(dāng)直接調(diào)用函數(shù)時(shí),由于編譯器可以看得到函數(shù)的實(shí)現(xiàn),知道函數(shù)是否會(huì)把 a 的地址存下來(lái)供后續(xù)使用;但是當(dāng)通過(guò)函數(shù)指針間接調(diào)用時(shí),就無(wú)法判斷,因此為了避免出現(xiàn)野指針,只能保守起見,把 a 分配到堆上。而堆分配比棧分配慢得多。
通過(guò)編譯選項(xiàng)“-m”也可以查看逃逸分析情況。而且逃逸對(duì)性能的影響往往更大,有興趣可以閱讀《通過(guò)實(shí)例理解 Go 逃逸分析》一文。
https://tonybai.com/2021/05/24/understand-go-escape-analysis-by-example/
相應(yīng)的代碼詳情:
https://godbolt.org/z/Khs8E1M6h
03
優(yōu)化
3.1 switch 語(yǔ)句
當(dāng)函數(shù)指針的數(shù)量不多時(shí),通過(guò) switch 語(yǔ)句直接調(diào)用,可以消除閉包和變量逃逸的開銷。
比如在 time 包的時(shí)間解析和格式化庫(kù)中就用了這種方式:
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 里。我在嘗試實(shí)現(xiàn) strftime 和 strptime 時(shí)一開始覺(jué)得如果用函數(shù)指針的方式代碼會(huì)更簡(jiǎn)單一些,但是實(shí)際卻發(fā)現(xiàn)了性能問(wèn)題,也選擇了采用 switch。
3.2 noescape
要在函數(shù)指針上避免變量逃逸,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)
}
也就是通過(guò)對(duì)指針進(jìn)行一次實(shí)際不改變結(jié)果的位運(yùn)算,讓逃逸分析認(rèn)為指針不再和原來(lái)的變量有關(guān)系。正如注釋說(shuō)明的那樣,使用時(shí)需要謹(jǐn)慎,確保函數(shù)內(nèi)不會(huì)把變量的地址保存下來(lái)供后續(xù)使用。
04
結(jié)論
Go 語(yǔ)言實(shí)現(xiàn)函數(shù)指針的方式,在性能方面,除了在 C/C++ 中也存在的無(wú)法被inline 外,還有增加了一次尋址,導(dǎo)致變量逃逸等新的影響,因此其對(duì)程序性能的影響要比 C/C++ 要大。
本文并非反對(duì)使用函數(shù)指針,只是指出在確實(shí)需要進(jìn)行微觀層面的深度優(yōu)化的時(shí)候,函數(shù)是一個(gè)要值得注意的切入點(diǎn)。對(duì)于大部分日常代碼,從代碼的可讀性/可維護(hù)性選擇即可,不需要過(guò)于擔(dān)心。
作者:陳峰
來(lái)源:微信公眾號(hào):騰訊云開發(fā)者
出處
:https://mp.weixin.qq.com/s/bcmvPbWV7nBi7wIfr-MR8w