導讀
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 (<source>:4) ADDQ BX, AX
0x0003 00003 (<source>: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 (<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: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的復雜多了(注意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: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
看起來和 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 (<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), AX
0x001a 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:nosplit
func noescape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}
也就是通過對指針進行一次實際不改變結果的位運算,讓逃逸分析認為指針不再和原來的變量有關系。正如注釋說明的那樣,使用時需要謹慎,確保函數內不會把變量的地址保存下來供后續使用。
04
結論
Go 語言實現函數指針的方式,在性能方面,除了在 C/C++ 中也存在的無法被inline 外,還有增加了一次尋址,導致變量逃逸等新的影響,因此其對程序性能的影響要比 C/C++ 要大。
本文并非反對使用函數指針,只是指出在確實需要進行微觀層面的深度優化的時候,函數是一個要值得注意的切入點。對于大部分日常代碼,從代碼的可讀性/可維護性選擇即可,不需要過于擔心。
作者:陳峰
來源:微信公眾號:騰訊云開發者
出處
:https://mp.weixin.qq.com/s/bcmvPbWV7nBi7wIfr-MR8w






