你有沒有看過這個錯誤:
RuntimeError: maximum recursion depth exceeded
或是 Go 的:
goroutine 1 [running]:
runtime: goroutine stack exceeds 1000000000-byte limit
這些都是 stack 空間耗盡的錯誤。要理解它們,要先知道 stack 和 heap 各自是什麼,為什麼有大小限制。
Stack:函式的工作空間
每次你呼叫一個函式,OS 在 stack 上分配一塊空間,存放:
- 函式的參數
- 函式的局部變數
- 返回位址(函式結束後要回到哪裡)
函式返回,這塊空間立刻釋放——不需要 GC,不需要 free,指標移回去就好了。
func add(a, b int) int {
result := a + b // result 在 stack 上(Go 編譯器確認它不會逃逸)
return result // 函式返回,result 的空間立刻釋放
}Stack 的分配和釋放極快,因為只是移動指標;大小有限,通常 1-8 MB per thread;生命週期和函式 scope 完全綁定,不需要 GC 介入。
Heap:長壽資料的家
不能放在 stack 上的東西放在 heap:
- 大小在編譯時不確定的資料
- 需要跨函式存活的資料
- 很大的資料
data = [1, 2, 3, 4, 5] # list 在 heap 上Heap 大小幾乎不受限(受實體記憶體限制);分配/釋放比 stack 慢,因為需要找空閒塊、可能碎片化;生命週期由 GC、ownership 或 free 管理。
Stack Overflow:遞迴的代價
遞迴呼叫每一層都在 stack 上分配新的空間。如果遞迴太深,stack 耗盡:
def factorial(n):
if n == 0:
return 1
return n * factorial(n - 1)
factorial(10000) # RecursionError: maximum recursion depth exceededPython 預設 recursion limit 是 1000,你可以用 sys.setrecursionlimit 調高,但根本問題是 stack 空間有限。
解法是尾遞迴(tail recursion)或迭代。函數式語言(Haskell、Erlang)的編譯器會自動把尾遞迴轉成迭代(TCO, Tail Call Optimization),Python 和 Java 不做這個。
各語言怎麼分配到 Stack vs Heap
Go:編譯器做逃逸分析
Go 的編譯器做 escape analysis——判斷一個變數的 lifetime 是否只在當前函式,如果是,放 stack;如果會「逃逸」到函式外(比如被 goroutine 引用、被 return 出去),放 heap。
func noEscape() *int {
x := 42
return &x // x 的位址被 return 出去,x 逃逸到 heap
}
func escape() {
x := 42
fmt.Println(x) // x 只在這個函式用,可能在 stack
}你可以用 go build -gcflags='-m' 看編譯器的逃逸分析結果。逃逸到 heap 的物件越少,GC 壓力越小。
Java / Python:幾乎所有物件在 Heap
Java 的 primitive(int, long, boolean)在 stack,物件在 heap。Python 的一切都是物件,幾乎都在 heap。
這是 Python 和 Java 的 GC 壓力比 Go 高的原因之一——分配率高,GC 要掃描的東西多。
Rust:編譯時確定
Rust 的所有權系統讓編譯器在編譯時就知道每個值的 scope——stack 分配的東西在 scope 結束時自動呼叫 drop(類似 destructor)。Heap 分配要明確用 Box<T>、Vec<T> 等 heap 容器。
Goroutine 的 Stack:動態成長
OS thread 有固定大小的 stack(通常 1-8 MB)。Go 的 goroutine 的 stack 從 2KB 開始,需要更多時自動擴展,最大預設 1 GB。
這就是為什麼你能開百萬個 goroutine——每個起始成本只有 2KB,而不是 OS thread 的 1-8 MB。當一個 goroutine 的 stack 要擴展,Go runtime 分配一塊更大的空間,複製現有內容過去。
實際的後端影響
減少 heap allocation 等於減少 GC 壓力
在高 QPS 的服務裡,每次請求如果分配很多小物件,GC 要持續回收,增加 latency jitter。常見優化:
sync.Pool(Go):重用物件,避免每次都new- Stack allocation:設計函式讓變數不逃逸(需要靠逃逸分析 profiling)
- 預分配 slice:
make([]T, 0, expectedLen)避免 slice 在成長時不斷 reallocate
ulimit -s 和 Stack 大小
在 Linux 上,你可以用 ulimit -s 查看當前 thread 的 stack size 限制(通常 8 MB)。在某些需要深遞迴的場景,你可能需要調整這個值——但更好的解法通常是把遞迴改成迭代或用 Go 的 goroutine(它的 stack 可以動態成長)。
→ Actor