日日操夜夜添-日日操影院-日日草夜夜操-日日干干-精品一区二区三区波多野结衣-精品一区二区三区高清免费不卡

公告:魔扣目錄網(wǎng)為廣大站長(zhǎng)提供免費(fèi)收錄網(wǎng)站服務(wù),提交前請(qǐng)做好本站友鏈:【 網(wǎng)站目錄:http://www.ylptlb.cn 】, 免友鏈快審服務(wù)(50元/站),

點(diǎn)擊這里在線咨詢客服
新站提交
  • 網(wǎng)站:51998
  • 待審:31
  • 小程序:12
  • 文章:1030137
  • 會(huì)員:747

導(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

分享到:
標(biāo)簽:函數(shù)
用戶無(wú)頭像

網(wǎng)友整理

注冊(cè)時(shí)間:

網(wǎng)站:5 個(gè)   小程序:0 個(gè)  文章:12 篇

  • 51998

    網(wǎng)站

  • 12

    小程序

  • 1030137

    文章

  • 747

    會(huì)員

趕快注冊(cè)賬號(hào),推廣您的網(wǎng)站吧!
最新入駐小程序

數(shù)獨(dú)大挑戰(zhàn)2018-06-03

數(shù)獨(dú)一種數(shù)學(xué)游戲,玩家需要根據(jù)9

答題星2018-06-03

您可以通過(guò)答題星輕松地創(chuàng)建試卷

全階人生考試2018-06-03

各種考試題,題庫(kù),初中,高中,大學(xué)四六

運(yùn)動(dòng)步數(shù)有氧達(dá)人2018-06-03

記錄運(yùn)動(dòng)步數(shù),積累氧氣值。還可偷

每日養(yǎng)生app2018-06-03

每日養(yǎng)生,天天健康

體育訓(xùn)練成績(jī)?cè)u(píng)定2018-06-03

通用課目體育訓(xùn)練成績(jī)?cè)u(píng)定