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

  • 2021 年 1 月 17 日
  • 筆記

原文標題:Async/Await

原文鏈接://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: //doc.rust-lang.org/nightly/core/future/trait.Future.html


[2]

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


[3]

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


[4]

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


[5]

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


[6]

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


[7]

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