JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

協程:有堆疊與無堆疊

async/await 讓一個函式能暫停又恢復——但它在等待時,那半成品的狀態究竟存在哪裡?這個答案把協程的整個世界一分為二,而一旦你看見這道分界,你遇到的每一個執行期都會乾淨俐落地落在某一邊。

async/await 逼出來的那個問題

在上一篇,你看著一個函式撞上 `await`、把控制權交回執行期,稍後又在它離開的那一點原地醒來——它的區域變數完好無缺,準備好跑下一行。我們對那個恢復怎麼運作只是揮了揮手就帶過。現在我們把盒子打開。能在中途暫停又恢復的東西叫做協程(coroutine),而關於它最難的單一問題殘酷地實在:當它暫停時,它的區域變數究竟坐在記憶體的哪裡?一個普通函式把它的區域變數放在呼叫堆疊上的一個堆疊框裡——但那個框在函式回傳的那一瞬間就消失了,而暫停正是一種回傳。所以那份狀態必須住在某個能熬過暫停的地方。

誠實的答案恰好只有兩個,它們點出了本篇的大分界。有堆疊(stackful):給協程它自己完整、私有的呼叫堆疊——一塊真正的記憶體區域,有它自己的堆疊指標——當它暫停時,就只是停止碰那個堆疊、記住 rsp 在哪裡;區域變數從未移動,它們只是凍結地坐在一個目前沒人在用的堆疊上。無堆疊(stackless):根本不給它堆疊;而是在暫停前,把少數幾個必須存活的變數複製進一個堆積上的小物件裡,恢復時再把它們讀回來。兩者都行得通。兩者都是真實的。它們的成本天差地遠,而你的語言選了哪一個,會塑造從「一百萬個任務燒掉多少記憶體」到「你能不能從一個輔助函式深處暫停」的一切。

有堆疊:一個你可以走開的凍結堆疊

把有堆疊協程想成一個有自己辦公桌的工人。當執行期啟動它,會配置一個全新的堆疊——比方說 64 KiB 或 1 MiB 的記憶體——把 rsp 設到那塊區域的頂端,然後跳進去。協程正常地跑:它呼叫函式,那些呼叫推入框,區域變數堆疊起來,跟在主堆疊上一模一樣,因為它真的就是一個堆疊。當它走到一個 `await`,執行期做一件令人愉快地簡單的事:把目前的 rsp(外加少數幾個其他暫存器)存進協程的記帳物件,然後載入下一個該跑的人的 rsp、跳過去。那是一次完全在使用者空間完成的脈絡切換——沒有核心、沒有系統呼叫——只是換掉那唯一的 rip 正在走的是哪一個堆疊。

這件事的美在於你可以從任何地方暫停。因為暫停點只是凍結一個真實的堆疊,你在多深都無所謂——你可以在某個函式庫輔助函式裡往下十層呼叫,撞上一個 `await`,整座框的高塔一起凍結、稍後一起解凍。程式碼讀起來就像完全普通的阻塞式程式碼;函式簽章裡沒有任何東西需要宣告「我可能會暫停」。這個性質有個值得記住的名字:有堆疊協程支援跨任意呼叫深度的暫停。Go 的 goroutine 與許多 C++ 函式庫裡的 fiber 正是這樣運作,這就是為什麼一個 Go 函式在呼叫鏈深處能在一個 channel 上阻塞,而語言就這麼替你處理掉了。

成本同樣是實在的,你該對它誠實。每個協程都需要事先保留一整個堆疊,而你無法知道它會遞迴多深,所以你必須慷慨地猜、或付出代價去成長它。一個固定 1 MiB 的堆疊乘上一百萬個協程,就是一兆位元組(terabyte)的位址空間——這就是為什麼有堆疊執行期在這件事上很費勁:Go 用一個極小的約 2 KiB 到 8 KiB 堆疊啟動 goroutine,當它溢位時靠複製到一個更大的堆疊來成長它,而那次複製意味著每一個指進堆疊的指標都必須被找出來、重寫。那套機制,恰恰就是無堆疊協程存在所要避開的成本。

無堆疊:一個結構,就是一個暫停中的函式

無堆疊的答案拒絕配置任何堆疊。它的洞見是:一個暫停中的函式其實不需要它的整個堆疊被凍結——它只需要那少數幾個跨越暫停仍然存活的變數,外加一個數字:要從哪裡恢復。於是編譯器做了一個了不起的變換。它看你的 `async` 函式,找出每一個生命期跨越某個 `await` 的變數,把恰好那些打包進一個小結構裡——一台狀態機(state machine)——連同一個整數狀態欄位。函式本體被改寫成一個 switch:「如果狀態是 0,從頂端跑到第一個 await;如果狀態是 1,從第一個 await 之後恢復」,依此類推。那個結構就是那個暫停中的協程。

async fn read_two(sock) -> (Msg, Msg):       // what you write
    let a = await recv(sock)
    let b = await recv(sock)
    return (a, b)

// roughly what the compiler builds:
struct ReadTwo { state: int; sock: Socket; a: Msg; }   // only a survives the 2nd await; b never does

fn poll(self) -> Poll:
    switch self.state:
      case 0:  self.state = 1; return suspend_until(recv ready)   // a not yet set
      case 1:  self.a = recv(self.sock); self.state = 2; return suspend_until(recv ready)
      case 2:  let b = recv(self.sock); return Done((self.a, b))
一個有兩個 await 的 async 函式,以及無堆疊編譯器把它降階(lower)成的狀態機。那個結構只保存必須跨越暫停點的東西;整數狀態欄位就是「從這裡恢復」的標記。每次呼叫 poll() 會跑兩個 await 之間的一塊、並回報它是否完成了。

現在數一數成本。那個結構恰好只有它所需要的大小——沒有猜出來的堆疊、沒有浪費——而且它住在你放的任何地方:堆積上、另一個 future 裡、一個陣列中。一百萬個這種任務的成本是一百萬個小結構,常常每個只有幾百位元組,而不是一百萬個堆疊。這就是為什麼無堆疊 async 是那些想廉價地擁有海量微小並行任務的執行期所採用的模型,也正是 Rust 的 `async`/`await`、C++20 協程,以及 C#、JavaScript 與 Python 的 async 函式的建造方式。驅動它的那個記帳物件,就是上一篇的 future;呼叫它的 `poll` 會跑一個 await 之間的切片,這也是為什麼擁有那個迴圈的執行期叫做執行器(executor)

你放棄了什麼:函式顏色問題

無堆疊並非免費;它用記憶體換來一個尖銳的限制。因為編譯器是看一個函式的本體來建造狀態機,暫停點就必須在那個函式內部可見。你不能在十層普通呼叫的深處就這麼暫停——你身處的那個輔助函式沒有狀態機,沒有地方在暫停期間藏它的區域變數。所以語言逼你把整條鏈標出來:一個能暫停的函式必須宣告為 `async`,而要等待它你必須 `await` 它,這意味著呼叫者自己也必須是 `async`,一路往上都是。這就是著名的函式顏色問題(function-color problem):`async` 函式與普通函式是不同的顏色,一個普通函式不能透明地呼叫一個 async 函式。有堆疊協程根本沒有這個問題,因為它們凍結真實的堆疊、不在乎上面有什麼。

還有一個無堆疊的微妙之處現在值得認識,因為它真的會咬到 Rust 程式設計師。既然協程存活的區域變數住在一個結構裡,一個是指向同一個結構裡另一個區域變數的指標的區域變數,就製造出一個自我參照(self-reference):結構指向它自己。如果執行期曾經把那個結構移到一個新位址——比方說在成長一個持有它的向量時——那個內部指標就會懸空,一個教科書級的懸空指標。這正是為什麼 Rust 長出了 `Pin` 機制:它是一個承諾,保證一個 async future 一旦被 poll 過就永遠不會被移動,於是那些自我參照保持有效。這是一個真正的瑕疵,是無堆疊設計的一個誠實成本,不是一個可以含糊帶過的細節。

協程在更大的機器裡坐在哪裡

退一步看,協程便嵌進了這整級一直在建造的圖景。協程是一個能暫停的工作單元;執行器是那個恢復就緒者的迴圈;而底下坐著一小群真正的作業系統執行緒。許多協程乘載在少數幾個執行緒上——那就是稍早的 M 對 N 執行緒模型,M 個輕量任務多工到 N 個核心執行緒上,而協程恰恰就是一個 M 由什麼構成。那些任務是有堆疊還是無堆疊,是一個關於「每個 M 怎麼儲存它暫停狀態」的實作選擇;坐在它們之上的排程故事,無論哪一種都是一樣的。

協程這個詞連回它底下更深的觀念也有幫助:一個延續(continuation)。延續是「所有還剩下要做的工作」被捕捉成一個你可以稍後呼叫的值。恢復一個協程,恰恰就是去喚起它的延續——有堆疊版本把那個延續存成一個凍結的堆疊,無堆疊版本把它存成一個狀態機結構,但概念完全相同:凍結剩下的計算、帶著它走、在世界準備好時跑它。把兩種風格看成同一個延續的兩種編碼,是本篇能給你的最乾淨的心智統一。

在你爬上去之前有一個誠實的告誡。協程改變的是工作怎麼等待,不是底層 I/O 能不能阻塞。一個 `await` 只有在被等待的東西真的合作式地暫停時,才會讓出執行緒——在一個 async 函式裡呼叫一個阻塞式系統呼叫,像一個同步的 read(),你就會卡住整條執行器執行緒,凍結所有與它共用的其他協程。那就是第三級的規則「別阻塞執行器」披著協程的外衣。協程是一個漂亮又廉價的組織等待的方式;它們不會魔法般地把一個阻塞呼叫變成非阻塞。把那條界線保持明亮,下一篇——那些任務在那裡被組織進範圍、channel 與 actor——就會立在堅實的地基上。