原文標題: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
函式得以更加容易地呼叫foo
,foo
與檔案載入並行執行。在這個例子中,檔案載入在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_std
)future
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 的支援,就是為了讓非同步程式碼的編寫從根本上變得更加簡單。
參考資料
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