有堆疊與無堆疊協程(stackful vs stackless coroutines)
離開座位時替自己夾書籤有兩種辦法。其一:把整張桌子原封不動地留著——文件攤開、筆停在句子中間——直接走人;回來時一切未動,你接著做。其二:寫張小紙條「我做到第 4 步,x=7」,把桌子清空給別人用,回來時照紙條重建。協程也照同樣方式一分為二。有堆疊協程在暫停時保有自己整條呼叫堆疊;無堆疊協程則只把恢復所需的最小狀態存進一個精簡的物件裡。
有堆疊協程——纖程、綠色執行緒、Go 的 goroutine——擁有一條真實(雖小)的堆疊,與任何作業系統執行緒的堆疊分開。暫停它,意味著保存 CPU 暫存器、並把堆疊指標切到另一個纖程的堆疊;被暫停的纖程其堆疊就凍結在記憶體裡。因為它有完整的堆疊,它可以從任何地方暫停,即使深陷某個被呼叫函式的內部,而 await 是隱形的——程式碼看起來很平實。代價是每個協程一條堆疊(Go 的 goroutine 起始約 2 KiB,再依需要增長)和每次暫停的一次上下文切換。無堆疊協程沒有自己的堆疊。編譯器把非同步函式轉換成一台狀態機:一個結構,裝著該函式仍存活的區域變數,加上一個整數記著它停在哪個 await。暫停只是從一次 poll() 呼叫回返;恢復是再呼叫一次 poll(),跳到保存的狀態去。這是 Rust async 和 C++20 協程。被暫停的狀態很小(就只是存活的區域變數),但你只能在非同步函式本身裡的明確 await 點暫停,無法從一個普通被呼叫函式內部暫停。
這個取捨是現代非同步設計的核心。有堆疊在人體工學上順手又一致(任何函式都能讓出,沒有函式染色問題),但要付每個協程的記憶體與切換成本。無堆疊極為輕量——一個暫停的 Rust 任務可能只有幾十位元組——且不需要獨立堆疊,但它逼出「async 會替函式染色」的規則(非同步函式只能從非同步程式碼裡被等待),而整條呼叫鏈都必須由可等待的零件搭成。沒有放諸四海皆準的「比較好」;Go 為了簡潔押注有堆疊,Rust 為了零成本的高密度押注無堆疊。兩者都是協作式排程、都會暫停與恢復;它們的差別在於存什麼、以及能在哪裡暫停。
有堆疊(Go):go work(); // work() 可在任意處阻塞並讓出,跑在自己約 KiB 大小的堆疊上。無堆疊(Rust):編譯器把 'async fn f' 改寫成一個狀態機結構 struct { state: u8, /* 存活的區域變數 */ },附帶一個 poll() 方法。
有堆疊停泊整條堆疊;無堆疊只把存活的區域變數加上一個「停在哪個 await」標籤存進精簡的狀態機。
常見混淆:goroutine 是被多工到作業系統執行緒上的有堆疊協程,並非作業系統執行緒本身。而無堆疊會替函式「染色」——async 只能從 async 裡被等待——這是設計限制而非臭蟲;有堆疊避開了它,代價是每個協程一條堆疊。