堆和棧
要理解什么是逃逸分析會涉及堆和棧的一些基本知識,如果忘記的同學我們可以簡單的回顧一下:
-
堆(Heap):一般來講是人為手動進行管理,手動申請、分配、釋放。堆適合不可預知大小的內存分配,這也意味着為此付出的代價是分配速度較慢,而且會形成內存碎片。
-
棧(Stack):由編譯器進行管理,自動申請、分配、釋放。一般不會太大,因此棧的分配和回收速度非常快;我們常見的函數參數(不同平台允許存放的數量不同),局部變量等都會存放在棧上。
棧分配內存只需要兩個CPU指令:“PUSH”和“RELEASE”,分配和釋放;而堆分配內存首先需要去找到一塊大小合適的內存塊,之后要通過垃圾回收才能釋放。
通俗比喻的說,棧就如我們去飯館吃飯,只需要點菜(發出申請)--》吃吃吃(使用內存)--》吃飽就跑剩下的交給飯館(操作系統自動回收),而堆就如在家里做飯,大到家,小到買什么菜,每一個環節都需要自己來實現,但是自由度會大很多。
什么是逃逸分析
在編譯程序優化理論中,逃逸分析是一種確定指針動態范圍的方法,簡單來說就是分析在程序的哪些地方可以訪問到該指針。
再往簡單的說,Go是通過在編譯器里做逃逸分析(escape analysis)來決定一個對象放棧上還是放堆上,不逃逸的對象放棧上,可能逃逸的放堆上;即我發現變量在退出函數后沒有用了,那么就把丟到棧上,畢竟棧上的內存分配和回收比堆上快很多;反之,函數內的普通變量經過逃逸分析后,發現在函數退出后變量還有在其他地方上引用,那就將變量分配在堆上。做到按需分配(哪里的人民需要我,我就往哪去~~,一個黨員的吶喊)。
為何需要逃逸分析
ok,了解完堆和棧各自的優缺點后,我們就可以更好的知道逃逸分析存在的目的了:
-
減少
gc壓力,棧上的變量,隨着函數退出后系統直接回收,不需要gc標記后再清除。 -
減少內存碎片的產生。
-
減輕分配堆內存的開銷,提高程序的運行速度。
如何確定是否逃逸
在Go中通過逃逸分析日志來確定變量是否逃逸,開啟逃逸分析日志:
go run -gcflags '-m -l' main.go
-
-m會打印出逃逸分析的優化策略,實際上最多總共可以用 4 個-m,但是信息量較大,一般用 1 個就可以了。 -
逃逸案例
案例一:取地址發生逃逸
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的編譯器就如一個聰明的孩子一般,大多時候在逃逸分析問題上的處理都令人眼前一亮,但有時鬧性子的時候處理也是非常粗糙的分析或完全放棄,畢竟這是孩子天性不是嗎? 所以也需要我們在編寫代碼的時候多多觀察,多多留意了。
參考文章
