你有沒有看過這個錯誤:

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 exceeded

Python 預設 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)
  • 預分配 slicemake([]T, 0, expectedLen) 避免 slice 在成長時不斷 reallocate

ulimit -s 和 Stack 大小

在 Linux 上,你可以用 ulimit -s 查看當前 thread 的 stack size 限制(通常 8 MB)。在某些需要深遞迴的場景,你可能需要調整這個值——但更好的解法通常是把遞迴改成迭代或用 Go 的 goroutine(它的 stack 可以動態成長)。

Actor