Go 逃逸分析

原文地址:Go 逃逸分析

堆和棧

要理解什麼是逃逸分析會涉及堆和棧的一些基本知識,如果忘記的同學我們可以簡單的回顧一下:

  • 堆(Heap):一般來講是人爲手動進行管理,手動申請、分配、釋放。堆適合不可預知大小的內存分配,這也意味着爲此付出的代價是分配速度較慢,而且會形成內存碎片。
  • 棧(Stack):由編譯器進行管理,自動申請、分配、釋放。一般不會太大,因此棧的分配和回收速度非常快;我們常見的函數參數(不同平臺允許存放的數量不同),局部變量等都會存放在棧上。

棧分配內存只需要兩個CPU指令:“PUSH”和“RELEASE”,分配和釋放;而堆分配內存首先需要去找到一塊大小合適的內存塊,之後要通過垃圾回收才能釋放。

通俗比喻的說,就如我們去飯館吃飯,只需要點菜(發出申請)--》吃吃吃(使用內存)--》吃飽就跑剩下的交給飯館(操作系統自動回收),而就如在家裏做飯,大到家,小到買什麼菜,每一個環節都需要自己來實現,但是自由度會大很多。

什麼是逃逸分析

在編譯程序優化理論中,逃逸分析是一種確定指針動態範圍的方法,簡單來說就是分析在程序的哪些地方可以訪問到該指針。

再往簡單的說,Go是通過在編譯器裏做逃逸分析(escape analysis)來決定一個對象放棧上還是放堆上,不逃逸的對象放棧上,可能逃逸的放堆上;即我發現變量在退出函數後沒有用了,那麼就把丟到棧上,畢竟棧上的內存分配和回收比堆上快很多;反之,函數內的普通變量經過逃逸分析後,發現在函數退出後變量還有在其他地方上引用,那就將變量分配在堆上。做到按需分配(哪裏的人民需要我,我就往哪去~~,一個黨員的吶喊)。

爲何需要逃逸分析

ok,瞭解完各自的優缺點後,我們就可以更好的知道逃逸分析存在的目的了:

  1. 減少gc壓力,棧上的變量,隨着函數退出後系統直接回收,不需要gc標記後再清除。
  2. 減少內存碎片的產生。
  3. 減輕分配堆內存的開銷,提高程序的運行速度。

如何確定是否逃逸

Go中通過逃逸分析日誌來確定變量是否逃逸,開啓逃逸分析日誌:

go run -gcflags '-m -l' main.go
  • -m 會打印出逃逸分析的優化策略,實際上最多總共可以用 4 個 -m,但是信息量較大,一般用 1 個就可以了。
  • -l 會禁用函數內聯,在這裏禁用掉內聯能更好的觀察逃逸情況,減少干擾。

逃逸案例

案例一:取地址發生逃逸

package main

type UserData struct {
    Name  string
}

func main() {
    var info UserData
    info.Name = "WilburXu"
    _ = GetUserInfo(info)
}

func GetUserInfo(userInfo UserData) *UserData {
    return &userInfo
}

執行 go run -gcflags '-m -l' main.go 後返回以下結果:

# command-line-arguments
.\main.go:14:9: &userInfo escapes to heap
.\main.go:13:18: moved to heap: userInfo
GetUserInfo函數裏面的變量 userInfo 逃到堆上了(分配到堆內存空間上了)。

GetUserInfo 函數的返回值爲 *UserData 指針類型,然後 將值變量userInfo 的地址返回,此時編譯器會判斷該值可能會在函數外使用,就將其分配到了堆上,所以變量userInfo就逃逸了。

優化方案

func main() {
    var info UserData
    info.Name = "WilburXu"
    _ = GetUserInfo(&info)
}

func GetUserInfo(userInfo *UserData) *UserData {
    return userInfo
}
# command-line-arguments
.\main.go:13:18: leaking param: userInfo to result ~r1 level=0
.\main.go:10:18: main &info does not escape

對一個變量取地址,可能會被分配到堆上。但是編譯器進行逃逸分析後,如果發現到在函數返回後,此變量不會被引用,那麼還是會被分配到棧上。套個取址符,就想騙補助?

編譯器傲嬌的說:Too young,Too Cool...!

案例二 :未確定類型

package main

type User struct {
    name interface{}
}

func main() {
    name := "WilburXu"
    MyPrintln(name)
}

func MyPrintln(one interface{}) (n int, err error) {
    var userInfo = new(User)
    userInfo.name = one // 泛型賦值 逃逸咯
    return
}

執行 go run -gcflags '-m -l' main.go 後返回以下結果:

# command-line-arguments
./main.go:12:16: leaking param: one
./main.go:13:20: MyPrintln new(User) does not escape
./main.go:9:11: name escapes to heap

這裏可能有同學會好奇,MyPrintln函數內並沒有被引用的便利,爲什麼變了name會被分配到了上呢?

上一個案例我們知道了,普通的手法想去"騙取補助",聰明靈利的編譯器是不會“上當受騙的噢”;但是對於interface類型,很遺憾,編譯器在編譯的時候很難知道在函數的調用或者結構體的賦值過程會是怎麼類型,因此只能分配到上。

優化方案

將結構體User的成員name的類型、函數MyPringLn參數one的類型改爲 string,將得出:

# command-line-arguments
./main.go:12:16: leaking param: one
./main.go:13:20: MyPrintln new(User) does not escape

拓展分析

對於案例二的分析,我們還可以通過反編譯命令go tool compile -S main.go查看,會發現如果爲interface類型,main主函數在編譯後會額外多出以下指令:

# main.go:9 -> MyPrintln(name)
    0x001d 00029 (main.go:9)    PCDATA    $2, $1
    0x001d 00029 (main.go:9)    PCDATA    $0, $1
    0x001d 00029 (main.go:9)    LEAQ    go.string."WilburXu"(SB), AX
    0x0024 00036 (main.go:9)    PCDATA    $2, $0
    0x0024 00036 (main.go:9)    MOVQ    AX, ""..autotmp_5+32(SP)
    0x0029 00041 (main.go:9)    MOVQ    $8, ""..autotmp_5+40(SP)
    0x0032 00050 (main.go:9)    PCDATA    $2, $1
    0x0032 00050 (main.go:9)    LEAQ    type.string(SB), AX
    0x0039 00057 (main.go:9)    PCDATA    $2, $0
    0x0039 00057 (main.go:9)    MOVQ    AX, (SP)
    0x003d 00061 (main.go:9)    PCDATA    $2, $1
    0x003d 00061 (main.go:9)    LEAQ    ""..autotmp_5+32(SP), AX
    0x0042 00066 (main.go:9)    PCDATA    $2, $0
    0x0042 00066 (main.go:9)    MOVQ    AX, 8(SP)
    0x0047 00071 (main.go:9)    CALL    runtime.convT2Estring(SB)

對於Go彙編語法不熟悉的可以參考 Golang彙編快速指南

總結

不要盲目使用變量的指針作爲函數參數,雖然它會減少複製操作。但其實當參數爲變量自身的時候,複製是在棧上完成的操作,開銷遠比變量逃逸後動態地在堆上分配內存少的多。

Go的編譯器就如一個聰明的孩子一般,大多時候在逃逸分析問題上的處理都令人眼前一亮,但有時鬧性子的時候處理也是非常粗糙的分析或完全放棄,畢竟這是孩子天性不是嗎? 所以也需要我們在編寫代碼的時候多多觀察,多多留意了。

參考文章

Golang escape analysis

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