原文標題:Async/Await
原文連結:https://os.phil-opp.com/async-await/#multitasking
公眾號: Rust 碎碎念
翻譯 by: Praying
Async/Await 模式(The Async/Await Pattern)
async/await 背後的思想是讓程式設計師能夠像寫普通的同步程式碼那樣來編寫程式碼,由編譯器負責將其轉為非同步程式碼。它基於async
和await
兩個關鍵字來發揮作用。async
關鍵字可以被用於一個函式簽名,負責把一個同步函式轉為一個返回 future 的非同步函式。
async fn foo() -> u32 {
0
}
// the above is roughly translated by the compiler to:
fn foo() -> impl Future<Output = u32> {
future::ready(0)
}
這個關鍵字是無法單獨發揮作用的,但是在async
函式內部,await
關鍵字可以被用於取回(retrieve)一個 future 的非同步值。
async fn example(min_len: usize) -> String {
let content = async_read_file("foo.txt").await;
if content.len() < min_len {
content + &async_read_file("bar.txt").await
} else {
content
}
}
(嘗試在 playground 上執行這段程式碼[1])
這個函式是對example
函式的一個直接轉換,example
函式使用了上面提到的組合子函式(譯註:在譯文 Async/Await(二)中)。通過使用.await
操作,我們能夠在不需要任何閉包或者Either
的情況下檢索一個 future 的值。因此,我們可以像寫普通的同步程式碼一樣來寫我們的程式碼,不同之處在於我們寫的仍然是非同步程式碼。
狀態機轉換
編譯器在背後把async
函式體轉為一個狀態機(state machine)[2],每一個.await
呼叫表示一個不同的狀態。對於上面的example
函式,編譯器建立了一個帶有下面四種狀態的狀態機:
每個狀態表示函式中一個不同的暫停點。"Start"和"End"狀態表示開始執行的函式和執行結束的函式。"Waiting on foo.txt"狀態表示函式當前正在等待第一個async_read_file
的結果。類似地,"Waiting on bar.txt"表示函式正在等待第二個async_read_file
結果。
這個狀態機通過讓每一個poll
呼叫成為一次狀態轉換來實現Future
trait。
上面這張圖用箭頭表示狀態切換,用菱形表示分支路徑。例如,如果foo.txt
沒有準備好,就會選擇標記"no"的路徑然後進入”Waiting on foo.txt“狀態。否則,就會選擇"yes"路徑。中間較小的沒有標題的紅色菱形表示example
函式的if content.len() < 100
分支。
我們可以看到第一個poll
呼叫啟動了這個函式並使函式一直執行直到它到達一個尚未就緒的 future。如果這條路徑上的所有 future 都已就緒,該函式就可以一直執行到"End"狀態,這裡它把自己的結果包裝在Poll::Ready
中然後返回。否則,狀態機進入到一個等待狀態並返回"Poll::Pending"。在下一個poll
呼叫時,狀態機從上次等待狀態開始然後重試上次操作。
儲存狀態
為了能夠從上次等待狀態繼續下去,狀態機必須在內部記錄當前狀態。此外,它還必須要儲存下次poll
呼叫時繼續執行需要的所有變數。這也正是編譯器大展身手的地方:因為編譯器知道哪個變數在何時被使用,所以它可以自動生成結構體,這些結構體準確地包含了所需要的變數。
例如,編譯器可以針對上面的example
函式生成類似下面的結構體:
// 再次放上`example` 函式 ,你就不用去上面找它了
async fn example(min_len: usize) -> String {
let content = async_read_file("foo.txt").await;
if content.len() < min_len {
content + &async_read_file("bar.txt").await
} else {
content
}
}
// 編譯器生成的狀態結構體:
struct StartState {
min_len: usize,
}
struct WaitingOnFooTxtState {
min_len: usize,
foo_txt_future: impl Future<Output = String>,
}
struct WaitingOnBarTxtState {
content: String,
bar_txt_future: impl Future<Output = String>,
}
struct EndState {}
在"Start"和"Waiting on foo.txt"這兩個狀態(分別對應 StartState 和 WaitingOnFooTxtState 結構體)裡,引數min_len
需要被儲存起來,因為在後面和content.len()
進行比較時會需要用到它。"Waiting on foo.txt"狀態還需要額外儲存一個foo_txt_future
,它表示由async_read_file
呼叫返回的 future。這個 future 在當狀態機繼續的時候會被再次輪詢(poll),所以它也需要被儲存起來。
"Waiting on bar.txt"狀態(譯註:對應WaitingOnBarTxtState
結構體)包含了content
變數,因為它會在bar.txt
就緒後被用於字串拼接。該狀態還儲存了一個bar_txt_future
用以表示對bar.txt
正在進行的載入。WaitingOnBarTxtState
結構體不包含min_len
變數因為它在和 content.len()
比較後就不再被需要了。在"End"狀態下,沒有儲存任何變數,因為函式在這裡已經執行完成。
注意,這裡只是編譯器針對程式碼可能生成的一個示例。結構體的命名以及欄位的佈局都是實現細節並且可能有所不同。
完整的狀態機型別
雖然具體的編譯器生成程式碼是一個實現細節,但是它有助於我們理解example
函式生成的狀態機看起來是怎麼樣的?我們已經定義了表示不同狀態的結構體並且包含需要的欄位。為了能夠在此基礎上建立一個狀態機,我們可以把它組合進enum
:
enum ExampleStateMachine {
Start(StartState),
WaitingOnFooTxt(WaitingOnFooTxtState),
WaitingOnBarTxt(WaitingOnBarTxtState),
End(EndState),
}
我們為每個狀態定義一個單獨的列舉變數,並且把對應的狀態結構體新增到每個變數中作為一個欄位。為了實現狀態轉換,編譯器基於example
函式生成了一個Future
trait 的實現:
impl Future for ExampleStateMachine {
type Output = String; // return type of `example`
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
loop {
match self { // TODO: handle pinning
ExampleStateMachine::Start(state) => {…}
ExampleStateMachine::WaitingOnFooTxt(state) => {…}
ExampleStateMachine::WaitingOnBarTxt(state) => {…}
ExampleStateMachine::End(state) => {…}
}
}
}
}
future 的Output
型別是String
,因為它是example
函式的返回型別。為了實現poll
函式,我們在loop
內部對當前的狀態使用一個 match 語句。其思想在於只要有可能就切換到下一個狀態,當無法繼續的時候就使用一個顯式的return Poll::Pending
。
簡單起見,我們只能展示簡化的程式碼且不對pinning[3]、所有權、生命週期等進行處理。所以,這段程式碼以及接下來的程式碼就當成是虛擬碼,不要直接使用。當然,實際上編譯器生成的程式碼已經正確地處理好了一切,儘管可能是以另一種方式。
為了讓程式碼片段儘可能地小,我們為每個 match 分支單獨展示程式碼。讓我們先從Start
狀態開始:
ExampleStateMachine::Start(state) => {
// from body of `example`
let foo_txt_future = async_read_file("foo.txt");
// `.await` operation
let state = WaitingOnFooTxtState {
min_len: state.min_len,
foo_txt_future,
};
*self = ExampleStateMachine::WaitingOnFooTxt(state);
}
狀態機在函式開始時就處於Start
狀態,在這種情況下,我們從example
函式體執行所有的程式碼,直至遇到第一個.await
。為了處理.await
操作,我們把self
狀態機的狀態更改為WaitingOnFooTxt
,該狀態包括了對WaitingOnFooTxtState
的構造。
因為match self {...}
狀態是在一個迴圈裡執行的,這個執行接下來跳轉到WaitingOnFooTxt
分支:
ExampleStateMachine::WaitingOnFooTxt(state) => {
match state.foo_txt_future.poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(content) => {
// from body of `example`
if content.len() < state.min_len {
let bar_txt_future = async_read_file("bar.txt");
// `.await` operation
let state = WaitingOnBarTxtState {
content,
bar_txt_future,
};
*self = ExampleStateMachine::WaitingOnBarTxt(state);
} else {
*self = ExampleStateMachine::End(EndState));
return Poll::Ready(content);
}
}
}
}
在這個 match 分支,我們首先呼叫foo_txt_future
的poll
函式。如果它尚未就緒,我們就退出迴圈然後返回Poll::Pending
。因為這種情況下self
仍處於WaitingOnFooTxt
狀態,下一次的poll
呼叫將會進入到相同的 match 分支然後重試對foo_txt_future
輪詢。
當foo_txt_future
就緒後,我們把結果賦予content
變數並且繼續執行example
函式的程式碼:如果content.len()
小於儲存在狀態結構體裡的min_len
,bar.txt
檔案會被非同步地讀取。我們再次把.await
操作轉換為一個狀態改變,這次改變為WaitingOnBarTxt
狀態。因為我們在一個迴圈裡面正在執行match
,執行流程直接跳轉到新的狀態對應的 match 分支,這個新分支對bar_txt_future
進行了輪詢。
一旦我們進入到else
分支,後面就不再會進行.await
操作。我們到達了函式結尾並返回包裝在Poll::Ready
中的content
。我們還把當前的狀態改為了End
狀態。
WaitingOnBarTxt
狀態的程式碼看起來像下面這樣:
ExampleStateMachine::WaitingOnBarTxt(state) => {
match state.bar_txt_future.poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(bar_txt) => {
*self = ExampleStateMachine::End(EndState));
// from body of `example`
return Poll::Ready(state.content + &bar_txt);
}
}
}
與WaitingOnFooTxt
狀態類似,我們從輪詢bar_txt_future
開始。如果它仍然是 pending,我們退出迴圈然後返回Poll::Pending
。否則,我們可以執行example
函式最後的操作:將來自 future 的結果與content
相連線。我們把狀態機更新到End
狀態,然後將結果包裝在Poll::Ready
中進行返回。
最後,End
狀態的程式碼看起來像下面這樣:
ExampleStateMachine::End(_) => {
panic!("poll called after Poll::Ready was returned");
}
在返回Poll::Ready
之後,future 不應該被再次輪詢。因此,當我們已經處於End
狀態時,如果poll
被呼叫我們將會 panic。
我們現在知道編譯器生成的狀態機以及它對Future
trait 的實現是什麼樣子的了。實際上,編譯器是以一種不同的方式來生成程式碼。(如果你感興趣的話,當前的實現是基於生成器(generator)[4]的,但是這只是一個實現細節)。
最後一部分是生成的示例函式本身的程式碼。記住,函式簽名是這樣定義的:
async fn example(min_len: usize) -> String
因為完整的函式體實現是通過狀態機來實現的,這個函式唯一需要做的事情是初始化狀態機並將其返回。生成的程式碼看起來像下面這樣:
fn example(min_len: usize) -> ExampleStateMachine {
ExampleStateMachine::Start(StartState {
min_len,
})
}
這個函式不再有async
修飾符,因為它現在顯式地返回一個ExampleStateMachine
型別,這個型別實現了Future
trait。正如所期望的,狀態機在Start
狀態被構造,並使用min_len
引數初始化與之對應的狀態結構體。
記住,這個函式沒有開始狀態機的執行。這是 Rust 中 future 的一個基本設計決定:在第一次輪詢之前,它們什麼都不做。
參考資料
嘗試在 playground 上執行這段程式碼: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=d93c28509a1c67661f31ff820281d434
[2]狀態機(state machine): https://en.wikipedia.org/wiki/Finite-state_machine
[3]pinning: https://doc.rust-lang.org/stable/core/pin/index.html
[4]生成器(generator): https://doc.rust-lang.org/nightly/unstable-book/language-features/generators.html