原文:Why async Rust?
譯者:兔子不咬人
Rust 中的 async/await 語法發佈之初備受關注和鼓舞!引用當時 Hacker News 的説法:
它將掀起新的序幕。我相信有很多人正等待該特性被 Rust 採用的一刻。我本人也絕對是其中一個。
此外,它保持了所有優點:開源、高質量的工程、開放的設計,大批貢獻者為一個複雜的軟件做出貢獻。真是鼓舞人心!
最近,對它的接受程度卻有些褒貶不一。這裏引用近期關於該話題的一篇博文的評論,還是 Hacker News 上的:
我真的無法理解怎麼有人能看到 Rust 的異步部分這堆爛攤子後還認為它是一個好的設計,何況是針對一個已經被認為非常複雜的語言來説。
我試過掌握它,真的試過了,俺滴個玉皇大帝啊,這也太混亂了。況且它還會肆意傳染。我真的很喜歡 Rust,這陣子我大部分時間都在用它編碼,但每每遇到異步密集的 Rust 代碼時,我便下頜緊繃,視線逐漸模糊。
當然,這兩條評論並不能完全代表所有人:其實在四年前便已經有人提出了擔憂。在討論“下頜緊繃,視線逐漸模糊”的這條評論的同一跟帖中,也有很多人以相同的熱情捍衞着異步 Rust。但我覺得可以這麼説,抱怨者的數量正越來越多,而且他們的口氣也變得更為強硬。在某種程度上講,這只是熱度週期的自然發展過程,但同時我也認為隨着與設計初衷越來越遠,一些背景信息早已丟失。
2017 年至 2019 年期間,我在前人工作的基礎上,與他人合作推動了 async/await 語法的設計。當有人説,他們不知道怎麼會有人看到這個“爛攤子”還“認為它是個好設計”時,請原諒我有點不以為然。請容許我在這個組織地不夠完美且過於冗長的篇幅中解釋異步 Rust 是如何產生的,其目的及動因為何。在我看來,對於 Rust 沒有其他可行選擇。我希望在解釋的過程中,至少可以在某種程度上更廣泛、更深入地闡述 Rust 的設計,而不僅僅是重複過往的辯解。
術語背景簡介
在這場辯論中爭議的基本問題是,Rust 決定使用“無棧協程”方式來實現用户空間併發。本次討論中使用了諸多術語,若不瞭解所有術語也在情理之中。
首先需要搞清楚的概念是該功能特性的主要目的:“用户空間併發”。主流操作系統提供了一組相當類似的接口來實現併發:你可以創建線程,並在該線程上使用系統調用來執行 IO 操作,這些 IO 操作在完成前會一直阻塞該線程。此類接口的問題在於它們需要一定的開銷,當你在追求特定性能目標時,這些開銷很可能會成為限制因素。體現在兩個方面:
- 內核和用户空間之間的上下文切換在 CPU 週期中十分昂貴。
- 操作系統線程具有大量預分配的堆棧,增加了各線程的內存開銷。
以上限制在一定程度上是可以接受的,但對於大規模併發的程序來説就不好使了。解決方案是使用非阻塞 IO 接口,並在單個操作系統線程上調度多個併發操作。程序員可以“手動”完成這項操作,但現代編程語言通常提供了更便利的功能來完成相同的工作。從抽象層面看,編程語言用某種方式將整個工作分解成任務,並將任務調度到線程上。體現到 Rust 上則是通過 async/await 實現的。
在該設計空間中,第一個抉擇是採用協作式調度還是搶佔式調度。任務是否“協作地”將控制權交還給調度子系統,或者運行期間可以在某個點“搶佔地”停止它,且使任務並不知曉?
在相關討論中經常提到的一個術語是協程,而且它被用在了一些互相矛盾的地方。協程是一個可以暫停繼而恢復的函數。關於它最大的分歧是,有些人會使用術語“協程”來表示那些具有顯式的暫停及恢復語法的函數(對應協作式調度任務),有些人則使用它來表示任意可以暫停的函數,即便暫停是由語言運行時隱式執行的(還包括搶佔式調度任務)。我更喜歡第一種定義,畢竟它帶來了一些有價值的區分。
另一方面,Goroutines 是 Go 語言的一項功能,它可以實現併發的、搶佔式調度的任務。其具有與線程相同的 API,不過它被實現為語言的一部分,而不再作為操作系統原語。這在其他語言中通常被稱為虛擬線程或綠色線程。故按我的定義,Goroutines 不算是協程,但也有人使用更廣泛的定義説 Goroutines 是一種協程。我傾向於將此稱為綠色線程,因為這是 Rust 中使用的術語。
第二個抉擇是採用堆棧式還是無堆棧式協程。堆棧式協程與操作系統線程類似,它具有程序堆棧:當函數作為協程的一部分被調用時,它們的棧幀被推到堆棧上;當協程暫停時,堆棧狀態被保存,以便可以從相同的位置恢復。另一方面,無堆棧式協程則以不同的方式存儲需要恢復的狀態,比如在續體或狀態機中。暫停時,其所使用的堆棧被接管操作使用,恢復時,其重新控制堆棧,並由續體或狀態機恢復至協程的中斷位置。
一個常被提及的話題是 async/await(在 Rust 和其他語言中)的“函數着色問題[1]” —— 一種對“為了獲取異步函數的結果,需要使用不同的操作(例如等待它)而不是正常調用它”的怨言。綠色線程和堆棧式協程機制均可以避免此問題,畢竟它們使用了特殊的語法(具體取決於語言)來指示正在發生某種特殊事件以期管理協程的無堆棧狀態。
Rust 的 async/await 語法是無堆棧式協程機制中的一例:異步函數被編譯為返回 Future 的函數,該 Future 用於在協程暫停時存儲其狀態。回到辯論的基本問題: Rust 是否正確採用了此方法,或者是否應該索性採用更類似 Go 的“堆棧式”或“綠色線程”方法,進而最好不使用“着色”函數的顯式語法。
異步 Rust 的開發過程
綠色線程
第三個 Hacker News 上的評論很好地代表了在這場辯論中我經常聽到的一種聲音:
人們想要的替代併發模型是通過工作竊取執行器上的堆棧式協程及通道來實現的結構化併發。
除非有人做了演示,並將其與基於 futures 的 async/await 進行比較,否則我認為無法展開任何建設性討論。
暫時不考慮結構化併發、通道和工作竊取執行器(完全無關的問題),像這類令人困惑的評論的問題所在是,最初 Rust 確實有一種以綠色線程的形式擁有堆棧式協程的機制。它在 2014 年底被移除,就在 1.0 版本發佈之前。瞭解移除的原因將有助於理解為什麼 Rust 推出了 async/await 語法。
對於任何綠色線程系統——無論是 Rust 的、Go 的還是任何其他語言的——一個重大問題就是如何處理這些線程的程序堆棧。切記,用户空間併發機制的目標之一就是減少操作系統線程所使用的大型、預分配堆棧的內存開銷。因此,綠色線程庫往往傾向於採用某種機制來生成佔用較小堆棧的線程,並僅在需要時擴展它們。
實現此目標的一種方法是所謂的“分段堆棧”,它將堆棧做成一系列小堆棧段的鏈表;當堆棧增長並超出段的上限時,新的段將被添加到鏈表中,當堆棧縮小時,該段被移除。該技術的問題在於將棧幀推送到堆棧中的成本是極度不確定的。如果棧幀恰在當前段中,基本上是零開銷。否則,則需要分配一個新的段。針對這一問題有種特別惡劣的情況:在熱循環中的函數調用需要分配一個新段。這將在該循環的每次迭代中形成一次內存分配和釋放,嚴重影響性能。而這一切對於用户來説完全不透明,因為用户並不知曉在調用函數時堆棧的深度。Rust 和 Go 開始時都採用了分段堆棧,然後出於這些原因放棄了此做法。
另一種方法被稱為“堆棧複製”。在這種情況下,堆棧更像是個 Vec 而非鏈表:當堆棧即將達到上限時,它會被重新分配為更大的空間,以避免達到限制。這種做法允許堆棧從較小容量開始並根據需要增長,而且不存在分段堆棧的缺點。但問題在於,重新分配堆棧意味着要複製它們,也就意味着堆棧將會分配到內存中新的位置。任何指向堆棧的指針都會失效,因而需要某種機制來更新它們。
Go 使用了堆棧複製,受益於 Go 中指向堆棧的那些指針只能存在於同一堆棧中,故而只需掃描這一堆棧以重寫指針即可。儘管這需要依賴運行時類型信息,而 Rust 並不保留這類信息,但 Rust 仍然允許指向堆棧的指針不存儲在同一特定堆棧內——可以在堆中的某處,也可以在另一個線程的棧中。追蹤這些指針最終變得像是垃圾回收問題,只不過它是在移動內存而不是釋放內存。Rust 不可能採用該方式,因為 Rust 沒有垃圾回收器,繼而最終無法採用堆棧複製。相反,Rust 解決分段堆棧問題的方法是通過使其綠色線程足夠大,就像操作系統線程一樣,可這也就消除了綠色線程的一個關鍵優勢。
即便採用了像 Go 那樣堆棧大小可調整的解決方案,當嘗試與其他語言編寫的庫集成時,綠色線程仍會帶來一些無法避免的成本。C 語言的 ABI 及其操作系統堆棧是各語言的共享最低標準。將代碼從綠色線程切換到操作系統線程堆棧上運行可能會造成高的可怕的 FFI 成本,而 Go 選擇了接受這一 FFI 成本。最近,C# 因為該原因中止了關於綠色線程的實驗。
對於 Rust 來説這是個特別棘手的問題,因為 Rust 的設計旨在支持像將 Rust 庫嵌入到另一種語言編寫的二進制文件中,並且在沒有時鐘週期或沒有內存來運行虛擬線程運行時的嵌入式系統上運行。為了嘗試解決該問題,綠色線程運行時變得可選,而 Rust 可以被編譯為使用阻塞 IO 在原生線程上運行。這被設計為由最終二進制文件在編譯期決定。因此,在一段時間內 Rust 出現了兩種變體,一種使用阻塞 IO 及原生線程,另一種則使用非阻塞 IO 及綠色線程,且所有代碼均旨在與兩種變體兼容。然而事情並不順利,綠色線程由於 RFC 230 被從 Rust 中移除,其中列舉了移除原因:
- 綠色線程和原生線程之間的抽象並非“零成本”,執行 IO 操作時會導致無可避免的虛函數調用及內存分配,這是不可接受的,尤其是對於原生代碼而言。
- 它迫使原生線程和綠色線程支持相同的 API,即便在某些不合理的情況下。
- 它並非完全可互操作的,因為即使在綠色線程上,仍然可以通過 FFI 調用原生 IO。
一旦綠色線程被移除,高性能的用户空間併發問題便仍待解決。這就是 Future trait 以及隨後開發出的 async/await 語法。但為了理解這一過程,我們需要再退一步,看看 Rust 對另一個問題的解決方案。
迭代器
我覺得異步 Rust 之旅真正的起點可以追溯到 2013 年,前貢獻者 Daniel Micay 在一份舊郵件列表上發表了一篇文章。該文章與 async/await、future 或非阻塞 IO 無關:它是一篇關於迭代器的文章。Micay 提議將 Rust 轉向使用所謂的“外部”迭代器,正是這一轉變——以及它與 Rust 的所有權、借用模型相結合後的效果——使 Rust 不可避免地走上了 async/await 的道路。顯然,當時還沒人意識到這點。
一直以來 Rust 禁止通過綁定變量別名來修改狀態——這一“可變或別名[2]”的原則對早期乃至今日的 Rust 一樣至關重要。但最初該原則不是通過生命週期分析來保障的,而是通過不同的機制。當時,引用還只是“參數修飾符”,概念上類似於 Swift 中的“inout”修飾符[3]。2012年,Niko Matsakis 提出並實現了第一個版本的 Rust 生命週期分析,將引用提升為真正的類型,並允許將其嵌入到結構體中。
儘管轉為生命週期分析對今日 Rust 的巨大影響已得到認可,但它與外部迭代器的共生交互,以及該 API 對 Rust 穩固當前地位的重要性,卻沒有得到足夠的重視。在採用“外部”迭代器之前,Rust 使用基於回調的方式來定義迭代器,用現在的 Rust 來演示的話看起來大概類似:
enum ControlFlow {
Break,
Continue,
}
trait Iterator {
type Item;
fn iterate(self, f: impl FnMut(Self::Item) -> ControlFlow) -> ControlFlow;
}
這種方式定義的迭代器會在集合的每個元素上調用其回調函數,除非返回 ControlFlow::Break 來停止迭代。for 循環的主體部分會被編譯為閉包並傳遞給正在循環中的迭代器。這種迭代器比外部迭代器容易編寫得多[4],但它存在兩個關鍵問題:
- 當要求中斷循環時,語言無法保證迭代實際上會停止運行,因此也就無法基於它來保障內存安全。這意味着不可能出現像從循環中返回引用這樣的操作,畢竟實際上循環可能還在繼續運行。
- 無法實現用於交叉多個迭代器的適配器,例如
zip,因為該 API 不支持交替地依次迭代多個迭代器。
相反,Daniel Micay 建議將 Rust 改為使用“外部迭代器”,這樣就能徹底解決以上問題,並採用 Rust 用户現已習慣的接口:
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
外部迭代器能夠與 Rust 的所有權和借用系統完美結合,它在底層會被編譯為一個結構體(內部保存着迭代狀態),因此可以像其他結構體一樣包含對正在迭代的數據結構的引用。由於採用了單態化技術,由多個組合器組合而成的複雜迭代器也能編譯成單個結構體,進而對優化器來説一切也是透明的。唯一的問題是,由於需要定義用於迭代的狀態機,它變得更難於手工編寫。當時,Daniel Micay 預言了未來的發展:
未來,Rust 可以像 C# 一樣使用 yield 生成器,它會被編譯成一個高效的狀態機,無需上下文切換、虛函數甚至閉包。這將消除使用外部迭代器手工編寫遞歸遍歷的困難。
然而,生成器方面的進展並不迅速,儘管最近發佈的一份令人興奮的 RFC 表明我們可能很快就會看到這一功能。
即使沒有生成器,外部迭代器也取得了巨大的成功,該技術的價值也已得到了認可。例如,Aria Beingessner 在 “Entry API” 中使用了類似的方法來訪問 map。值得注意的是,在該 API 的 RFC 中,她將其稱為“類迭代器[5]”。她的意思是,API 通過一系列組合器構建了一個狀態機,在編譯器看來這是高度可讀的,因此也是可優化的。該技術經久不衰。
Futures
當開始着手替換綠色線程時,Aaron Turon 和 Alex Crichton 參考了許多其他語言使用的 API,這類 API 後來被稱為 futures 或 promises,它們基於所謂的“續體傳遞風格[6]”構建。以這種方式定義的 future 將回調作為一個額外的參數,即續體,並在 future 完成時調用該續體作為最終操作。這是大多數語言中定義此種抽象的方式,而大多數語言的 async/await 語法也會編譯為續體傳遞風格。
在 Rust 中,上述 API 可能看起來會像這樣:
trait Future {
type Output;
fn schedule(self, continuation: impl FnOnce(Self::Output));
}
Aaron Turon 和 Alex Crichton 嘗試了該方法,但正如 Aaron Turon 在一篇富有啓發性的博文[7]中所寫,他們很快就遇到了一個問題,即頻繁使用續體傳遞風格通常需要為回調分配內存。Turon 以 join 為例:join 接收兩個 future 並同時運行它們。這兩個子 future 均需持有 join 的續體,因為無論最終是哪個 future 完成,都需要執行它。這就導致了需要引用計數並分配內存來實現它,Rust 對此表示不接受。
之後,他們研究了 C 語言程序員如何實現異步編程:在 C 語言中,程序員通過創建狀態機來處理非阻塞 IO。於是他們想通過一種對 Future 的定義來解決該問題,依賴該定義要能夠編譯成形如 C 語言程序員手工編寫的那種狀態機。經過一番嘗試後,他們採用了所謂的“基於就緒狀態”的方法:
enum Poll<T> {
Ready(T),
Pending,
}
trait Future {
type Output;
fn poll(&mut self) -> Poll<Self::Output>;
}
不同於存儲續體,future 將由某個外部執行器來輪詢。當 future 處於 pending 狀態時,它會將喚醒執行器的方法存儲起來,等輪詢再次準備就緒時就會執行該方法。通過這種反轉控制方式,他們將不再需要存儲 future 完成時的回調,也就使得他們能夠用單個狀態機來表示 future。他們在上述接口的基礎上構建了一個組合器模式的庫,所有這些組合器都可以編譯成單個狀態機。
從基於回調的方式轉向外部驅動,將一組組合器編譯成單個狀態機,甚至是這兩個 API 的具體規範:如果你閲讀了上一節,所有這些聽起來應該都很熟悉。從續體到輪詢的轉變,與 2013 年迭代器的轉變如出一轍! 再次重申,正是由於 Rust 能夠處理具有生命週期的結構體,因此也能處理從外部借用狀態的無棧式協程,這使它能夠在不違反內存安全的前提下,以最佳方式將 future 表示為狀態機。這種從較小的組件中構建單對象狀態機的模式,是 Rust 工作方式的關鍵部分,無論是應用於迭代器還是 future。它幾乎是自然而然地從語言中脱胎。
在此,我想強調一下迭代器和 future 之間的一個區別:除非編程語言對建立其上的協程有某種原生支持,否則像 Zip 這樣交錯使用兩個迭代器的組合器根本無法採用類似回調的方法。另一方面,如果你想交錯使用兩個 future,比如 Join,基於續體的方法就可以支持:只不過會產生一些運行時成本。這就解釋了為什麼外部迭代器在其他語言中很常見,但 Rust 卻獨一無二地將這種轉換應用於 future。
在最初的迭代中,future 庫的設計原則是,讓用户以與構造迭代器幾乎相同的方式構造 future:底層庫的作者使用 Future trait,而編寫應用程序的用户將使用 futures 庫提供的一組組合器,通過較簡單的組件構建更復雜的 future。不幸的是,當用户試圖遵循這種方式時,他們立即遇到了令人沮喪的編譯錯誤。問題在於,future 在啓動時需要“逃離”周圍的上下文,因此不能從上下文中借用狀態:任務必須擁有其完整狀態。
這對 future 組合器來説成了問題,因為在多個組合器中訪問狀態通常很有必要,而這些組合器又構成了構造 future 的操作鏈的一部分。例如,用户通常會在對象上先調用一個“異步”方法,然後再調用另一個,寫法如下:
foo.bar().and_then(|result| foo.baz(result))
問題在於 foo 已在 bar 方法中被借用,隨後又在傳遞給 and_then 的閉包中被借用了。從本質上講,用户想要做的是在“跨 await 點”之間存儲狀態,而 await 點由 future 組合器鏈構成;這通常會導致撲朔迷離的借用檢查錯誤。最直接的解決方案是將狀態存儲在 Arc 和 Mutex 中,但它們並不是零成本的,更重要的是,隨着系統複雜度的增加,這種方法會變得非常笨拙和不便。例如:
let foo = Arc::new(Mutex::new(foo));
foo.clone().lock().bar()
.and_then(move |result| foo.lock().baz(result))
儘管 future 在最初的實驗中基準測試結果成績斐然,但該限制導致用户無法利用它們構建複雜的系統。就在這時,我加入了這場遊戲。
Async/await
2017 年末,因用户體驗不佳,future 生態系統顯然未能成功推出。future 項目的最終目標始終是實現所謂的“無棧式協程轉換”——使用 async 和 await 語法操作符的函數,可以轉換為返回結果為 future 的函數,從而避免用户手工編寫 future。Alex Crichton 開發了一套基於宏的 async/await 實現庫,但幾乎沒有引起任何關注。因此,必須有所改變。
Alex Crichton 的宏的最大問題之一是,如果用户嘗試在 await 點保持對 future 狀態的引用,就會產生錯誤。這實際上與用户在使用 future 組合器時遇到的借用問題相同,只不過變成了在新的語法中復現而已。future 在等待時不可能持有對自身狀態的引用,因為那樣需要編譯成自引用結構,而 Rust 並不支持自引用結構。
把這點與綠色線程的問題進行比較會很有趣。我們解釋過將 future 編譯為狀態機的一種方式,是説狀態機是個“大小恰好的堆棧”——與綠色線程的堆棧不同,綠色線程的堆棧必須能夠增長,以容納任意線程所可能具有的未知大小的堆棧狀態,而編譯後的 future(無論是手動實現、使用組合器還是使用 async 函數)恰好是它所需要的大小。因此,運行期堆棧增長的問題不再存在。
然而,這一堆棧被表示為結構體,而在 Rust 中移動結構體應當總是安全的。意味着,即使執行中的 future 不需要移動,但根據 Rust 的規則必須能夠支持移動。因此,在綠色線程中遇到的堆棧指針問題在新系統中還會再次出現。不過,這次的優勢在於,我們不需要真的移動 future,我們只需要表達 future 是不可移動的。
最初試圖實現這點的做法是定義一個名為 Move 的新 trait,以便將協程從可以移動的 API 中排除掉。該做法遇到了些向後兼容的問題,我之前已經記錄過這些問題[8]。關於 async/await 的論點我主要有三個:
- 需要在語言中提供 async/await 語法,以便用户使用類似於協程的函數來構建複雜的 future。
- async/await 語法需要支持將這些函數編譯為自引用的結構體,以便用户可以在協程中使用引用。
- 該 feature 功能需儘快發佈。
綜合以上三點,我開始尋找 Move trait 的替代方案,一種可以在不對語言進行任何重大破壞性更改即可實現的解決方案。
我最初的計劃比如今最終得到的方案要糟糕得多。我提議將 poll 方法標記為 unsafe,並增加一個不變條件:“一旦開始輪詢 future,就不能再移動它”。該方案很簡單,馬上就能實現,也極其粗暴:它會讓每個手寫的 future 都變得不安全,並強加了一個編譯器也無法提供幫助的難以驗證的規則。該方案最終可能會因為穩定性問題而擱淺,也肯定會引起極大的爭議。
接下來,Eddy Burtescu 提出的幾點意見引導我找到了更好的 API,新的 API 能夠以更精細的方式執行所需的不變條件。最終形成了 Pin 類型。儘管 Pin 類型本身已經引起了相當大的爭議,但我認為,與當時其他備選之策相比,它無疑是一大改進,因為它具有針對性、可執行,也能按時發佈。
事後看來,Pin 方式存在兩個問題:
- 向後兼容性:出於種種原因,一些已經存在的接口(尤其是
Iterator和Drop)本應支持不可移動類型,這限制了進一步開發語言時的選擇。 - 暴露給最終用户:我們的初衷是讓編寫“普通異步 Rust”的用户永遠不必與
Pin打交道。大多數情況下也確實如此,但也有幾處值得關注的例外,而所有這些情況幾乎都可以通過語法改進來解決。唯一一個非常糟糕(也使我個人感到尷尬)的問題是,在 await 一個 future trait 對象前你必須先 pin 住它。這是個不必要的錯誤,而現在修復它卻會導致破壞性的變更。
其他關於 async/await 的決策都是語法上的,我就不在這篇文章中贅述了,它已經夠長了。
組織結構方面的考慮
我之所以要探究這些歷史,是為了證明 Rust 的一系列事實不可避免地將我們帶入到一個特定的設計空間。首先,Rust 缺乏運行時,使得綠色線程成為不可行的解決方案,而且 Rust 需要支持嵌入(既支持嵌入到其他應用程序中,也支持在嵌入式系統上運行),無法為綠色線程的執行提供必要的內存管理。其次,Rust 天然具有將協程編譯為高度可優化的狀態機的能力,同時仍能保證內存安全,我們不僅利用這一點來處理future,也用於處理迭代器。
但這段歷史還有另一面:為什麼要為用户空間併發設計運行時系統?為什麼要引入 future 和 async/await 呢?這種爭論通常有兩種形式:一方面,有些人習慣於“手動”管理用户空間併發,直接使用像 epoll 這樣的接口,他們有時會嘲笑 async/await 語法是“網頁垃圾”。另一方面,有些人只是説 “並不需要它”,並建議使用線程和阻塞 IO 等更簡單的操作系統併發功能。
在沒有用户空間併發功能的語言(如 C)中,編寫高性能網絡服務的人往往會採用手寫狀態機來實現它。而這正是 Future 被設計用來編譯的目標,無需手工編寫狀態機:協程轉換的要義在於編寫命令式代碼,“就好像你的函數永遠不會產生”,但會讓編譯器生成狀態轉換,以便在阻塞時掛起它。這樣做可不是隻有微不足道的好處,最近的 curl CVE 漏洞就是因為在狀態轉換過程中無法識別需要保存的狀態而導致的。這類邏輯錯誤在手工實現狀態機時很容易發生。
Rust 推出 async/await 語法的目的是發佈這樣一種功能,它可以避免該類錯誤,同時仍能保持相同的性能指標。鑑於我們提供的可控級別以及沒有運行時內存管理,像這樣的系統(通常用 C 或 C++ 編寫)完全符合我們的目標受眾。
2018 年初,Rust 項目致力於在當年發佈一個新的“版本”,以修復 1.0 版本中出現的語法問題。同時決定以該版本為契機,宣佈 Rust 已經準備好投入實際應用;Mozilla 團隊大多是編譯器方面的極客和類型方面的理論家,但我們對市場營銷也有一定的瞭解,並認識到這一版本是讓更多人關注產品的機會。我向 Aaron Turon 提議,我們應該把重點放在四個基本應用場景中,這似乎是 Rust 的發展機會。它們是:
- 嵌入式系統
- WebAssembly
- CLIs
- 網絡服務
這一提議成為創建“領域工作組”的發端,這些工作組旨在成為專注於特定“領域”(相較與管控技術或組織的現有“團隊”)的跨職能小組。Rust 項目中的工作組概念自那時起發生了變化,而且大部分已失去了本意,但我還是想説點題外話。
有關 async/await 的工作是由所謂的“網絡服務”工作組率先開展的,該工作組最終被簡稱為 async 工作組(至今沿用此名稱)。不過,我們也敏鋭地意識到,由於缺乏運行時依賴,異步 Rust 也可以在其他領域,尤其是嵌入式系統中發揮重要作用。我們在設計 feature 功能時同時考慮了這兩種用例。
Rust 要取得成功就需要被業界採用,這是不言而喻的,這樣一旦 Mozilla 不再願意資助這個實驗性的新語言時,Rust 仍能繼續得到支持。很顯然,網絡服務是短期內被行業採用的最可能的方向,尤其是那些當時為了性能而不得不用 C/C++ 編寫的服務。這種場景完全符合 Rust 的定位——需要高度控制以滿足其性能要求的、由於暴露在網絡中而使得避免可利用的內存漏洞至關重要的系統。
定位於網絡服務的另一個優勢在於,軟件行業的這一分支具備靈活性和接受新技術的熱情,能夠迅速採納諸如 Rust 這樣的新技術。對於 Rust 來説,其他領域雖也存在着長線機會,但現在看來,那些領域要麼接納新技術的速度太慢(嵌入式),要麼依賴於尚未廣泛應用的新平台(WebAssembly),或者不是特別有利可圖的、無法帶來資金的工業應用(CLIs)。我抱着Rust的生存取決於異步/等待特性的堅定信念,積極投身於相關工作中。
async/await 在網絡服務方向上取得了巨大成功。Rust 基金會的許多著名贊助商,尤其是那些付錢給開發者的贊助商,都依賴 async/await 來編寫高性能的網絡服務,並將其作為證明他們投資合理性的主要用例之一。在嵌入式系統或內核編程中使用 async/await 也日漸成為熱點。async/await 如此成功,最常見的抱怨反而是它的生態系統過於以它為中心,不太像“常規的” Rust。
對於那些更願意使用線程和阻塞式 IO 的用户,我不知道該説些什麼。當然,我知道對很多系統來説那也是合理的做法。而且 Rust 語言本身也不會阻止他們這樣做。這些人的反對意見似乎在於,crates.io 上的生態系統,尤其是編寫網絡服務的生態系統,都是圍繞着使用 async/await 特性展開的。我偶爾也會看到一些庫使用 async/await 的方式近乎“貨物崇拜[9]”,但大多數情況下,我們可以放心地假設庫的作者實際上是想執行非阻塞 IO 並獲取用户空間併發的性能優勢。
誰都無法左右別人的決定,但事實情況是,無論出於商業原因還是興趣,大多數在 crates.io 上發佈網絡相關庫的人都願意使用異步 Rust。我希望能夠在非異步上下文中更容易地使用這些庫(例如,在標準庫中引入類似 pollster 的 API),但對於那些抱怨沒有在線上找到符合其用例場景的免費代碼的人們,我無話可説。
未完待續
儘管我堅持認為 Rust 沒有其他選擇,但我並不認為 async/await 是所有語言的最佳替代方案。特別地,我認為有可能存在某種語言,它在提供與 Rust 相同的可靠性保障的同時,對值的運行時表示方式控制較少,並且使用堆棧式協程而不是無堆棧式協程。我甚至認為,如果該語言能夠以支持迭代和併發的方式支持這樣的協程,那麼它甚至可以完全不需要生命週期,同時消除因別名可變性引起的錯誤。如果你讀過 Graydon Hoare 的筆記[10],就會看到這正是他最初的目標,但是在 Rust 改弦更張為可以與 C 和 C++ 競爭的系統語言之前。
我認為 Rust 的用户非常樂意使用這門語言,我也理解他們為什麼不樂意處理那些底層細節所固有的複雜性。以往這些用户總是在抱怨字符串類型繁多,而現在他們又轉嫁於抱怨異步問題。我希望有某種語言能搞定這個問題,又像 Rust 一樣為其提供保障,但問題並不出在 Rust 身上。
儘管我相信 async/await 是 Rust 的正確選擇,但我也認為對 async 生態系統現狀感到不滿是合理的。我們在 2019 年發佈了 MVP,tokio 在 2020 年發佈了 1.0 版本,但自那以後,事情就停滯不前了,我想這是所有相關人員都不願意看到的。在後續文章中,我想討論一下 async 生態系統的現狀,以及我認為項目可以做些什麼來改善用户體驗。這已經是我發表過的最長的博文了,所以我得就此打住。
譯註:
[1] 異步函數是紅色,同步函數是藍色。詳細內容可以參考 https://journal.stuffwithstuff.com/2015/02/01/what-color-is-y...
[2] 原文中的“或”使用的是“XOR”,即“異或”,意指“值可以是別名或可變的,但不能同時是兩者”。
[3] Swift 中的 inout 可以讓值類型以引用方式傳遞給函數,此時函數可以更改函數外變量的值。
[4] 這裏所謂的容易編寫,指的是實現回調迭代器的編譯器更容易編寫,不是指用户側代碼。
[5] 是“類似”的“類”,不是“類型”的“類”。 :-)
[6] CPS(continuation-passing style)一般翻譯為“延續傳遞風格”,也可譯成“續體傳遞風格”,是函數式編程中的常見術語,新手可以拿異步回調的方式做簡單理解,只是它的定義更為廣泛和抽象。我在這裏採用後一種譯法,是為了將 continuation 單獨出現時譯作“續體”,這樣更容易被讀者當作名詞接納,而“延續”很難在不同語境中區分其是名詞或動詞。
[7] 該文章地址為 https://aturon.github.io/blog/2016/09/07/futures-design/
[8] 該記錄的地址為 https://without.boats/blog/changing-the-rules-of-rust/
[9] 又譯“貨物運動”,是一種宗教形式,尤其出現於一些與世隔絕的落後土著之中。當貨物崇拜者看見外來的先進科技物品,便會將之當作神祇般崇拜。比較有趣的是 "cargo" 這個詞在 Rust 語境下的雙關含義。
[10] 該筆記地址為 https://graydon2.dreamwidth.org/307291.html