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

Future、Promise 與 Async/Await

一個 future 是「還沒到場的值」。本篇從底層把這個點子建起來——一個用來填它的 promise、一個在它到場時要跑的後續(continuation),以及那套把整台狀態機藏起來的 async/await 語法,讓你的程式雖然在中途暫停,讀起來卻仍是由上往下的。

一個指向尚未到場之值的把手

第 1、2 篇給了你底下的機械:一個執行緒池,其工作執行緒從一個工作佇列上拉取工作單位;以及一個工作竊取式排程器,靠讓閒置者向鄰居偷工作來讓那些工作者保持忙碌。你在那裡提交的是一個「丟了就不管」的工作——你交進一個閉包,池子就把它跑掉。但大多數真實工作會產出你需要拿回來的結果:一次網路讀取回傳的位元組、一次資料庫查詢找到的列、一次長計算落定的那個數字。本篇要回答的正是缺的那一半:當你提交工作,要怎麼在不阻塞發問的那條執行緒下拿到它的結果?

答案是一個 future:一個你立刻收到的小把手物件,代表一個稍後才會存在的值。把它想成你在衣帽間拿到的那張紙票。票不是你的外套——但它是對外套一份貨真價實的索取權,你可以走開、去做別的事,等外套備妥時再去兌換。一個 future 恰有兩個階段。工作執行時它是未決(pending):向它要值就意味著等待。工作完成後它變成就緒(ready)(或說已解析,resolved),這時 future 裡持有的,要嘛是結果、要嘛同樣誠實地、持有發生的那個錯誤——因為真實工作會失敗,而一個只能裝成功的 future 會是個謊言。

注意 future 替你買到了什麼:時間上的解耦。提交工作與消費其結果如今是兩個分開的時刻,而提交的那條執行緒在兩者之間是自由的。這就是全部的重點。future 不會讓工作變快;它讓等待變得有生產力,因為你握的是一個便宜的把手、而不是一條被阻塞的執行緒。這跟稍早非阻塞輸入輸出那級是同一課——絕不要讓一個慢操作釘住一整條執行緒——如今被抬升到任意計算的層次,而不只是檔案描述符。

同一管道的兩端:promise 來填,future 來讀

值究竟從哪來?這裡有個最乾淨的想像方式,也解釋了為什麼這一對常被合稱為 future 與 promise。它們是同一條一次性管道的兩端。promise寫入端,由正在計算這個值的人持有;呼叫它的 `set_value(...)`(或 `set_exception(...)`)就把它恰好兌現一次。future讀取端,交給正在等待的人;它觀察 promise 放進去的那個值。一個生產者、一個消費者、一個值——而關鍵在於順序無所謂:消費者可以先抓住 future 並等待,而生產者還沒算出任何東西;或者生產者可以在消費者去看之前就兌現 promise。管道會記得。

Thread A (producer)                  Thread B (consumer)
-------------------                  -------------------
promise<int> p;                      future<int> f = p.get_future();
future<int> f = p.get_future();      // ... do other work ...
submit_to_pool([&]{                   int v = f.get();   // blocks ONLY
    int r = slow_compute();          //   if not ready yet;
    p.set_value(r);  // fulfil once  //   returns r once set
});
promise/future 一對背後的共享狀態。promise 的 set_value 在生產者端發生一次;future 的 get() 在消費者端觀察它。這一對會同步——set_value happens-before get() 回傳——所以結果跨執行緒安全可見。

最後這一點不是枝節——它正是讓 future 安全的那紙契約。這一對攜帶了一條來自記憶體模型那級的 happens-before 邊:生產者在 `set_value` 之前寫的一切,保證在消費者的 `get` 回傳之後可見,沒有資料競爭、你這邊也不必額外上鎖。這一對背後的共享狀態持有三樣東西:值或錯誤的槽位、一個就緒旗標,以及把一端發佈給另一端的同步。所以 future 不只是一個方便的包裝;它是一次微小而正確的「把結果的所有權從一條執行緒交接到另一條」,順序早已被證明好了。你是在重用得來不易的 acquire/release 機制,而不必每次都把它寫出來。

Continuation:別阻塞——說好接下來做什麼

用 future 最天真的方式是呼叫 `get()` 並阻塞到它就緒。這能動,但它把全部的好處都丟掉了:一條被阻塞的執行緒是一條停放的執行緒,而如果它是你第 1 篇那批寶貴的池工作者之一,你就剛把一個工作者從池子裡抽走、讓它除了等待什麼都不做。在小池子上,這正是程式把自己鎖死的方式——每個工作者都坐在某個 future 上阻塞,而兌現那個 future 的工作還卡在佇列裡,沒有空閒工作者去跑它。在執行器裡阻塞於一個 future,是頭號大罪:別阻塞執行器

解法是別再問「就緒了嗎?」,改為登記「當它就緒時該發生什麼」。那段登記下來的工作就是一個 continuation(後續)——字面上就是你計算的後續,是值落定後接手的那段程式。你不寫 `int v = f.get(); use(v);`,而是寫成像 `f.then(use);`:「當 f 解析時,把它的值交給 `use`,並在一條池執行緒上跑它。」你立刻回傳;執行緒從不被阻塞。當 promise 兌現了 future,執行期就把這個 continuation 當成一個全新工作排上池子——這正是第 1 篇的工作模型,如今在某個值就緒的那一刻被自動產生出來。

Async/await:一台讀起來像直線的狀態機

這裡是讓這一切變得實用的把戲。Async/await 是一套語法,讓你能把「以 continuation 傳遞」的程式寫得彷彿是普通由上往下的程式,並讓編譯器替你做切碎的工作。你把一個函式標成 `async`;在它裡面你 `await` 一個 future。`await` 的意思是:「如果這個值還沒就緒,就在這裡暫停、把控制權交還給執行期;一旦它就緒,就從這個確切的位置繼續。」關鍵字是暫停(suspend),不是阻塞(block)。執行緒沒有被停放——它被釋放去跑其他工作。只有這條邏輯流暫停了。

// What you WRITE (reads like a straight line):
async fn fetch_user(id) {
    let conn = await open_db();      // suspend point 1
    let row  = await conn.query(id); // suspend point 2
    return parse(row);
}

// What the compiler GENERATES (a state machine):
//   state 0: start open_db; on ready -> save conn, go to state 1
//   state 1: start query;   on ready -> save row,  go to state 2
//   state 2: return parse(row)   (done)
// Locals conn/row live in a struct, NOT on a thread's stack.
Async/await 是覆蓋在編譯器產生之狀態機上的語法糖。每個 await 變成一個有標籤的繼續點;必須跨越一次暫停而存活的區域變數,被存進一個堆積物件、而非呼叫堆疊上——這正是為什麼這種風格叫「無堆疊(stackless)」。

看看編譯器做了什麼。它在每個 `await` 處把函式切開,變成一台狀態機:一個結構,持有一個「我在第幾步」的標籤、外加恰好那些必須跨暫停存活的區域變數。繼續這個函式,只是依標籤跳到正確的 case。每個 `await` 精確地就是一個 continuation——跟上一節同一個機制——只是如今由編譯器產生並接線,而非由你打字。你讀的程式是一條直線;跑的程式是一台可繼續的狀態機。那道被無形地架起的鴻溝,正是 async/await 的全部禮物:以 continuation 傳遞的效能,配上循序程式的可讀性。

因為存活的區域變數住在那個堆積結構裡、而非執行緒的呼叫堆疊上,這種風味叫做 無堆疊(stackless)——暫停的工作不帶自己的機器堆疊,只帶它那個小小的狀態結構。這就是為什麼一個 async 工作可以只佔幾十位元組,而一條 作業系統執行緒的堆疊以 KiB 或 MiB 計,也是為什麼一台機器能跑數百萬個 async 工作、卻只能跑數千條執行緒。它的取捨——第 4 篇會完整攤開——是無堆疊的 `await` 只能在編譯器轉換過的那些點暫停;你無法像有堆疊的協程那樣,在一個普通被呼叫函式的中途暫停。我們現在先把這道接縫指出來,好讓下一篇把它撬開。

誰來跑這台狀態機?執行器與它的規則

一台沒人去踩的狀態機只是一個凍住的結構。必須有某個東西在某個工作所等待的值就緒時,去對它呼叫「繼續」,那個東西就是 執行器(executor)(或說執行期,runtime)——而到這裡,迴圈接回了我們的起點。執行器就是第 1、2 篇那個執行緒池與工作竊取式排程器,只是如今驅動的是可暫停的工作、而非跑到結束的閉包。一小撮作業系統執行緒各自從佇列拉取就緒的工作並繼續它們;一個工作跑到它的下一個 `await` 就暫停,執行緒隨即抓下一個就緒工作。這就是 m 對 n 排程:m 個微小的 async 工作多工到 n 條作業系統執行緒上,這正是少數幾顆核心能服務一大片並行工作的全部緣由。

這是協作式(cooperative)而非搶佔式的排程,而這一個事實就支配了你必須怎麼寫 async 程式。一個工作會佔住一條執行緒,直到它在某個 `await` 處自願讓出。所以如果一個工作做一段很長的 CPU 迴圈、或——經典災難——呼叫一個沒有 `await` 的阻塞操作,像同步的 `read()` 或 `pthread_mutex_lock()`,它就永不讓出,獨佔那條執行緒。只有 n 條執行緒時,幾個這種工作就能凍住整個執行期:這又是別阻塞執行器,也是 async 程式最常見的死法。規則很尖銳:在 async 程式裡,每個可能變慢的操作都必須是它的 async、可 await 的形式,好讓每次等待時執行緒都被釋放。

這把你帶到哪裡

退一步看,各層乾淨地疊起來。一個 future 是指向「尚未存在之值」的把手,以一個 promise 作為它的寫入端,並內建一條 happens-before 保證。一個 continuation 說好「值落定時要做什麼」,把阻塞變成排程。Async/await 是替你把那些 continuation 寫成一台無堆疊狀態機的語法糖,讓可暫停的程式讀起來像一條直線。而執行器——你第 1 篇那個執行緒池——是繼續那些狀態機的東西,遵循一套你必須尊重的協作規則。每一層都重用它底下那一層;這裡沒有任何新機制,只有覆蓋在你早已建好之機制上的新形狀。

還剩兩道誠實的缺口,而本級其餘部分正是為了補上它們。第一,我們倚重了無堆疊這個詞——第 4 篇會把它跟有堆疊(stackful)協程正面對照,後者帶著一個真實的堆疊、能在任何地方暫停,以每個工作更多的記憶體,換取對「在哪暫停」更少的限制。第二,我們教了怎麼啟動await 工作,卻完全沒說一群 async 工作該如何一起被擁有取消收尾——而一個會洩漏、或你永遠取消不掉的 async 工作,是一個真實的臭蟲,不是一個漂亮的抽象。那就是結構化並行,第 5 篇的主題。你現在有了引擎;接下來兩篇給它形狀與煞車。