go語言記憶體管理(3):逃逸分析
Go 語言較之 C 語言一個很大的優勢就是自帶 GC 功能,可 GC 並不是沒有代價的。寫 C 語言的時候,在一個函式內宣告的變數,在函式退出後會自動釋放掉,因為這些變數分配在棧上。如果你想要變數的資料能在函式退出後還能訪問,就需要呼叫 malloc
方法在堆上申請記憶體,如果程式不再需要這塊記憶體了,再呼叫 free
方法釋放掉。Go 語言不需要你主動呼叫 malloc
來分配堆空間,編譯器會自動分析,找出需要 malloc
的變數,使用堆記憶體。編譯器的這個分析過程就叫做逃逸分析。
所以你在一個函式中通過 dict := make(map[string]int)
建立一個 map 變數,其背後的資料是放在棧空間上還是堆空間上,是不一定的。這要看編譯器分析的結果。
可逃逸分析並不是百分百準確的,它有缺陷。有的時候你會發現有些變數其實在棧空間上分配完全沒問題的,但編譯後程序還是把這些資料放在了堆上。如果你瞭解 Go 語言編譯器逃逸分析的機制,在寫程式碼的時候就可以有意識的繞開這些缺陷,使你的程式更高效。
關於堆疊
Go 語言雖然在記憶體管理方面降低了程式設計門檻,即使你不瞭解堆疊也能正常開發,但如果你要在效能上較真的話,還是要掌握這些基礎知識。
這裡不對堆記憶體和棧記憶體的區別做太多闡述。簡單來說就是,棧分配廉價,堆分配昂貴。棧空間會隨著一個函式的結束自動釋放,堆空間需要 GC 模組不斷的跟蹤掃描回收。如果對這兩個概念有些迷糊,建議閱讀下面 2 個文章:
這裡舉一個小例子,來對比下堆疊的差別:
func stack() int {
// 變數 i 會在棧上分配
i := 10
return i
}
func heap() *int {
// 變數 j 會在堆上分配
j := 10
return &j
}
stack
函式中的變數 i
在函式退出會自動釋放;而 heap
函式返回的是對變數i
的引用,也就是說 heap()
退出後,表示變數 i
他們編譯出來的程式碼如下:
// go build --gcflags '-l' test.go
// go tool objdump ./test
TEXT main.stack(SB) /tmp/test.go
test.go:7 0x487240 48c74424080a000000 MOVQ $0xa, 0x8(SP)
test.go:7 0x487249 c3 RET
TEXT main.heap(SB) /tmp/test.go
test.go:9 0x487250 64488b0c25f8ffffff MOVQ FS:0xfffffff8, CX
test.go:9 0x487259 483b6110 CMPQ 0x10(CX), SP
test.go:9 0x48725d 7639 JBE 0x487298
test.go:9 0x48725f 4883ec18 SUBQ $0x18, SP
test.go:9 0x487263 48896c2410 MOVQ BP, 0x10(SP)
test.go:9 0x487268 488d6c2410 LEAQ 0x10(SP), BP
test.go:10 0x48726d 488d05ac090100 LEAQ 0x109ac(IP), AX
test.go:10 0x487274 48890424 MOVQ AX, 0(SP)
test.go:10 0x487278 e8f33df8ff CALL runtime.newobject(SB)
test.go:10 0x48727d 488b442408 MOVQ 0x8(SP), AX
test.go:10 0x487282 48c7000a000000 MOVQ $0xa, 0(AX)
test.go:11 0x487289 4889442420 MOVQ AX, 0x20(SP)
test.go:11 0x48728e 488b6c2410 MOVQ 0x10(SP), BP
test.go:11 0x487293 4883c418 ADDQ $0x18, SP
test.go:11 0x487297 c3 RET
test.go:9 0x487298 e8a380fcff CALL runtime.morestack_noctxt(SB)
test.go:9 0x48729d ebb1 JMP main.heap(SB)
// ...
TEXT runtime.newobject(SB) /usr/share/go/src/runtime/malloc.go
malloc.go:1067 0x40b070 64488b0c25f8ffffff MOVQ FS:0xfffffff8, CX
malloc.go:1067 0x40b079 483b6110 CMPQ 0x10(CX), SP
malloc.go:1067 0x40b07d 763d JBE 0x40b0bc
malloc.go:1067 0x40b07f 4883ec28 SUBQ $0x28, SP
malloc.go:1067 0x40b083 48896c2420 MOVQ BP, 0x20(SP)
malloc.go:1067 0x40b088 488d6c2420 LEAQ 0x20(SP), BP
malloc.go:1068 0x40b08d 488b442430 MOVQ 0x30(SP), AX
malloc.go:1068 0x40b092 488b08 MOVQ 0(AX), CX
malloc.go:1068 0x40b095 48890c24 MOVQ CX, 0(SP)
malloc.go:1068 0x40b099 4889442408 MOVQ AX, 0x8(SP)
malloc.go:1068 0x40b09e c644241001 MOVB $0x1, 0x10(SP)
malloc.go:1068 0x40b0a3 e888f4ffff CALL runtime.mallocgc(SB)
malloc.go:1068 0x40b0a8 488b442418 MOVQ 0x18(SP), AX
malloc.go:1068 0x40b0ad 4889442438 MOVQ AX, 0x38(SP)
malloc.go:1068 0x40b0b2 488b6c2420 MOVQ 0x20(SP), BP
malloc.go:1068 0x40b0b7 4883c428 ADDQ $0x28, SP
malloc.go:1068 0x40b0bb c3 RET
malloc.go:1067 0x40b0bc e87f420400 CALL runtime.morestack_noctxt(SB)
malloc.go:1067 0x40b0c1 ebad JMP runtime.newobject(SB)
邏輯的複雜度不言而喻,上面的彙編中可看到, heap()
函式呼叫了 runtime.newobject()
方法,它會呼叫 mallocgc
方法從 mcache
上申請記憶體,申請的內部邏輯前面文章已經講述過。堆記憶體分配不僅分配上邏輯比棧空間分配複雜,它最致命的是會帶來很大的管理成本,Go 語言要消耗很多的計算資源對其進行標記回收(也就是 GC 成本)。
不要以為使用了堆記憶體就一定會導致效能低下,使用棧記憶體會帶來效能優勢。因為實際專案中,系統的效能瓶頸一般都不會出現在記憶體分配上。千萬不要盲目優化,找到系統瓶頸,用資料驅動優化。
逃逸分析
Go 編輯器會自動幫我們找出需要進行動態分配的變數,它是在編譯時追蹤一個變數的生命週期,如果能確認一個數據只在函式空間內訪問,不會被外部使用,則使用棧空間,否則就要使用堆空間。
我們在 go build
編譯程式碼時,可使用 -gcflags '-m'
引數來檢視逃逸分析日誌。
go build -gcflags '-m -m' test.go
以上面的兩個函式為例,編譯的日誌輸出是:
/tmp/test.go:11:9: &i escapes to heap
/tmp/test.go:11:9: from ~r0 (return) at /tmp/test.go:11:2
/tmp/test.go:10:2: moved to heap: i
/tmp/test.go:16:18: heap() escapes to heap
/tmp/test.go:16:18: from ... argument (arg to ...) at /tmp/test.go:16:13
/tmp/test.go:16:18: from *(... argument) (indirection) at /tmp/test.go:16:13
/tmp/test.go:16:18: from ... argument (passed to call[argument content escapes]) at /tmp/test.go:16:13
/tmp/test.go:16:13: main ... argument does not escape
日誌中的 &i escapes to heap
表示該變數資料逃逸到了堆上。
逃逸分析的缺陷
需要使用堆空間則逃逸,這沒什麼可爭議的。但編譯器有時會將不需要使用堆空間的變數,也逃逸掉。這裡是容易出現效能問題的大坑。網上有很多相關文章,列舉了一些導致逃逸情況,其實總結起來就一句話:
多級間接賦值容易導致逃逸。
這裡的多級間接指的是,對某個引用類物件中的引用類成員進行賦值。Go 語言中的引用類資料型別有 func
, interface
, slice
, map
, chan
, *Type(指標)
。
記住公式 Data.Field = Value
,如果 Data
, Field
都是引用類的資料型別,則會導致 Value
逃逸。這裡的等號 =
不單單隻賦值,也表示引數傳遞。
根據公式,我們假設一個變數 data
是以下幾種型別,相應的可得出結論:
[]interface{}
:data[0] = 100
會導致100
逃逸map[string]interface{}
:data["key"] = "value"
會導致"value"
逃逸map[interface{}]interface{}
:data["key"] = "value"
會導致key
和value
都逃逸map[string][]string
:data["key"] = []string{"hello"}
會導致切片逃逸map[string]*int
: 賦值時*int
會 逃逸[]*int
:data[0] = &i
會使i
逃逸func(*int)
:data(&i)
會使i
逃逸func([]string)
:data([]{"hello"})
會使[]string{"hello"}
逃逸chan []string
:data <- []string{"hello"}
會使[]string{"hello"}
逃逸- 以此類推,不一一列舉了
下面給出一些實際的例子:
函式變數
如果變數值是一個函式,函式的引數又是引用型別,則傳遞給它的引數都會逃逸。
func test(i int) {}
func testEscape(i *int) {}
func main() {
i, j, m, n := 0, 0, 0, 0
t, te := test, testEscape // 函式變數
// 直接呼叫
test(m) // 不逃逸
testEscape(&n) // 不逃逸
// 間接呼叫
t(i) // 不逃逸
te(&j) // 逃逸
}
./test.go:4:17: testEscape i does not escape
./test.go:11:5: &j escapes to heap
./test.go:11:5: from te(&j) (parameter to indirect call) at ./test.go:11:4
./test.go:7:5: moved to heap: j
./test.go:14:13: main &n does not escape
上例中 te
的型別是 func(*int)
,屬於引用型別,引數 *int
也是引用型別,則呼叫 te(&j)
形成了為 te
的引數(成員) *int
賦值的現象,即 te.i = &j
會導致逃逸。程式碼中其他幾種呼叫都沒有形成多級間接賦值情況。
同理,如果函式的引數型別是 slice
, map
或 interface{}
都會導致引數逃逸。
func testSlice(slice []int) {}
func testMap(m map[int]int) {}
func testInterface(i interface{}) {}
func main() {
x, y, z := make([]int, 1), make(map[int]int), 100
ts, tm, ti := testSlice, testMap, testInterface
ts(x) // ts.slice = x 導致 x 逃逸
tm(y) // tm.m = y 導致 y 逃逸
ti(z) // ti.i = z 導致 z 逃逸
}
./test.go:3:16: testSlice slice does not escape
./test.go:4:14: testMap m does not escape
./test.go:5:20: testInterface i does not escape
./test.go:8:17: make([]int, 1) escapes to heap
./test.go:8:17: from x (assign-pair) at ./test.go:8:10
./test.go:8:17: from ts(x) (parameter to indirect call) at ./test.go:10:4
./test.go:8:33: make(map[int]int) escapes to heap
./test.go:8:33: from y (assign-pair) at ./test.go:8:10
./test.go:8:33: from tm(y) (parameter to indirect call) at ./test.go:11:4
./test.go:12:4: z escapes to heap
./test.go:12:4: from ti(z) (parameter to indirect call) at ./test.go:12:4
匿名函式的呼叫也是一樣的,它本質上也是一個函式變數。有興趣的可以自己測試一下。
間接賦值
type Data struct {
data map[int]int
slice []int
ch chan int
inf interface{}
p *int
}
func main() {
d1 := Data{}
d1.data = make(map[int]int) // GOOD: does not escape
d1.slice = make([]int, 4) // GOOD: does not escape
d1.ch = make(chan int, 4) // GOOD: does not escape
d1.inf = 3 // GOOD: does not escape
d1.p = new(int) // GOOD: does not escape
d2 := new(Data) // d2 是指標變數, 下面為該指標變數中的指標成員賦值
d2.data = make(map[int]int) // BAD: escape to heap
d2.slice = make([]int, 4) // BAD: escape to heap
d2.ch = make(chan int, 4) // BAD: escape to heap
d2.inf = 3 // BAD: escape to heap
d2.p = new(int) // BAD: escape to heap
}
./test.go:20:16: make(map[int]int) escapes to heap
./test.go:20:16: from d2.data (star-dot-equals) at ./test.go:20:10
./test.go:21:17: make([]int, 4) escapes to heap
./test.go:21:17: from d2.slice (star-dot-equals) at ./test.go:21:11
./test.go:22:14: make(chan int, 4) escapes to heap
./test.go:22:14: from d2.ch (star-dot-equals) at ./test.go:22:8
./test.go:23:9: 3 escapes to heap
./test.go:23:9: from d2.inf (star-dot-equals) at ./test.go:23:9
./test.go:24:12: new(int) escapes to heap
./test.go:24:12: from d2.p (star-dot-equals) at ./test.go:24:7
./test.go:13:16: main make(map[int]int) does not escape
./test.go:14:17: main make([]int, 4) does not escape
./test.go:15:14: main make(chan int, 4) does not escape
./test.go:16:9: main 3 does not escape
./test