Golang 內存逃逸

程序佔用的內存可以分爲棧區、堆區、靜態區、文字常量區和程序代碼區。佔用的棧區由編譯器自動分配釋放,程序員不用關心管理問題。堆區的內容一般由需要程序員手動管理,手動申請和釋放。例如 C/C++ 語言,調用 malloc 在堆上可以分配一塊內存,釋放需要調用 free 或 delete 操作。如果申請後沒有釋放就會導致嚴重內存泄露問題,這在實際開發的產品中是不允許的。所以對堆上內存的申請和釋放要非常小心。但是在 Go 語言中,我們並不需要非常關心一個對象到底是申請在棧上還是堆上,因爲 Go 的編譯器會確定對象的真正分配位置,如果一個變量或對象需要分配在堆上時,會自動將其分配在堆上而不是棧上,使用 new 創建的對象也不一定是分配在堆上。堆和棧的界限變得比較模糊,Go 採用逃逸分析技術確定一個對象是分配在堆上還是棧上。

什麼是內存逃逸分析

In compiler optimization, escape analysis is a method for determining the dynamic scope of pointers – where in the program a pointer can be accessed. It is related to pointer analysis and shape analysis.

上面是來至維基百科的定義,大意是說在編譯器優化中,內存逃逸用來動態確定指針的作用域範圍。如果子程序分配了一個對象並返回一個指向它的指針,則可以根據返回的指針來訪問對象,這時對象不能直接存放在棧上。如果指針存在在全局變量或其他數據結構中,而這些數據結構又會在當前過程中逃逸,則他們可以發送逃逸。逃逸分析可以確定指針對象存儲的位置,以及是否可以證明指針的生命週期僅限於當前過程或線程。

爲什麼要進行逃逸分析

在 C/C++ 中,動態分配的內存需要手動進行釋放,一不小心就會導致內存泄露,導致寫程序的心智負擔很重。Go 中有垃圾回收機制幫助我們自動回收不用的內存,讓我們可以專注於業務,高效地編寫代碼。逃逸分析的作用是合理分配變量在該去的地方,即 “找準自己的位置”。就算用 new 申請內存,如果退出函數後發現不在沒用,會把它分配到棧上。畢竟,堆棧上的內存分配比堆上的內存分配要快得多。相反,即使你只是表面上的一個普通變量,經過 escape 分析,發現退出函數之後還有其他引用,會將其分配到堆中。真正做到 “按需分配”。如果將變量分配在堆上,堆不會像堆棧一樣自動清理。會導致 Go 頻繁做垃圾回收,垃圾回收會佔用大量的系統開銷。

堆與棧相比,堆適用於不可預測大小的內存分配。但是這樣做的代價是分配速度慢,會形成內存碎片。棧內存分配非常快。棧只需要兩條 CPU 指令:“push”和 “release” 來分配和釋放內存。堆內存分配首先需要找到一個合適大小的內存塊,在使用完後還需要通過垃圾回收來釋放。

通過逃逸分析,可以將不需要分配到堆中的變量直接分配到棧中。如果堆上的變量少了,分配堆內存的開銷就會減少,GC 的壓力也會降低,程序的運行速度也會提高。

如何進行逃逸分析

Go 逃逸分析的基本原理是如果函數返回的是對一個變量的引用,它就會進行逃逸。編譯器會對代碼中的變量類型、引用關係和生命週期進行分析。只有在編譯器能夠證明函數返回之後不會存在對變量再次引用的情況下才會被分配到棧中。在其他情況下,它們會被分配到堆中。Go 中並沒有提供將變量分配到堆或棧上的關鍵字和函數。也就是程序員可以不關心變量實際分配在什麼地方。在 Go 程序編譯期間,編譯器會通過逃逸分析方法來確定變量的分配位置。可尋址的變量通常來說會分配在堆上,但是經過逃逸分析,觀察到這個變量在函數返回後不會被引用,還是會分配到棧上。總結起來,編譯器會根據變量是否被外部引用來決定是否逃逸。

逃逸分析實例

暴露引用到函數外部

func escapeDemo1() *int {
 i := 10
 return &i
}

上面的函數寫在 main.go 文件中,然後執行 go build -gcflags="-m -l" main.go, 得到輸出如下

./main.go:49:2: moved to heap: i

moved to heap: i 提示說明對象 i 移動到堆上了。這裏函數返回了局部變量 i 的地址,調用該函數的邏輯拿到返回的地址可以做其他處理。如果將 i 申請在棧上,當函數 escapeDemo1 調用完之後,i 的內存可能被其他函數調用覆蓋,所以需要將 i 分配到堆上,才能避免被覆蓋的情況

變量所佔內存比較大

func escapeDemo2_1() {
 // 8191不會逃逸
 s := make([]int, 8191, 8191)
 for i := 0; i < len(s); i++ {
  s[i] = i
 }
}

func escapeDemo2_2() {
 // 8192會逃逸
 s := make([]int, 8192, 8192)
 for i := 0; i < len(s); i++ {
  s[i] = i
 }
}

先執行下 go build -gcflags="-m -l" main.go 看下輸出情況,

./main.go:61:11: make([]int, 8191, 8191) does not escape
./main.go:69:11: make([]int, 8192, 8192) escapes to heap

咦? escapeDemo2_1 中的 s 沒有發生逃逸,escapeDemo2_2 中的 s 發生了逃逸。爲什麼會這樣呢?下面在執行以下 go build -gcflags="-m -m -l" main.go 看下更詳細的輸入,這裏傳入了兩個 - m.

./main.go:61:11: make([]int, 8191, 8191) does not escape
./main.go:69:11: make([]int, 8192, 8192) escapes to heap:
./main.go:69:11:   flow: {heap} = &{storage for make([]int, 8192, 8192)}:
./main.go:69:11:     from make([]int, 8192, 8192) (too large for stack) at ./main.go:69:11
./main.go:69:11: make([]int, 8192, 8192) escapes to heap

現在來分析一下原因,上面的輸出給出最直接的原因,too large for stack, 也就是在棧上分配的內存太大了。一個 int 佔 8 個字節,8192 個 int 佔用的內存是 64k. 那爲啥大內存要分配到堆上呢?因爲在 Go 中,函數的執行是在 goroutine 中的,goroutine 是一種用戶態線程,其調用棧內存被稱爲用戶棧,與之對應的是系統線程。在 GMP 模型中,一個 M 對應一個系統棧(M 的 g0 棧),M 上的多個 goroutine 會共享系統棧。在 x86_64 架構下,系統棧的大小爲 8M。用戶棧 (goroutine) 開始的大小爲 2k,這也是一個 Go 程序可以輕鬆開上萬個 goroutine 的原因。爲了不造成棧溢出和頻繁的擴容或縮容,大的對象分配到堆上更合理。通過 escapeDemo2_1 和 escapeDemo2_2 可以看到,64K 是一個臨界值,小於 64K 的會分配在棧上,大於等於 64K 的對象會分配到堆上。

make 創建切片申請的大小非常量

func escapeDemo3() {
 v := 1
 s := make([]int, v)
 for i := 0; i < len(s); i++ {
  s[i] = i
 }
}

執行 go build -gcflags="-m -m -l" main.go 可以看到 s 發生了逃逸. 雖然這裏 v 是 1,申請的切片 s 佔的空間很小,但 v 是一個變量,也將其分配到了堆上,可能是爲了保證絕對的安全。放在堆上反正有 gc 回收,不會存在泄漏,也是沒有問題的。

./main.go:78:11: make([]int, v) escapes to heap:
./main.go:78:11:   flow: {heap} = &{storage for make([]int, v)}:
./main.go:78:11:     from make([]int, v) (non-constant size) at ./main.go:78:11
./main.go:78:11: make([]int, v) escapes to heap

變量綁定到了堆上的變量

還是先來看一個例子,如下,下面的變量會發生內存逃逸嗎?執行 go build -gcflags="-m -l" main.go 可以看到 & User 還真是發生了逃逸,爲啥呢?&User 跟 fmt.Println 有啥關係,這裏傳入的是 u 是 User 的地址。先跳轉到 fmt.Println 內部,看看它的具體實現。

type User struct {
 UserName string
 PassWord string
 Age      int
}

func escapeDemo5() {
 u := &User{"mingyong""123", 12}
 fmt.Println(u)
}

執行逃逸分析後輸出

./main.go:91:7: &User literal escapes to heap
./main.go:92:13: ... argument does not escape

fmt.Println 內部調用了 Fprintln(os.Stdout, a...), Fprintln 內部調用了下面的代碼。p 是一個對象的引用,根據上面第一個實例,可以知道 p 對象是分配在堆上的,p.doPrintln(a) 將 a 賦值給 p 的一個字段上,這裏的 a 也就是 fmt.Println 的傳入參數。擴大了 a 的範圍,將其分配在了堆上。這個也比較好理解,因爲 p 在堆上,p 的局部字段的內容生命週期也要跟 p 一樣長,否則就出現 p 還存在,它的局部變量已不存在的情況。所以 a 不能分配在棧上。這裏的 a 即傳入的 u,u 是執行 User 的地址,所以 & User 要分配在堆上。

func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
 p := newPrinter()
 p.doPrintln(a)
 n, err = w.Write(p.buf)
 p.free()
 return
}

爲了進一步驗證上面的分析,下面的例子與上面的稍有不同,進行對比分析,加深理解。

func escapeDemo6() {
 u := &User{"mingyong""123", 12}
 call(u)
}

func call(u *User) {
 u.UserName = "mingyong2"
}

執行逃逸分析,輸出結果爲

./main.go:96:7: &User literal does not escape

可以看到,上面的 & User 沒有發生內存逃逸。因爲 call 內部並沒有放大 u 的範圍,u 是外部傳入的,call 內部也沒有將其賦值給一個堆上的對象。所以 & User 並沒有發生逃逸。下面將 call 中的 u 賦值給一個堆上對象的,看看是不是跟前面分析的一樣,會發生逃逸。

type User2 struct {
 uers  *User
 other string
}

func newUser2() *User2 {
 return new(User2)
}

func call2(u *User) {
 u2 := newUser2()
 u2.uers = u
}

func escapeDemo7() {
 u := &User{"mingyong""123", 12}
 call2(u)
}

執行逃逸分析輸出可以看到 & User 發生了逃逸,已預期的一致

./main.go:101:7: &User literal escapes to heap

切片類型(元素爲 T 類型)的逃逸

下面分析切片元素是否發生逃逸,對一個切片類型來說,它底層是有 3 個字段構成,分別是是真正存儲切片數據的地址 DData unsafe.Pointer, 當前切片中的元素個數 Len  int, 切片的容量 Cap  int。一個切片類型的變量除了要考慮切片變量自身在哪裏存儲,還要考慮切片中元素的在哪裏存儲。下面的代碼保存在 escape.go 文件中。

package main

import (
 "reflect"
 "unsafe"
)

func printHeader(header *[]int) {
 sh := (*reflect.SliceHeader)(unsafe.Pointer(header))
 println("slice data addr ", unsafe.Pointer(sh.Data))
}
func escapeDemo8() {
 var s []int
 println("s addr "&s)
 printHeader(&s)
 s = append(s, 1)
 println("add 1 to s")
 printHeader(&s)

 s = append(s, 2)
 println("add 2 to s")
 printHeader(&s)
}

func escapeDemo9() {
 var s = make([]int, 0, 4)
 println("\ns addr "&s)
 printHeader(&s)

 s = append(s, 1)
 println("add 1 to s")
 printHeader(&s)

 s = append(s, 2)
 println("add 2 to s")
 printHeader(&s)

 s = append(s, 3)
 println("add 3 to s")
 printHeader(&s)

 s = append(s, 4)
 println("add 4 to s")
 printHeader(&s)

 s = append(s, 5)
 println("add 5 to s")
 printHeader(&s)

}

func escapeDemo10() *[]int {
 var s = make([]int, 0, 4)
 println("\ns addr "&s)
 printHeader(&s)

 s = append(s, 1)
 println("add 1 to s")
 printHeader(&s)

 return &s
}

func main() {
 escapeDemo8()
 escapeDemo9()
 escapeDemo10()
}

執行 go build -gcflags="-m -l" escape.go 得到如下輸出, 可以看到 escapeDemo10 中的 s 發生了逃逸,很容易理解,因爲函數返回的 s 的引用。其他兩個函數中的 s 都沒有發送逃逸。

# command-line-arguments
./escape.go:8:18: header does not escape
./escape.go:26:14: make([]int, 0, 4) does not escape
./escape.go:53:6: moved to heap: s
./escape.go:53:14: make([]int, 0, 4) escapes to heap

執行 go run escape.go 運行下程序,輸出如下. escapeDemo8 中輸入 s 的地址爲 0xc00009af48,s 中數據 data 中第一個數據地址爲 0xc0000aa000,這時地址差異很大,是兩個不同的區域。因爲 s 沒有逃逸,所以它的地址在棧上,根據地址信息,data 是分配在堆上。在 escapeDemo9 中申請的是一個 4 個元素大小的切片。根據打印的地址信息 s 本身的地址(0xc00009aeb8)和數據地址(0xc00009ae90)是很相近的。所以可判斷開始他們都分配在棧區。當向裏面 append 第 5 個元素後,輸出數據地址變了,此時爲 0xc0000ac000,可知分配的數據地址發生了變化。跟 s 的地址差異很大,可以判斷此時數據分配在了堆上,並將之前棧上的數據拷貝到新分配的堆上。escapeDemo10 中切片自身和數據都分配在堆上。

s addr  0xc00009af48
slice data addr  0x0
add 1 to s
slice data addr  0xc0000aa000
add 2 to s
slice data addr  0xc0000aa010

s addr  0xc00009aeb8
slice data addr  0xc00009ae90
add 1 to s
slice data addr  0xc00009ae90
add 2 to s
slice data addr  0xc00009ae90
add 3 to s
slice data addr  0xc00009ae90
add 4 to s
slice data addr  0xc00009ae90
add 5 to s
slice data addr  0xc0000ac000

s addr  0xc00009af60
slice data addr  0xc00009af20
add 1 to s
slice data addr  0xc00009af20

切片類型(元素爲 * T 類型)的逃逸

上面分析的是切片元素爲值類型的切片情況,下面分析切片中的元素是指針類型的情況。還是先看一個實例,用事實說話。

func escapeDemo11() {
 s := make([]*int, 0, 4)
 v := 10
 s = append(s, &v)
}

執行下逃逸檢查看下輸出,得到的輸出爲

# command-line-arguments
./escape2.go:5:2: moved to heap: v
./escape2.go:4:11: make([]*int, 0, 4) does not escape

輸出的結果有點反常識,切片 s 沒有逃逸,但是 v 發生了逃逸。雖然不是什麼問題,因爲 v 最後是會被 GC 回收的,按照我們通常的理解,v 是不會發生逃逸的,因爲引用它的切片並沒有逃逸。那爲什麼會這樣呢?繼續看下面這個例子,看完你可能覺得有道理了。下面的代碼將切片 s 傳給了調用函數,然後在函數內部做了 append 操作。這裏的 s 是沒有逃逸的,但 v 是逃逸的,因爲在 callee 被調用函數執行完成之後,在調用函數里面是可以訪問 s 中的數據的,此時數據是要存在的。如果 v 不發生逃逸,當 callee 執行完後,回到調用函數,獲取切片中數據地址是已經被回收了的(因爲函數已出棧)。所以對於切片中是 * T 數據,都處理成逃逸,我猜測是考慮到了下面的情況,做了簡化統一處理。雖然 escapeDemo11 可以繼續分析 s 的作用域進一步決定 v 是否逃逸,但處理起來比較複雜。

func escapeDemo12() {
 s := make([]*int, 0, 4)
 callee(s)
}

func callee([]*int) {
 v := 10
 s = append(s, &v)
}

對於元素爲 * T 的切片,切片中的變量會逃逸,類似的在 map 和 chan 中也是這樣。

強制不逃逸

雖然 Go 中逃逸分析算法已經很強大,但也很難做非常準確。對於不是很明確的情況,逃逸分析處理最保險的做法是分配到堆上,雖然會犧牲一點性能,但能保證程序的正確性。那對於某個變量,我很清楚它分配在棧上肯定沒有問題,有沒有方法人爲阻止將其分配在堆上呢?有一個函數 noescape, 可以切斷逃逸分析算法,阻止將變量分配在堆上。noescape 在 src/runtime/stubs.go 中,下面是它的實現,這裏抽出來放在這裏。可以看到該函數是非導出的,也就是我們不能調用它。畢竟是黑科技,寫業務程序的用不上,是給 Go 標準庫和運行時實現用的,這裏就不做深究了。

func noescape(p unsafe.Pointer) unsafe.Pointer {
 x := uintptr(p)
 return unsafe.Pointer(x ^ 0)
}

總結

根據上面的實例分析,可以總結出幾個必然的情況。下面的幾種情況必然發生逃逸

在堆上分配內存比在棧上靜態分配內存開銷要大不少,因爲堆上的內存要靠 GC 回收,GC 是有代價的。逃逸分析算法非常複雜,工作中不確定變量到底分配在哪裏,直接執行 go build -gcflags="-m" gofile 可以觀察到變量是否發生逃逸。Go 中的逃逸分析是在編譯階段完成的,不是在運行時,這點與 Java 不太一樣。

Detailed explanation of the mechanism of golang escape analysis[1] 通過實例理解 Go 逃逸分析 [2]

Reference

[1]

Detailed explanation of the mechanism of golang escape analysis: https://developpaper.com/detailed-explanation-of-the-mechanism-of-golang-escape-analysis/

[2]

通過實例理解 Go 逃逸分析: https://blog.csdn.net/bigwhite20xx/article/details/117236072

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/_tjDnPcdBkUpjJ80f3cGTg