我先前解釋過異步膨脹(async bloat)及其一些解決方法,但更希望能在根源,也就是編譯器中解決這個問題。我已提交一個專案目標,並尋求資金支持這項工作。

我非常喜歡異步 Rust!我們能夠編寫獨立於執行緒(executor agnostic)的程式碼,使其能在大型伺服器和小型微控制器上並行運行,這真是太棒了。

但尤其是在那些小型微控制器上,我們會發現異步 Rust 離我們被承諾的零成本抽象(zero cost abstractions)相去甚遠。這是因為二進位檔的每一個位元組都很重要,而異步會引入大量的膨脹。這種膨脹在桌面和伺服器上同樣存在,但當你有更多記憶體和運算資源時,它就不那麼明顯了。

我先前已解釋過這個問題的一些解決方法,但我更希望能夠直擊問題根源,並著手改進編譯器中的異步膨脹。因此,我提交了一個專案目標。

這是我的部落格系列關於這個主題的第二部分。第一部分探討了該主題的初步探索以及你在編寫異步程式碼時可以採取哪些措施來避免一些膨脹。在第二部分中,我們將深入探討內部機制,並將第一部分的部落格方法轉化為對編譯器的優化。

我不會討論經常被討論的關於 futures 變得比必要時更大以及它們進行大量複製的問題。人們已經意識到這一點了。事實上,有一個開放的 PR 正在處理其中的一部分:https://github.com/rust-lang/rust/pull/135527

我們將要查看這段程式碼:

我們使用 futures 的 desugared 語法,因為這樣更容易看出發生了什麼。

那麼 bar future 實際上是什麼樣子的呢?

有兩個 await 點,所以狀態機至少必須有兩個狀態,對吧?

幸運的是,我們可以要求編譯器在各種傳遞(passes)中傾印 MIR(Mid-level Intermediate Representation)。一個有趣的傳遞是 coroutine_resume。這是最後一個特定於異步的 MIR 傳遞。為什麼這很重要?嗯,異步是一個仍然存在於 MIR 中,但在 LLVM IR 中不存在的語言特性。所以異步轉換為狀態機的過程作為一個 MIR 傳遞發生。

bar 函數生成了 360 行 MIR。是不是很瘋狂?雖然這在之後會得到一些優化,但非異步版本僅使用了 23 行來完成同樣的事情。

編譯器還會輸出 CoroutineLayout。它基本上是一個列舉(enum),包含這些狀態(註解是我自己加的):

好吧,Future::poll 是一個安全函數。呼叫它不應該會導致任何未定義行為(UB),即使 future 已經完成。所以,在 Suspend1 之後,future 會返回 Ready,並且 future 會被更改為 Returned 狀態。一旦在該狀態下再次輪詢,poll 函數就會 panic。

Panicked 狀態的存在是為了在 async fn 發生 panic,但 catch_unwind 機制被用來捕獲它之後,future 就不能再被輪詢了。輪詢處於 Panicked 狀態的 future 會 panic。如果沒有這個機制,我們可以在 panic 後再次輪詢 future。但 future 可能處於未完成狀態,這可能會導致 UB。這個機制非常類似於 mutex poisoning。

(我 90% 確定關於 Panicked 狀態我說的沒錯,但我找不到任何實際描述它的文件。)

但這是合理的嗎?處於 Returned 狀態的 futures 會 panic。但它們不必如此。唯一不能做的是導致 UB。

Panic 相對昂貴。它們引入了一個帶有副作用的路徑,這個副作用不容易被優化掉。如果我們只是再次返回 Pending 呢?沒有不安全的操作,所以我們滿足了 Future 類型的合約。

我已經在編譯器中對此進行了實驗,並在異步嵌入式韌體中看到了 2%-5% 的二進位檔大小縮減。

所以我建議這應該是一個開關,就像整數溢位時的 overflow-checks = false 一樣。在調試構建中,它仍然會 panic,以便錯誤行為立即可見,但在發布構建中,我們可以獲得更小的 futures。

類似地,當使用 panic=abort 時,我們或許可以完全擺脫 Panicked 狀態。我想研究一下這樣做的後果。

我們已經看了 bar,但還沒看 foo。

讓我們手動實現它,看看最佳解決方案會是什麼。

很容易,對吧?我們不需要任何狀態。我們只需返回數字。

讓我們看看編譯器給我們的版本的生成 MIR:

請注意第 4 行,我們仍然有 3 個預設狀態,以及第 22 行我們仍在對其進行切換。這裡有一個我們沒有利用的巨大優化機會,即沒有狀態,並且在每次輪詢時始終返回 Poll::Ready(5)。

我也在編譯器中對此進行了實驗,它節省了 0.2% 的二進位檔大小。雖然不多,但這是一個相當簡單的優化,所以可能仍然值得。

這確實稍微改變了行為,但僅對不合規的執行緒而言。這意味著 future 始終返回 Ready。編譯器目前的行為是任何後續的輪詢都會 panic。

好吧,所以 MIR 輸出並不好。但 LLVM 會收拾殘局,對吧?

嗯,有時候會,是的。但僅當 futures 足夠簡單,並且你運行 opt-level=3 時。如果 future 變得太複雜(這很快發生,因為 futures 在慣用的異步 Rust 程式碼中嵌套得很深),或者你正在優化大小(我們經常在嵌入式或 wasm 中這樣做),LLVM 就無法將其全部優化掉。

這是一個 godbolt 上的例子:https://godbolt.org/z/58ahb3nne

如果你查看生成的組件(assembly),你會注意到它確實知道 foo 返回 5,但它沒有將 bar 的答案優化為 10。foo 的 poll 函數也被呼叫了。這是因為潛在的 panic,編譯器無法完全考慮到。它沒有意識到 foo 只被呼叫一次,並且實際上永遠不會 panic。

如果我們註釋掉 IR 中的 panic 分支,我們會看到它得到了更好的優化:https://godbolt.org/z/38KqjsY8E

可悲的是,LLVM 並不是我們在這裡的救星。我們真的需要給它好的輸入。

它在 opt-level=3 下表現更好,但當程式碼變得不那麼瑣碎時,最終也無法跟上。這是因為我們依賴 LLVM 來識別它應該優化我們要求它做的事情。

Inlining 非常棒,因為它啟用進一步的優化傳遞。可惜,生成的 Rust futures 永遠不會被 inlined。在每個 future 獲得其實現後,LLVM 和連結器才有機會進行 inlining。但正如我們上面所見,那太晚了。

Inlining 的主要機會是這個:

這是使用 trait 創建抽象時經常出現的模式。使用目前的編譯器,bar 會得到自己的狀態機,該狀態機呼叫 foo 狀態機,這非常浪費。相反,bar 也可以通過簡單地返回 foo future 來變成 foo。

當我們在範例中添加一個前導和後導時,情況會變得稍微複雜一些。

這種模式在將異步函數從一個簽名轉換到另一個簽名時很常見,這在 trait impls 中會發生。

請注意,這裡的 bar 也不需要任何自己的異步狀態。在單個 await 點上沒有保留除 foo 捕獲之外的任何資料。bar 不能簡單地變成 foo,但我們可以主要依賴 foo 的狀態。手動實現會像這樣:

這比目前生成的要好得多。如果我們被允許執行程式碼直到第一個 await 點,那麼我們就可以擺脫 Unresumed 狀態。但是「futures 在被輪詢之前不做任何事」是保證的,所以我們不能改變這一點。

如果你能夠查詢你正在輪詢的 futures 的屬性,你可以進行更多關於 inlining 的優化。我不認為這是可能的,至少在目前 rustc 的架構下是不行的。每個異步塊都會單獨轉換,之後不會保留任何資料。

例如,如果你可以查詢一個 future 在第一次輪詢時是否始終返回 ready,你就不需要在呼叫者的 future 中為 await 點創建一個狀態。如果那樣可行,並且你可以遞歸地應用這些優化,你就可以將許多 futures 折疊成更簡單的狀態機。

我還沒有測試過 inlining,但我認為這將顯著有助於減少二進位檔大小和提高效能。

狀態機為異步塊中的每個 await 點獲得一個額外的狀態。但有些程式碼可以將多個狀態折疊成一個。

這樣寫非常自然。但發生的情況是,我們得到了兩個相同的狀態:

這個函數的 MIR 長達 456 行,並且許多基本塊基本上是重複的。

我們可以手動重構程式碼為:

在這裡我們沒有得到重複的狀態:

總 MIR 長度現在是 302 行,並且沒有重複。

所以,搜尋相同的程式碼路徑和狀態並將它們折疊成一個,似乎是一個很好的優化傳遞。這個優化可能會與 inlining 傳遞很好地疊加。

Future inlining 應該會再次產生更大的影響。

最終,在實際系統中進行基準測試之前,很難確切知道改進程度。

希望這篇文章能闡明一些異步 Rust 的問題!

我很想在編譯器中處理這些項目:

我想在編譯器中處理這個問題,因此我提交了它作為一個專案目標:https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html

但我需要你的幫助,因為沒有資金我做不了太多事。

如果你是一家公司或組織,從這項工作中受益並願意(部分)資助它,請透過 [email protected] 與我聯繫。範圍是靈活的,所需的資金金額也是如此。然而,我估計 3 萬歐元可以完成所有或至少大部分工作。