【譯】Async/Await(二)——Futures

Praying發表於2021-01-17

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

Rust 中的 Async/Await

Rust 語言以 async/await 的形式對協作式多工提供了最好的支援。在我們探討 async/await 是什麼以及它是怎樣工作的之前,我們需要理解 future 和非同步程式設計在 Rust 中是如何工作的。

Futures

future 表示一個可能還無法獲取到的值。例如由另一個任務計算的整數或者從網路上下載的檔案。future 不需要一直等待,直到值變為可用,而是可以繼續執行直到需要這個值的時候。

示例

下面這個例子可以很好的闡述 future 的概念:

在這個時序圖裡,main函式從檔案系統讀取一個檔案,然後呼叫函式foo。這個過程重複了兩次:一次是呼叫同步的read_file,另一次是呼叫非同步的async_read_file

在同步呼叫的情況下,main需要等待檔案從檔案系統載入。然後它才可以呼叫foo函式,foo又需要再次等待結果。

在呼叫非同步的async_read_file的情況下,檔案系統直接返回一個 future 並且在後臺非同步地載入檔案。這使得main函式得以更加容易地呼叫foofoo與檔案載入並行執行。在這個例子中,檔案載入在foo返回之前就完成載入,所以main可以直接對檔案操作而不必等待foo返回。

Rust 中的 Futures

在 Rust 中,future 通過Future[1] trait 來表示,它看起來像下面這樣:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}

關聯型別[2]Output指定了非同步的值的型別。例如,上圖中的async_read_file函式將會返回一個Future例項,其中Output型別被設定為File

poll[3]能夠檢查是否值已經可用。它返回一個Poll列舉,看起來像下面這樣:

pub enum Poll<T> {
    Ready(T),
    Pending,
}

當這個值可用時(例如,檔案已經從磁碟上被完整地讀取),該值會被包裝在Ready變數中然後被返回。否則,會返回一個Pending變數,告訴呼叫者這個值目前還不可用。

poll方法接收兩個引數:self: Pin<&mut Self>cx: &mut Context。前者類似於一個普通的&mut self引用,不同的地方在於Self值被pinned[4]到它的記憶體位置。如果不理解 async/await 是如何工作的,就很難理解Pin以及為什麼需要它。因此,我們稍後再來解釋這個問題。

cx: &mut Context引數的目的是把一個Waker例項傳遞給非同步任務,例如從檔案系統載入檔案。Waker允許非同步任務傳送通知表示任務(或任務的一部分)已經完成,例如檔案已經從磁碟上載入。因為主任務知道當Future就緒的時候自己會被提醒,所以它不需要一次又一次地呼叫poll。在本文後面當我們實現自己的 Waker 型別時,我們將會更加詳細地解釋這個過程。

使用 Future(Working with Futures)

現在我們知道 future 是如何被定義的並且理解了poll方法背後的基本思想。儘管如此,我們仍然不知道如何使用 future 來高效地工作。問題在於 future 表示非同步任務的結果,而這個結果可能是不可用的。儘管如此,在實際中,我們經常需要這些值直接用於後面的計算。所以,問題是:我們怎樣在我們需要時能夠高效地取回一個 future 的值?

等待 Future

一個答案是等待 future 就緒。看起來類似下面這樣:

let future = async_read_file("foo.txt");
let file_content = loop {
    match future.poll(…) {
        Poll::Ready(value) => break value,
        Poll::Pending => {}, // do nothing
    }
}

在這段程式碼裡,我們通過在迴圈裡一次又一次地呼叫poll來等待 future。這裡poll的引數無關緊要,所以我們將其忽略。雖然這個方案能夠工作,但是它非常低效,因為在該值可用之前 CPU 一直處於忙等待狀態。

一個更加高效的方式是阻塞當前的執行緒直到 future 變為可用。當然這是在你有執行緒的情況下才有可能,所以這個解決方案對於我們的核心來講不起作用,至少目前還不行。即使是在支援阻塞的系統上,這通常也是不希望發生的,因為它又一次地把一個非同步任務轉為了一個同步任務,從而抑制了並行任務潛在的效能優勢。

Future 組合子(Future Combinators)

等待的一個替換選項是使用 future 組合子。Future 組合子是類似map的方法,它們能夠將 future 進行連結和組合,和Iterator上的方法比較相似。這些組合子不是在 future 上等待,而是自己返回一個 future,這個 future 在poll上進行了對映操作。

舉個例子,一個簡單的string_len組合子,用於把Future<Output = String>轉換為Future<Output = usize>,可能看起來像下面這樣:

struct StringLen<F> {
    inner_future: F,
}

impl<F> Future for StringLen<F> where F: Future<Output = String> {
    type Output = usize;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<T> {
        match self.inner_future.poll(cx) {
            Poll::Ready(s) => Poll::Ready(s.len()),
            Poll::Pending => Poll::Pending,
        }
    }
}

fn string_len(string: impl Future<Output = String>)
    -> impl Future<Output = usize>
{
    StringLen {
        inner_future: string,
    }
}

// Usage
fn file_len() -> impl Future<Output = usize> {
    let file_content_future = async_read_file("foo.txt");
    string_len(file_content_future)
}

這段程式碼不怎麼有效,因為它沒有處理pinning[5],但是這裡它作為一個例子已經足夠了。基本的思想是,string_len函式把一個給定的Future例項包裝進一個新的StringLen結構體,該結構體也實現了Future。當被包裝的 Future 被輪詢(poll)時,它輪詢內部的 future。如果這個值尚未就緒,被包裝的 future 也會返回Poll::Pending。如果這個值就緒,字串會從Poll::Ready變數中匯出並且它的長度會被計算出來。之後,它會再次被包裝進Poll::Ready然後返回。

通過string_len函式,我們可以在不必等待的情況下非同步地計算一個字串的長度。因為這個函式會再次返回一個Future,所以呼叫者無法直接在返回值上操作,而是需要再次使用組合子函式。通過這種方式,整個呼叫圖就變成了非同步的,並且我們可以在某個時間點高效地同時等待多個 future,例如在 main 函式中。

手動編寫組合子函式是困難的,因此它們通常由庫來提供。然而 Rust 標準庫本身沒有提供組合子方法,但是半官方的(相容no_stdfuture crate 提供了。它的FutureExt trait 提供了高階別的組合子方法,像map或者then,這些組合子方法可以被用於操作帶有任意閉包的結果。

優勢

Future 組合子的最大優勢在於,它們保持了操作的非同步性。通過結合非同步 I/O 介面,這種方式可以得到很高的效能。事實上,future 組合子實現為帶有 trait 實現的普通結構體,這使得編譯器能夠對它們進行極度優化。如果想了解更多的細節,可以閱讀Zero-cost futures in Rust[6]這篇文章,該文宣佈了 future 加入了 Rust 生態系統。

缺點

儘管 future 組合子能夠讓我們寫出非常高效的程式碼,但是在某些情況下由於型別系統和基於閉包的介面,使用它們也很困難。例如,考慮下面的程式碼:

fn example(min_len: usize) -> impl Future<Output = String> {
    async_read_file("foo.txt").then(move |content| {
        if content.len() < min_len {
            Either::Left(async_read_file("bar.txt").map(|s| content + &s))
        } else {
            Either::Right(future::ready(content))
        }
    })
}

在 playground 上嘗試執行這段程式碼[7]

在這裡,我們讀取檔案foo.txt,接著使用then組合子基於檔案內容連結第二個 future。如果內容長度小於給定的min_len,我們讀取另一個檔案bar.txt然後使用map組合子將其追加到content中。否則,我們就僅返回foo.txt的內容。

我們需要對傳入then裡的閉包使用move關鍵字,因為如果不這樣做,將會出現一個關於min_len的生命中週期錯誤。使用Either包裝器(wrapper)的原因是 if 和 else 語句塊必須擁有相同的型別。因為我們在塊中返回不同的 future 型別,所以我們必須使用包裝器型別來把它們統一到相同型別。ready函式把一個值包裝進一個立即就緒的 future。需要這個函式是因為Either包裝器期望被包裝的值實現了Future

正如你所想,對於較大的專案,這樣寫很快就能產生非常複雜的程式碼。如果涉及到借用和不同的生命週期,它會變得更為複雜。為此,我們投入了大量的工作來為 Rust 新增對 async/await 的支援,就是為了讓非同步程式碼的編寫從根本上變得更加簡單。

參考資料

[1]

Future: https://doc.rust-lang.org/nightly/core/future/trait.Future.html

[2]

關聯型別: https://doc.rust-lang.org/book/ch19-03-advanced-traits.html#specifying-placeholder-types-in-trait-definitions-with-associated-types

[3]

poll: https://doc.rust-lang.org/nightly/core/future/trait.Future.html#tymethod.poll

[4]

pinned: https://doc.rust-lang.org/nightly/core/pin/index.html

[5]

pinning: https://doc.rust-lang.org/stable/core/pin/index.html

[6]

Zero-cost futures in Rust: https://aturon.github.io/blog/2016/08/11/futures/

[7]

在 playground 上嘗試執行這段程式碼: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=91fc09024eecb2448a85a7ef6a97b8d8

相關文章