一文讀懂Go函數調用

圖片

導讀|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 list

顯示gvm安裝的go版本列表:

    go1.14.2
    go1.15.14
    go1.15.7
    go1.16.1
    go1.16.13
    go1.17.1
    go1.18
    go1.18.1
    system

1)Go15版本函數調用分析

執行

gvm use go1.15.14

切換到 go1.15.14版本,我們定義一個函數調用:

package main

func main() {
    var r1, r2, r3, r4, r5, r6, r7 int64 = 1, 2, 3, 4, 5, 6, 7
    A(r1, r2, r3, r4, r5, r6, r7)
}

func A(p1, p2, p3, p4, p5, p6, p7 int64) int64 {
    return p1 + p2 + p3 + p4 + p5 + p6 + p7
}

使用命令打印出main.go彙編:

GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go

接下來我們分析main函數的彙編代碼:

"".main STEXT size=190 args=0x0 locals=0x80
        0x0000 00000 (main.go:3)        TEXT    "".main(SB), ABIInternal, $128-0  #main函數定義, $128-0:128表示將分配的main函數的棧幀大小;0指定了調用方傳入的參數,由於main是最上層函數,這裏沒有入參
        0x0000 00000 (main.go:3)        MOVQ    (TLS), CX          # 將本地線程存儲信息保存到CX寄存器中
        0x0009 00009 (main.go:3)        CMPQ    SP, 16(CX)         # 棧溢出檢測:比較當前棧頂地址(SP寄存器存放的)與本地線程存儲的棧頂地址
        0x000d 00013 (main.go:3)        PCDATA  $0, $-2            # PCDATA,FUNCDATA用於Go彙編額外信息,不必關注
        0x000d 00013 (main.go:3)        JLS     180                # 如果當前棧頂地址(SP寄存器存放的)小於本地線程存儲的棧頂地址,則跳到180處代碼處進行棧分裂擴容操作
        0x0013 00019 (main.go:3)        PCDATA  $0, $-1
        0x0013 00019 (main.go:3)        ADDQ    $-128, SP          # 爲main函數棧幀分配了128字節的空間,注意此時的SP寄存器指向,會往下移動128個字節
        0x0017 00023 (main.go:3)        MOVQ    BP, 120(SP)        # BP寄存器存放的是main函數caller的基址,movq這條指令是將main函數caller的基址入棧。
        0x001c 00028 (main.go:3)        LEAQ    120(SP), BP        # 將main函數的基址存放到到BP寄存器
        0x0021 00033 (main.go:3)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0021 00033 (main.go:3)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0021 00033 (main.go:4)        MOVQ    $1, "".r1+112(SP)  # main函數局部變量r1入棧
        0x002a 00042 (main.go:4)        MOVQ    $2, "".r2+104(SP)  # main函數局部變量r2入棧
        0x0033 00051 (main.go:4)        MOVQ    $3, "".r3+96(SP)   # main函數局部變量r3入棧
        0x003c 00060 (main.go:4)        MOVQ    $4, "".r4+88(SP)   # main函數局部變量r4入棧
        0x0045 00069 (main.go:4)        MOVQ    $5, "".r5+80(SP)   # main函數局部變量r5入棧
        0x004e 00078 (main.go:4)        MOVQ    $6, "".r6+72(SP)   # main函數局部變量r6入棧
        0x0057 00087 (main.go:4)        MOVQ    $7, "".r7+64(SP)   # main函數局部變量r7入棧
        0x0060 00096 (main.go:5)        MOVQ    "".r1+112(SP), AX  # 將局部變量r1傳給寄存器AX
        0x0065 00101 (main.go:5)        MOVQ    AX, (SP)           # 寄存器AX將局部變量r1加入棧頭SP指向的位置
        0x0069 00105 (main.go:5)        MOVQ    "".r2+104(SP), AX  # 將局部變量r2傳給寄存器AX
        0x006e 00110 (main.go:5)        MOVQ    AX, 8(SP)          # 寄存器AX將局部變量r2加入棧頭SP+8指向的位置
        0x0073 00115 (main.go:5)        MOVQ    "".r3+96(SP), AX   # 將局部變量r3傳給寄存器AX
        0x0078 00120 (main.go:5)        MOVQ    AX, 16(SP)         # 寄存器AX將局部變量r3加入棧頭SP+16指向的位置
        0x007d 00125 (main.go:5)        MOVQ    "".r4+88(SP), AX   # 將局部變量r4傳給寄存器AX 
        0x0082 00130 (main.go:5)        MOVQ    AX, 24(SP)         # 寄存器AX將局部變量r4加入棧頭SP+24指向的位置
        0x0087 00135 (main.go:5)        MOVQ    "".r5+80(SP), AX   # 將局部變量r5傳給寄存器AX 
        0x008c 00140 (main.go:5)        MOVQ    AX, 32(SP)         # 寄存器AX將局部變量r4加入棧頭SP+32指向的位置
        0x0091 00145 (main.go:5)        MOVQ    "".r6+72(SP), AX   # 將局部變量r6傳給寄存器AX 
        0x0096 00150 (main.go:5)        MOVQ    AX, 40(SP)         # 寄存器AX將局部變量r6加入棧頭SP+40指向的位置
        0x009b 00155 (main.go:5)        MOVQ    "".r7+64(SP), AX   # 將局部變量r7傳給寄存器AX 
        0x00a0 00160 (main.go:5)        MOVQ    AX, 48(SP)         # 寄存器AX將局部變量r7加入棧頭SP+48指向的位置
        0x00a5 00165 (main.go:5)        PCDATA  $1, $0
        0x00a5 00165 (main.go:5)        CALL    "".A(SB)           # 調用 A函數
        0x00aa 00170 (main.go:6)        MOVQ    120(SP), BP        # 將棧上存儲的main函數的調用方的基地址恢復到BP  
        0x00af 00175 (main.go:6)        SUBQ    $-128, SP          # 增加SP的值,棧收縮,收回分配給main函數棧幀的128字節空間
        0x00b3 00179 (main.go:6)        RET

從彙編代碼的註釋中,我們可以清楚的看到,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 函數:

"".A STEXT nosplit size=50 args=0x40 locals=0x0
        0x0000 00000 (main.go:8)        TEXT    "".A(SB), NOSPLIT|ABIInternal, $0-64  #A函數定義, $0-64:0表示將分配的A函數的棧幀大小;64指定了調用方傳入的參數和函數的返回值的大小,入參7個,返回值1個,都是8字節,共64字節
        0x0000 00000 (main.go:8)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:8)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:8)        MOVQ    $0, "".~r7+64(SP)   # 這裏 SP+64就是上面main棧空間中用來接收返回值的地址
        0x0009 00009 (main.go:9)        MOVQ    "".p1+8(SP), AX     # A返回值和r1參數求和後,放入AX寄存器
        0x000e 00014 (main.go:9)        ADDQ    "".p2+16(SP), AX    # AX寄存器的值再和r2參數求和,結果放入AX
        0x0013 00019 (main.go:9)        ADDQ    "".p3+24(SP), AX    # AX寄存器的值再和r3參數求和,結果放入AX
        0x0018 00024 (main.go:9)        ADDQ    "".p4+32(SP), AX    # AX寄存器的值再和r4參數求和,結果放入AX
        0x001d 00029 (main.go:9)        ADDQ    "".p5+40(SP), AX    # AX寄存器的值再和r5參數求和,結果放入AX
        0x0022 00034 (main.go:9)        ADDQ    "".p6+48(SP), AX    # AX寄存器的值再和r6參數求和,結果放入AX
        0x0027 00039 (main.go:9)        ADDQ    "".p7+56(SP), AX    # AX寄存器的值再和r7參數求和,結果放入AX
        0x002c 00044 (main.go:9)        MOVQ    AX, "".~r7+64(SP)   # AX寄存器的值 寫回main棧空間中用來接收返回值的地址SP+64中
        0x0031 00049 (main.go:9)        RET

需要注意的是,"".~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版本函數調用分析

執行

gvm use go1.17.1

切換到 go1.17.1版本,修改main.go代碼結構如下:

package main

func main() {
    var r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11 int64 = 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
    a, b := A(r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11)
    c := a + b
    print(c)
}

func A(p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11 int64) (int64, int64) {
    return p1 + p2 + p3 + p4 + p5 + p6 + p7, p2 + p4 + p6 + p7 + p8 + p9 + p10 + p11  
}

使用命令打印出main.go彙編:

GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go

分析main函數的彙編代碼:

"".main STEXT size=362 args=0x0 locals=0xe0 funcid=0x0
        0x0000 00000 (main.go:3)        TEXT    "".main(SB), ABIInternal, $224-0    #main函數定義, $224-0:224表示將分配的main函數的棧幀大小;0指定了調用方傳入的參數,由於main是最上層函數,這裏沒有入參
        0x0000 00000 (main.go:3)        LEAQ    -96(SP), R12
        0x0005 00005 (main.go:3)        CMPQ    R12, 16(R14)
        0x0009 00009 (main.go:3)        PCDATA  $0, $-2
        0x0009 00009 (main.go:3)        JLS     349
        0x000f 00015 (main.go:3)        PCDATA  $0, $-1
        0x000f 00015 (main.go:3)        SUBQ    $224, SP                     # 爲main函數棧幀分配了224字節的空間,注意此時的SP寄存器指向,會往下移動224個字節
        0x0016 00022 (main.go:3)        MOVQ    BP, 216(SP)                  # BP寄存器存放的是main函數caller的基址,movq這條指令是將main函數caller的基址入棧
        0x001e 00030 (main.go:3)        LEAQ    216(SP), BP                  # 將main函數的基址存放到到BP寄存器
        0x0026 00038 (main.go:3)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0026 00038 (main.go:3)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0026 00038 (main.go:4)        MOVQ    $1, "".r1+168(SP)            # main函數局部變量r1入棧    
        0x0032 00050 (main.go:4)        MOVQ    $2, "".r2+144(SP)            # main函數局部變量r2入棧
        0x003e 00062 (main.go:4)        MOVQ    $3, "".r3+136(SP)            # main函數局部變量r3入棧
        0x004a 00074 (main.go:4)        MOVQ    $4, "".r4+128(SP)            # main函數局部變量r4入棧
        0x0056 00086 (main.go:4)        MOVQ    $5, "".r5+120(SP)            # main函數局部變量r5入棧
        0x005f 00095 (main.go:4)        MOVQ    $6, "".r6+112(SP)            # main函數局部變量r6入棧
        0x0068 00104 (main.go:4)        MOVQ    $7, "".r7+104(SP)            # main函數局部變量r7入棧
        0x0071 00113 (main.go:4)        MOVQ    $8, "".r8+96(SP)             # main函數局部變量r8入棧 
        0x007a 00122 (main.go:4)        MOVQ    $9, "".r9+88(SP)             # main函數局部變量r9入棧
        0x0083 00131 (main.go:4)        MOVQ    $10, "".r10+160(SP)          # main函數局部變量r10入棧
        0x008f 00143 (main.go:4)        MOVQ    $11, "".r11+152(SP)          # main函數局部變量r11入棧
        0x009b 00155 (main.go:5)        MOVQ    "".r2+144(SP), BX            # 將局部變量r2傳給寄存器BX
        0x00a3 00163 (main.go:5)        MOVQ    "".r3+136(SP), CX            # 將局部變量r3傳給寄存器CX
        0x00ab 00171 (main.go:5)        MOVQ    "".r4+128(SP), DI            # 將局部變量r4傳給寄存器DI
        0x00b3 00179 (main.go:5)        MOVQ    "".r5+120(SP), SI            # 將局部變量r5傳給寄存器SI
        0x00b8 00184 (main.go:5)        MOVQ    "".r6+112(SP), R8            # 將局部變量r6傳給寄存器R8
        0x00bd 00189 (main.go:5)        MOVQ    "".r7+104(SP), R9            # 將局部變量r7傳給寄存器R9
        0x00c2 00194 (main.go:5)        MOVQ    "".r8+96(SP), R10            # 將局部變量r8傳給寄存器R10
        0x00c7 00199 (main.go:5)        MOVQ    "".r9+88(SP), R11            # 將局部變量r9傳給寄存器R11
        0x00cc 00204 (main.go:5)        MOVQ    "".r10+160(SP), DX           # 將局部變量r10傳給寄存器DX
        0x00d4 00212 (main.go:5)        MOVQ    "".r1+168(SP), AX            # 將局部變量r1傳給寄存器DX
        0x00dc 00220 (main.go:5)        MOVQ    DX, (SP)                     # 將寄存器DX保存的r10傳給SP指向的棧頂
        0x00e0 00224 (main.go:5)        MOVQ    $11, 8(SP)                   # 將變量r11傳給SP+8
        0x00e9 00233 (main.go:5)        PCDATA  $1, $0
        0x00e9 00233 (main.go:5)        CALL    "".A(SB)                     # 調用 A 函數
        0x00ee 00238 (main.go:5)        MOVQ    AX, ""..autotmp_14+208(SP)   # 將寄存器AX存的函數A的第一個返回值a賦值給SP+208
        0x00f6 00246 (main.go:5)        MOVQ    BX, ""..autotmp_15+200(SP)   # 將寄存器BX存的函數A的第二個返回值b賦值給SP+200
        0x00fe 00254 (main.go:5)        MOVQ    ""..autotmp_14+208(SP), DX   # 將SP+208保存的A函數第一個返回值a傳給寄存器DX
        0x0106 00262 (main.go:5)        MOVQ    DX, "".a+192(SP)             # 將A函數第一個返回值a通過寄存器DX入棧到SP+192
        0x010e 00270 (main.go:5)        MOVQ    ""..autotmp_15+200(SP), DX   # 將SP+200保存的A函數第二個返回值b傳給寄存器DX
        0x0116 00278 (main.go:5)        MOVQ    DX, "".b+184(SP)             # 將第二個返回值b通過寄存器DX入棧到SP+184
        0x011e 00286 (main.go:6)        MOVQ    "".a+192(SP), DX             # 將返回值a傳給DX寄存器
        0x0126 00294 (main.go:6)        ADDQ    "".b+184(SP), DX             # 將a+b賦值給DX寄存器
        0x012e 00302 (main.go:6)        MOVQ    DX, "".c+176(SP)             # 將DX寄存器的值入棧到SP+176
        0x0136 00310 (main.go:7)        CALL    runtime.printlock(SB)        
        0x013b 00315 (main.go:7)        MOVQ    "".c+176(SP), AX             # 將SP+176存儲的入參c賦值給AX
        0x0143 00323 (main.go:7)        CALL    runtime.printint(SB)         # 調用打印函數打印c
        0x0148 00328 (main.go:7)        CALL    runtime.printunlock(SB)
        0x014d 00333 (main.go:8)        MOVQ    216(SP), BP
        0x0155 00341 (main.go:8)        ADDQ    $224, SP
        0x015c 00348 (main.go:8)        RET

通過上面彙編代碼的註釋,我們可以看到:main函數調用A函數的參數個數爲11個,其中前 9 個參數分別是通過寄存器 AX、BX、CX、DI、SI、R8、R9、R10、R11傳遞,後面兩個通過棧頂的SP,SP+8地址傳遞。

圖片

下面看 A 函數在Go1.17.1的彙編代碼:

"".A STEXT nosplit size=175 args=0x58 locals=0x18 funcid=0x0
        0x0000 00000 (main.go:10)       TEXT    "".A(SB), NOSPLIT|ABIInternal, $24-88
        0x0000 00000 (main.go:10)       SUBQ    $24, SP                        # 爲A函數棧幀分配了24字節的空間
        0x0004 00004 (main.go:10)       MOVQ    BP, 16(SP)
        0x0009 00009 (main.go:10)       LEAQ    16(SP), BP
        0x000e 00014 (main.go:10)       FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:10)       FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:10)       FUNCDATA        $5, "".A.arginfo1(SB)
        0x000e 00014 (main.go:10)       MOVQ    AX, "".p1+48(SP)               # 寄存器AX存儲的r1賦值給SP+48    
        0x0013 00019 (main.go:10)       MOVQ    BX, "".p2+56(SP)               # 寄存器BX存儲的r2賦值給SP+56
        0x0018 00024 (main.go:10)       MOVQ    CX, "".p3+64(SP)               # 寄存器CX存儲的r3賦值給SP+64
        0x001d 00029 (main.go:10)       MOVQ    DI, "".p4+72(SP)               # 寄存器DI存儲的r4賦值給SP+72
        0x0022 00034 (main.go:10)       MOVQ    SI, "".p5+80(SP)               # 寄存器SI存儲的r5賦值給SP+80
        0x0027 00039 (main.go:10)       MOVQ    R8, "".p6+88(SP)               # 寄存器R8存儲的r6賦值給SP+88
        0x002c 00044 (main.go:10)       MOVQ    R9, "".p7+96(SP)               # 寄存器R9存儲的r7賦值給SP+96
        0x0031 00049 (main.go:10)       MOVQ    R10, "".p8+104(SP)             # 寄存器R10存儲的r8賦值給SP+104
        0x0036 00054 (main.go:10)       MOVQ    R11, "".p9+112(SP)             # 寄存器R11存儲的r9賦值給SP+112
        0x003b 00059 (main.go:10)       MOVQ    $0, "".~r11+8(SP)              # 初始化第一個返回值a存放地址SP+8爲0
        0x0044 00068 (main.go:10)       MOVQ    $0, "".~r12(SP)                # 初始化第二個返回值b存放地址SP爲0
        0x004c 00076 (main.go:11)       MOVQ    "".p1+48(SP), CX               # SP+48存儲的r1賦值給CX寄存器
        0x0051 00081 (main.go:11)       ADDQ    "".p2+56(SP), CX               # CX+r2賦值給CX寄存器
        0x0056 00086 (main.go:11)       ADDQ    "".p3+64(SP), CX               # CX+r3賦值給CX寄存器
        0x005b 00091 (main.go:11)       ADDQ    "".p4+72(SP), CX               # CX+r4賦值給CX寄存器
        0x0060 00096 (main.go:11)       ADDQ    "".p5+80(SP), CX               # CX+r5賦值給CX寄存器
        0x0065 00101 (main.go:11)       ADDQ    "".p6+88(SP), CX               # CX+r6賦值給CX寄存器
        0x006a 00106 (main.go:11)       ADDQ    "".p7+96(SP), CX               # CX+r7賦值給CX寄存器
        0x006f 00111 (main.go:11)       MOVQ    CX, "".~r11+8(SP)              # CX寄存器賦值給第一個返回值存放地址SP+8
        0x0074 00116 (main.go:11)       MOVQ    "".p2+56(SP), BX               # r2賦值給BX寄存器
        0x0079 00121 (main.go:11)       ADDQ    "".p4+72(SP), BX               # BX+r4賦值給BX寄存器
        0x007e 00126 (main.go:11)       ADDQ    "".p6+88(SP), BX               # BX+r6賦值給BX寄存器
        0x0083 00131 (main.go:11)       ADDQ    "".p7+96(SP), BX               # BX+r7賦值給BX寄存器
        0x0088 00136 (main.go:11)       ADDQ    "".p8+104(SP), BX              # BX+r8賦值給BX寄存器
        0x008d 00141 (main.go:11)       ADDQ    "".p9+112(SP), BX              # BX+r9賦值給BX寄存器
        0x0092 00146 (main.go:11)       ADDQ    "".p10+32(SP), BX              # BX+r11賦值給BX寄存器
        0x0097 00151 (main.go:11)       ADDQ    "".p11+40(SP), BX              # BX+r10賦值給BX寄存器
        0x009c 00156 (main.go:11)       MOVQ    BX, "".~r12(SP)                # BX寄存器賦值給第二個返回值存放地址SP
        0x00a0 00160 (main.go:11)       MOVQ    "".~r11+8(SP), AX              # 第一個返回值SP+8的值賦值給AX寄存器
        0x00a5 00165 (main.go:11)       MOVQ    16(SP), BP                     # main返回地址賦值給BP
        0x00aa 00170 (main.go:11)       ADDQ    $24, SP                        # 回收A函數棧幀空間
        0x00ae 00174 (main.go:11)       RET

在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節程序執行命令:

GOOS=linux GOARCH=amd64 go tool compile -S main.go

Go1.15優化後的彙編代碼是:

"".A STEXT nosplit size=59 args=0x40 locals=0x0
        0x0000 00000 (main.go:8)        TEXT    "".A(SB), NOSPLIT|ABIInternal, $0-64
        0x0000 00000 (main.go:8)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:8)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:9)        MOVQ    "".p1+8(SP), AX     #參數從棧賦值到寄存器AX
        0x0005 00005 (main.go:9)        MOVQ    "".p2+16(SP), CX    #參數從棧賦值到寄存器CX
        0x000a 00010 (main.go:9)        ADDQ    CX, AX              
        0x000d 00013 (main.go:9)        MOVQ    "".p3+24(SP), CX    #參數從棧賦值到寄存器CX
        0x0012 00018 (main.go:9)        ADDQ    CX, AX
        0x0015 00021 (main.go:9)        MOVQ    "".p4+32(SP), CX
        0x001a 00026 (main.go:9)        ADDQ    CX, AX
        0x001d 00029 (main.go:9)        MOVQ    "".p5+40(SP), CX
        0x0022 00034 (main.go:9)        ADDQ    CX, AX
        0x0025 00037 (main.go:9)        MOVQ    "".p6+48(SP), CX
        0x002a 00042 (main.go:9)        ADDQ    CX, AX
        0x002d 00045 (main.go:9)        MOVQ    "".p7+56(SP), CX
        0x0032 00050 (main.go:9)        ADDQ    CX, AX
        0x0035 00053 (main.go:9)        MOVQ    AX, "".~r7+64(SP)
        0x003a 00058 (main.go:9)        RET

gvm 切換到Go1.17.1版本。對2.1節程序執行命令:

GOOS=linux GOARCH=amd64 go tool compile -S main.go

Go1.17優化後的彙編代碼是:

"".A STEXT nosplit size=21 args=0x38 locals=0x0 funcid=0x0
        0x0000 00000 (main.go:8)        TEXT    "".A(SB), NOSPLIT|ABIInternal, $0-56
        0x0000 00000 (main.go:8)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:8)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:8)        FUNCDATA        $5, "".A.arginfo1(SB)
        0x0000 00000 (main.go:9)        LEAQ    (BX)(AX*1), DX
        0x0004 00004 (main.go:9)        ADDQ    DX, CX            #直接在寄存器之間做加法
        0x0007 00007 (main.go:9)        ADDQ    DI, CX            #直接在寄存器之間做加法
        0x000a 00010 (main.go:9)        ADDQ    SI, CX
        0x000d 00013 (main.go:9)        ADDQ    R8, CX
        0x0010 00016 (main.go:9)        LEAQ    (R9)(CX*1), AX
        0x0014 00020 (main.go:9)        RET

對比發現:寄存器傳參和棧傳參,在編譯器實際優化後的執行代碼中,前者直接會在寄存器之間做加法,後者多了從棧拷貝數據到寄存器到動作,因此前者效率更高。

通過分析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及以上。

公衆號後臺回覆“GO117”獲得作者推薦GO相關作品

騰訊工程師技術乾貨直達:

1、H5開屏從龜速到閃電,企微是如何做到的

2、全網首次揭祕:微秒級“復活”網絡的HARP協議及其關鍵技術

3、閏秒終於要取消了!一文詳解其來源及影響

4、萬字避坑指南!C++的缺陷與思考(下)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章