【譯】Async/Await(三)——Aysnc/Await模式

Praying發表於2021-01-20

原文標題:Async/Await
原文連結:https://os.phil-opp.com/async-await/#multitasking
公眾號: Rust 碎碎念
翻譯 by: Praying

Async/Await 模式(The Async/Await Pattern)

async/await 背後的思想是讓程式設計師能夠像寫普通的同步程式碼那樣來編寫程式碼,由編譯器負責將其轉為非同步程式碼。它基於asyncawait兩個關鍵字來發揮作用。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_futurepoll函式。如果它尚未就緒,我們就退出迴圈然後返回Poll::Pending。因為這種情況下self仍處於WaitingOnFooTxt狀態,下一次的poll呼叫將會進入到相同的 match 分支然後重試對foo_txt_future輪詢。

foo_txt_future就緒後,我們把結果賦予content變數並且繼續執行example函式的程式碼:如果content.len()小於儲存在狀態結構體裡的min_lenbar.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 的一個基本設計決定:在第一次輪詢之前,它們什麼都不做。

參考資料

[1]

嘗試在 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

相關文章