Go Escape Analysis:你的變數住在 Stack 還是 Heap?
讀完這篇,你會理解 Go 編譯器如何決定變數的分配位置,以及如何用 -gcflags=-m 看穿這些決策
讀完這篇,你會理解 Go 編譯器如何決定變數的分配位置,以及如何用
-gcflags=-m看穿這些決策
為什麼該在意這件事
寫 Go 的時候,不會需要手動 malloc / free,但這不代表記憶體分配對你來說無關痛癢
一個回傳 *User 的函數和回傳 User 的函數,效能差異可能超乎你的想像。真正的原因是前者可能觸發 Heap 分配和 GC 掃描,不是「指標比較快」這個常見迷思
決定這一切的,就是 Escape Analysis(逃逸分析)
核心概念:Stack vs Heap
Go 編譯器在編譯期就決定每個變數要分配在哪裡:
| 分配位置 | 條件 | 特性 |
|---|---|---|
| Stack | 變數生命週期不超過函數 | 快速分配、自動回收、cache 友好 |
| Heap | 變數可能在函數結束後被引用 | 需要 GC 管理、有額外開銷 |
重點:「逃逸」不是執行期的搬移。編譯器在編譯時就決定了:這個變數一開始就要分配在 Heap 上
四種常見逃逸情境
1. 回傳指標
// user 逃逸到 Heap
func createUserB() *User {
user := User{Name: "Bob", Age: 25}
return &user // 呼叫者需要這個位址 → 必須逃逸
}
// user 留在 Stack
func createUserA() User {
user := User{Name: "Alice", Age: 30}
return user // 值複製 → 原始 user 隨 frame 消失也沒關係
}
2. 閉包捕捉
func counter() func() int {
count := 0
return func() int {
count++ // 閉包捕捉了 count
return count // counter() 結束後閉包仍需要 count → 逃逸
}
}
3. 傳給 interface{}
func example() {
x := 42
fmt.Println(x) // fmt.Println 接收 any,且無法內聯 → x 逃逸
}
4. 回傳 Slice 底層陣列
func makeSlice() []int {
s := make([]int, 0, 10)
s = append(s, 1, 2, 3)
return s // slice header 值複製,但底層陣列必須逃逸
}
親眼看見逃逸
用 -gcflags=-m 讓編譯器告訴你決策:
go build -gcflags="-m" main.go
輸出範例:
./main.go:17:9: &user escapes to heap
./main.go:16:2: moved to heap: user
加兩個 -m 看更多細節:
go build -gcflags="-m -m" main.go
三個常見誤解
大型結構一定會逃逸 — 逃逸取決於生命週期和引用關係,不是大小。1MB 的結構如果只在函數內使用且不回傳指標,它仍然可以留在 Stack
用指標一定比較快 — 對於小型結構,值複製的成本可能低於逃逸到 Heap 的分配成本 + GC 掃描成本 + 指標間接存取的 cache miss 成本
逃逸是執行時發生的 — 編譯期決策,執行期只是按照決定分配
什麼時候該管、什麼時候別管
適合優化:
- Hot path 上的頻繁分配
- Profiling 顯示 GC 壓力大
-gcflags=-m顯示意外的逃逸
不該過度優化:
- 沒有效能問題的程式碼
- 可讀性會嚴重下降
- 一次性執行的初始化程式碼
快速檢查清單
當你想知道某個變數是否會逃逸:
- 它的指標有沒有被回傳?
- 它有沒有被閉包捕捉?
- 它有沒有被傳給
any(interface{})? - 它有沒有被傳給無法內聯的函數?
- 它有沒有被存入會被回傳的容器?
如果以上都是「否」,它很可能留在 Stack。有任何一個是「是」,用 -gcflags=-m 確認
總結
- Escape Analysis 是編譯期決策,決定變數住 Stack 還是 Heap
- 回傳指標、閉包捕捉、interface{}/any 是最常見的逃逸原因
- 先用
-gcflags=-m看見事實,再用 profiling 決定要不要優化