Golang中的逃逸分析 頂

  • 2020 年 2 月 18 日
  • 筆記

使用Golang來作為日常的cmdline程序開發也有一兩年了,之前作為一名Ops來說,會使用Golang去開發一些常用的工具來實現生產環境的各種常規操作以及日常運維管理,而對於整個Golang語言內部的一些細節都不甚了解。但隨着對Ops要求的提高,以及向SRE理念轉型的需要,我們越來越需要深入理解一些內部底層的原理,這樣在我們去管理的我們的Kubernetes集群,或者其他的一些內部系統時才能真正做到遊刃有餘。

在Golang中,一個對象最終是分配到還是呢,接下來我們就一起通過逃逸分析來一起學習學習。

概念介紹

逃逸分析

逃逸分析是編譯器用來確定由程序創建的值所處位置的過程。具體來說,編譯器執行靜態代碼分析,以確定是否可以將值放在構造函數的棧(幀)上,或者該值是否必須逃逸上。

所以,更通俗一點講,逃逸分析就是確定一個對象是要放在還是上,一般遵循如下規則:

  • 1.是否有非局部調用(對象定義之外的調用).即:如果有可能被引用,那通常會被分配到堆上,否則就在棧上
  • 2.如果對象太大(即使沒有被引用),無法放在棧區也是可能放到上的

總結起來就是: 如果在函數外部引用,必定在堆中分配;如果沒有外部引用,優先在棧中分配;如果一個函數返回的是一個(局部)變量的地址,那麼這個變量就發生逃逸

避免逃逸的好處:

  • 1.減少gc的壓力,不逃逸的對象分配在棧上,當函數返回時就回收了資源,不需要gc標記清除
  • 2.逃逸分析完後可以確定哪些變量可以分配在棧上,棧的分配比堆快,性能好(系統開銷少)
  • 3.減少動態分配所造成的內存碎片

如何進行逃逸分析

注意: Golang程序中是在編譯階段確定逃逸的,而非運行時,因此我們可以使用go build的相關工具來進行逃逸分析.

分析工具:

  • 1.通過編譯工具查看詳細的逃逸分析過程(go build -gcflags '-m -l' main.go)
  • 2.通過反編譯命令查看go tool compile -S main.go

編譯參數介紹(-gcflags):

  • -N: 禁止編譯優化
  • -l: 禁止內聯(可以有效減少程序大小)
  • -m: 逃逸分析(最多可重複四次)
  • -benchmem: 壓測時打印內存分配統計

堆是除棧之外的第二個內存區域,用於存儲值,全局變量、內存佔用大的局部變量、發生了逃逸的局部變量存在的地方就是堆,這塊的內存沒有特定的結構,也沒有固定大小,可以根據需要進行調整(但也造成管理成本),因此堆不像棧那樣是自清理的,使用這個內存的成本更大(一般各個語言都會有自己的GC機制,在Golang中會使用三色標記法來進行堆內存的垃圾回收)。

首先,成本與垃圾收集器(GC)有關,垃圾收集器必須參與進來以保持該區域的清潔。當GC運行時,它將使用25%的可用CPU資源。此外,它可能會產生微秒級的「stop the world」延遲。擁有GC的好處是你不需要擔心內存的管理問題,因為內存管理是相當複雜、也容易出錯的。

堆上的值構成Go中的內存分配。這些分配對GC造成壓力,因為堆中不再被指針引用的每個值都需要刪除。需要檢查和刪除的值越多,GC每次運行時必須執行的工作就越多。因此,GC算法一直在努力在堆的大小分配和運行速度之間尋求平衡。

注意:堆是進程級別的

在程序中,每個函數塊都會有自己的內存區域用來存自己的局部變量(內存佔用少)、返回地址、返回值之類的數據,這一塊內存區域有特定的結構和尋址方式,大小在編譯時已經確定,尋址起來也十分迅速,開銷很少。

這塊內存地址稱為棧是線程級別的,大小在創建的時候已經確定,所以當數據太大的時候,就會發生"stack overflow"

注意:在Golang程序中,函數都是運行在上的,在棧上聲明臨時變量分配內存,函數運行完成後回收該段棧空間,並且每個函數的棧空間都是獨立的,其他代碼不可訪問的。但是在某些場景下,棧上的空間需要在該函數被釋放後依舊能訪問到(函數外調用),這時候就涉及到內存的逃逸了,而逃逸往往會對應對象的內存分配到堆上.

逃逸分析示例

1.示例-參數泄露

# 測試代碼  $ cat taoyi.go  package main  import (      _ "fmt"  )  // 定義一個簡單的結構體  type user struct {      name    string      age     int      webSite string  }  // 獲取用戶信息  func GetUserInfo(u *user) (*user) {      return u  }  // 獲取用戶名稱  func GetName(u *user) (string) {      return u.name  }    func main() {      // 初始化user結構體的指針對象      user := &user{"BGBiao",18,"https://bgbiao.top"}      GetUserInfo(user)      GetName(user)  }

使用逃逸分析來進行內存分析

$ go build -gcflags '-m -m  -l' taoyi.go  # command-line-arguments  ./taoyi.go:21:18: leaking param: u to result ~r1 level=0  ./taoyi.go:25:14: leaking param: u to result ~r1 level=1  ./taoyi.go:31:31: main &user literal does not escape

由上述輸出的leaking param可以看到,在GetUserInfoGetName函數中的指針變量u是一個泄露參數,在兩個函數中均沒有對u進行變量操作,就直接返回了變量內容,因此最後的該變量user並沒有發生逃逸,&user對象還是作用在了main()函數中。

2.示例-未知類型

這個時候,我們把上面的代碼稍微改動一下:

....  ....  func main() {      user := &user{"BGBiao",18,"https://bgbiao.top"}      fmt.Println(GetUserInfo(user))      fmt.Println(GetName(user))  }

再次進行逃逸分析:

$ go build -gcflags '-m -m  -l' taoyi.go  # command-line-arguments  ./taoyi.go:21:18: leaking param: u to result ~r1 level=0  ./taoyi.go:25:14: leaking param: u to result ~r1 level=1  ./taoyi.go:31:31: &user literal escapes to heap  ./taoyi.go:32:16: main ... argument does not escape  ./taoyi.go:32:28: GetUserInfo(user) escapes to heap  ./taoyi.go:33:16: main ... argument does not escape  ./taoyi.go:33:24: GetName(user) escapes to heap

由上可以發現我們的指針對象&user在該程序中發生了逃逸,具體是在GetUserInfo(user)GetName(user)發生了逃逸.

這是為什麼呢?怎麼加了個fmt.Println之後對象就發生了逃逸呢?

其實主要原因為fmt.Println函數的原因:

func Println(a ...interface{}) (n int, err error)

我們可以看到fmt.Println(a)函數中入參為interface{}類型,在編譯階段編譯器無法確定其具體的類型。因此會產生逃逸,最終分配到堆上(最本質的原因是interface{}類型一般情況下底層會進行reflect,而使用的reflect.TypeOf(arg).Kind()獲取接口類型對象的底層數據類型時發生了堆逃逸,最終就會反映為當入參是空接口類型時發生了逃逸)。

3.示例-指針

此時,我們再小改點代碼:

// 返回結構體對象的指針,此時就會產生逃逸  func GetUserInfo(u user) (*user) {      return &u  }    func main() {      user := user{"BGBiao",18,"https://bgbiao.top"}      GetUserInfo(user)  }

逃逸分析:

$ go build -gcflags '-m -m  -l' taoyi.go  # command-line-arguments  ./taoyi.go:21:18: moved to heap: u    # 查看彙編代碼(可以看到有個CALL	runtime.newobject(SB)的系統調用)  $ go tool compile -S taoyi.go | grep taoyi.go:21  	0x0000 00000 (taoyi.go:21)	TEXT	"".GetUserInfo(SB), ABIInternal, $40-48  	0x0000 00000 (taoyi.go:21)	MOVQ	(TLS), CX  	0x0009 00009 (taoyi.go:21)	CMPQ	SP, 16(CX)  	0x000d 00013 (taoyi.go:21)	JLS	147  	0x0013 00019 (taoyi.go:21)	SUBQ	$40, SP  	0x0017 00023 (taoyi.go:21)	MOVQ	BP, 32(SP)  	0x001c 00028 (taoyi.go:21)	LEAQ	32(SP), BP  	0x0021 00033 (taoyi.go:21)	FUNCDATA	$0, gclocals·fb57040982f53920ad6a8ad662a1594f(SB)  	0x0021 00033 (taoyi.go:21)	FUNCDATA	$1, gclocals·263043c8f03e3241528dfae4e2812ef4(SB)  	0x0021 00033 (taoyi.go:21)	FUNCDATA	$2, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)  	0x0021 00033 (taoyi.go:21)	PCDATA	$0, $1  	0x0021 00033 (taoyi.go:21)	PCDATA	$1, $0  	0x0021 00033 (taoyi.go:21)	LEAQ	type."".user(SB), AX  	0x0028 00040 (taoyi.go:21)	PCDATA	$0, $0  	0x0028 00040 (taoyi.go:21)	MOVQ	AX, (SP)  	0x002c 00044 (taoyi.go:21)	CALL	runtime.newobject(SB)  	0x0031 00049 (taoyi.go:21)	PCDATA	$0, $1  	0x0031 00049 (taoyi.go:21)	MOVQ	8(SP), AX  	0x0036 00054 (taoyi.go:21)	PCDATA	$0, $-2  	0x0036 00054 (taoyi.go:21)	PCDATA	$1, $-2  	0x0036 00054 (taoyi.go:21)	CMPL	runtime.writeBarrier(SB), $0  	0x003d 00061 (taoyi.go:21)	JNE	104  	0x003f 00063 (taoyi.go:21)	MOVQ	"".u+48(SP), CX  	0x0044 00068 (taoyi.go:21)	MOVQ	CX, (AX)  	0x0047 00071 (taoyi.go:21)	MOVUPS	"".u+56(SP), X0  	0x004c 00076 (taoyi.go:21)	MOVUPS	X0, 8(AX)  	0x0050 00080 (taoyi.go:21)	MOVUPS	"".u+72(SP), X0  	0x0055 00085 (taoyi.go:21)	MOVUPS	X0, 24(AX)  	0x0068 00104 (taoyi.go:21)	PCDATA	$0, $-2  	0x0068 00104 (taoyi.go:21)	PCDATA	$1, $-2  	0x0068 00104 (taoyi.go:21)	MOVQ	AX, "".&u+24(SP)  	0x006d 00109 (taoyi.go:21)	LEAQ	type."".user(SB), CX  	0x0074 00116 (taoyi.go:21)	MOVQ	CX, (SP)  	0x0078 00120 (taoyi.go:21)	MOVQ	AX, 8(SP)  	0x007d 00125 (taoyi.go:21)	LEAQ	"".u+48(SP), CX  	0x0082 00130 (taoyi.go:21)	MOVQ	CX, 16(SP)  	0x0087 00135 (taoyi.go:21)	CALL	runtime.typedmemmove(SB)  	0x0091 00145 (taoyi.go:21)	JMP	89  	0x0093 00147 (taoyi.go:21)	NOP  	0x0093 00147 (taoyi.go:21)	PCDATA	$1, $-1  	0x0093 00147 (taoyi.go:21)	PCDATA	$0, $-1  	0x0093 00147 (taoyi.go:21)	CALL	runtime.morestack_noctxt(SB)  	0x0098 00152 (taoyi.go:21)	JMP	0

由以上輸出可以看到在GetUserInfo(u user)函數中的對象u已經被移到上了,這是因為該函數返回的是指針對象,引用對象被返回到方法之外了(此時該引用對象可以在外部被調用和修改),因此編譯器會把該對象分配到堆上(否則方法結束後,局部變量被回收豈不是很慘)。

4.示例-綜合案例

$ cat taoyi-2.go  package main    func main() {      name := new(string)      *name = "BGBiao"    }    $ go build -gcflags '-m -m  -l' taoyi-2.go  # command-line-arguments  ./taoyi-demo.go:13:16: main new(string) does not escape

在上面第三個示例中我們提到,當返回對象是指針類型(引用對象)時,就會發現逃逸,但上面的示例其實告訴我們雖然*name是一個指針類型,但是並未發生逃逸,這是因為該引用類型未被外部使用.

但是又如第二個示例中所說,如果我們在上面的示例中增加fmt.Println(name)後,會發現該實例又會出現逃逸.

注意:雖然當使用fmt.Println的時候又會出現逃逸,但是當使用fmt.Println(*name)和fmt.Println(name),也是不同的。

$ cat demo1.go  package main  import ("fmt")  func main() {      name := new(string)      *name = "BGBiao"      fmt.Println(*name)  }    $ go build -gcflags '-m -m  -l' taoyi-demo.go  # command-line-arguments  ./taoyi-demo.go:13:16: main new(string) does not escape  ./taoyi-demo.go:15:16: main ... argument does not escape  ./taoyi-demo.go:15:17: *name escapes to heap

由上述輸出可看到,當使用引用類型來獲取底層的值時,在fmt.Println的入參處*name發生了逃逸.

$ cat demo2.go  package main  import ("fmt")  func main() {      name := new(string)      *name = "BGBiao"      fmt.Println(name)  }    $ go build -gcflags '-m -m  -l' taoyi-demo.go  # command-line-arguments  ./taoyi-demo.go:13:16: new(string) escapes to heap  ./taoyi-demo.go:15:16: main ... argument does not escape  ./taoyi-demo.go:15:16: name escapes to heap

而這次我們使用fmt.Println(name)來輸出底層值,就會發現變量name在初始化的時候就會出現逃逸new(string)

總結

通過上面的概念和實例分析,我們基本知道了逃逸分析的概念和規則,並且大概知道何時,那種對象會被分配到堆或棧內存中,在實際情況中可能情況會更加複雜,需要具體分析。

不過,有如下幾點可能在我們實際使用過程中要注意下:

  • 靜態分配到棧上,性能一定比動態分配到堆上好
  • 底層分配到堆,還是棧。實際上對你來說是透明的,不需要過度關心
  • 每個 Go 版本的逃逸分析都會有所不同(會改變,會優化)
  • 直接通過go build -gcflags '-m -l' 就可以看到逃逸分析的過程和結果
  • 到處都用指針傳遞並不一定是最好的,要用對
  • map & slice 初始化時,預估容量,避免由擴展導致的內存分配。但是如果太大(10000)也會逃逸,因為棧的空間是有限的

思考

函數傳遞指針真的比傳值效率高嗎?

我們知道傳遞指針可以減少底層值的拷貝,可以提高效率,但是如果拷貝的數據量小,由於指針傳遞會產生逃逸,可能會使用堆,也可能會增加GC的負擔,所以傳遞指針不一定是高效的。

內存碎片化問題

實際項目基本都是通過 c := make([]int, 0, l) 來申請內存,長度都是不確定的,自然而然這些變量都會申請到堆上面了.

Golang使用的垃圾回收算法是『標記——清除』.

簡單得說,就是程序要從操作系統申請一塊比較大的內存,內存分成小塊,通過鏈錶鏈接。

每次程序申請內存,就從鏈表上面遍歷每一小塊,找到符合的就返回其地址,沒有合適的就從操作系統再申請。如果申請內存次數較多,而且申請的大小不固定,就會引起內存碎片化的問題。

申請的堆內存並沒有用完,但是用戶申請的內存的時候卻沒有合適的空間提供。這樣會遍歷整個鏈表,還會繼續向操作系統申請內存。

wx公號: BGBiao,一起進步~