亚洲视频二区_亚洲欧洲日本天天堂在线观看_日韩一区二区在线观看_中文字幕不卡一区

公告:魔扣目錄網為廣大站長提供免費收錄網站服務,提交前請做好本站友鏈:【 網站目錄:http://www.430618.com 】, 免友鏈快審服務(50元/站),

點擊這里在線咨詢客服
新站提交
  • 網站:51998
  • 待審:31
  • 小程序:12
  • 文章:1030137
  • 會員:747

導讀

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

分享到:
標簽:函數
用戶無頭像

網友整理

注冊時間:

網站:5 個   小程序:0 個  文章:12 篇

  • 51998

    網站

  • 12

    小程序

  • 1030137

    文章

  • 747

    會員

趕快注冊賬號,推廣您的網站吧!
最新入駐小程序

數獨大挑戰2018-06-03

數獨一種數學游戲,玩家需要根據9

答題星2018-06-03

您可以通過答題星輕松地創建試卷

全階人生考試2018-06-03

各種考試題,題庫,初中,高中,大學四六

運動步數有氧達人2018-06-03

記錄運動步數,積累氧氣值。還可偷

每日養生app2018-06-03

每日養生,天天健康

體育訓練成績評定2018-06-03

通用課目體育訓練成績評定