Rust非同步Asyn的特點

banq發表於2022-09-27

經常聽到有人把Rust和其他語言描述為 "穿風衣的N種語言"。在Rust中,我們有Rust的控制流結構,我們有decl-macro元語言,我們有trait系統(它是圖靈完備的),我們有cfg註釋語言--這個名單還在繼續。但是,如果我們把Rust看作是開箱即用的 "基礎Rust",那麼它就有一些明顯的修改因素。
  • unsafe Rust:使用原始指標和FFI
  • const Rust:在編譯時計算數值
  • async Rust:啟用非阻塞式計算。

所有這些Rust語言的 "修飾關鍵字 "都提供了 "基礎Rust "中所沒有的新功能。但它們有時也會奪走一些能力。我開始思考和談論語言特性的方式是用 "語言的子集 "或 "語言的超集"。有了這種分類,我們可以再看看修飾語的關鍵詞,並做出以下分類。
  • 不安全的Rust:超集
  • const Rust:子集
  • 非同步Rust:超集

unsafe Rust只增加了使用原始指標的能力。 async只增加了.await值的能力。但是const增加了在編譯過程中計算值的能力,但刪除了使用靜態和訪問諸如網路或檔案系統的能力。

如果語言特性只增加了基礎Rust,那麼它們被認為是超集。但如果他們增加的功能要求他們也限制其他功能,那麼他們就被認為是子集。在const的情況下,所有const函式都可以在執行時執行。但不是所有可以在執行時執行的程式碼都可以被標記為const。

將語言特性設計成 "基礎 "Rust的子/超集是至關重要的:它可以確保語言保持內聚感。而比起大小或範圍,統一性是導致簡單的感覺的原因。

引擎蓋下的非同步
Rust的核心async/.await提供了一種標準化的方法來返回型別,並在它們上使用返回另一種型別的方法。函式不是直接返回一個型別,而是async首先返回一箇中間型別。

/// 這個函式返回一個字串
fn read_to_string(path: Path) -> String { . }

/// 這個函式返回一個型別,最終返回一個字串
async fn read_to_string(path: Path) -> String { . }

/// 不使用`async fn`,我們也可以這樣寫。
/// `impl Future`在這裡是一個型別轉換的結構
fn read_to_string(path: Path) -> impl Future<Output = String> { . }


Future只是一個帶有方法的型別(fn poll)。如果我們在正確的時間以正確的方式呼叫該方法,那麼最終它會給我們等價於Option<T>whereT是我們想要的值。

當我們談論“在一個型別中表示一個計算”時,我們實際上是在談論將async fn其及其所有.await點編譯成一個狀態機,該狀態機知道如何從各個.await 點暫停和恢復。這些狀態機只是其中包含一些欄位的結構,並且具有自動生成的Future::poll實現,它知道如何在各種狀態之間正確轉換。要了解有關這些狀態機如何工作的更多資訊,我建議觀看tmandry 的“非同步 fn 的生命”

.await語法提供了一種方法來確保沒有任何底層poll 細節出現在使用者語法中。大多數用法async/.await看起來就像非非同步 Rust,但async/.await在頂部新增了註釋。

RUST 的非同步特性
async/.awaitRust 提供的核心特性是對執行的控制:
從:

>“函式呼叫”->“輸出”

變成:

>“函式呼叫”->“計算”->“輸出”


計算不再只是對我們隱藏的東西。async/.await我們有權操縱計算本身。 這導致了幾個關鍵功能:
  • 暫停/取消/暫停/恢復計算的能力(臨時取消)
  • 併發執行計算的能力(臨時併發)
  • 結合對執行、取消和併發的控制的能力



臨時取消
暫停/取消/暫停/恢復計算的能力是非常有用的。在這三種能力中,能夠取消執行可能是最有用的一種。在同步和非同步程式碼中,在執行完成之前停止執行都是可取的。但非同步Rust的獨特之處在於,任何計算都可以以一種統一的方式停止。每個未來都可以被取消,而所有的未來都需要考慮到這一點 4.。

特設併發
併發執行計算的能力是async Rust的另一個標誌能力。任何數量的非同步fns都可以併發執行,並且.一起等待。
在非async Rust中,併發性通常是與並行性聯絡在一起的:許多計算可以透過使用thread::spoon並以這種方式拆分來安排併發。但async Rust將併發性與並行性分開,提供了更多的控制。

結合取消和併發性
現在,最後:當你把取消和併發結合起來時會發生什麼?它允許我們做一些有趣的事情: 在我的博文 " "Async Time III: Cancellation and Signals" "中,我深入探討了一些你可以用它做的事情。但這裡的典型例子是:超時。
超時是一些future 和一個定時器未來的併發執行,對映到一個結果。

  • 如果future在定時器之前完成,我們取消定時器並返回Ok。
  • 如果定時器在future 之前完成,我們就取消未來並返回Err。

這就是取消+併發的組合,提供了一種新的第三種操作型別。
要想了解為什麼能夠讓任何計算超時是一個有用的屬性,我強烈推薦閱讀Crash-Only Software by Candea and Fox。但它並不僅僅停留在超時上:如果我們將任何暫停/取消/暫停/恢復功能與併發性結合起來,我們就會釋放出無數新的可能操作。

這些都是async Rust實現的功能。
在非async Rust中,併發、取消和暫停往往需要呼叫底層作業系統--而這並不總是被支援。比如說。Rust沒有內建的方法來取消執行緒。做到這一點的方法通常是給執行緒傳遞一個通道,並定期檢查它,看是否有一些 "取消 "資訊被傳遞。

相反,在async Rust中,任何計算都可以被暫停、取消或併發執行。這並不意味著所有的計算都應該併發執行,或者所有的東西都應該有一個超時。但這些決定可以在我們實現的基礎上做出,而不是受到系統呼叫可用性等外部因素的限制。

效能:工作負載
當某樣東西被說成比其他東西效能更好時,總是值得一問。"在什麼情況下?" 效能總是取決於工作負載。在顯示卡基準測試中,你經常會看到顯示卡之間的差異是基於執行哪些遊戲。在CPU基準測試中,一個工作負載主要是單執行緒還是多執行緒非常重要。而當我們談論軟體功能時,"效能 "也不是二進位制的,而是高度依賴於工作負載的。在談論並行處理時,我們可以區分兩類一般的工作負載。

  • 面向吞吐量的工作負載
  • 面向延時的工作負載

面向吞吐量的工作負載通常關心的是在最短的時間內處理最大數量的事情。而面向延遲的工作負載關心的是儘可能快地處理每件事情。聽起來令人困惑?讓我們把它說得更清楚。

一個考慮到吞吐量的軟體的例子是Hadoop。它是為 "離線 "批次處理工作負載而建立的;其中最重要的設計目標是儘量減少處理資料所花費的總的CPU時間。當資料被放入系統時,它可能經常需要幾分鐘甚至幾小時才能被處理。而這很好。我們並不關心何時得到結果(當然是在合理範圍內),我們主要關心的是使用盡可能少的資源來得到結果。

與一個面向公眾的HTTP伺服器相比。網路通常是面向延遲的。我們通常不關心我們能處理多少個請求,而是關心我們能多快地對它們作出反應。當一個請求進來時,我們不希望花幾分鐘或幾小時來生成一個響應。我們希望請求-響應的往返時間最多隻能以毫秒計算。而像p99尾巴延遲這樣的東西經常被用來作為關鍵的效能指標。

一般來說,非同步Rust被認為是更注重延遲而不是吞吐量。async-std和tokio等執行時主要關注的是保持低的整體延遲,並防止突然的延遲峰值。

瞭解正在討論的工作負載的型別,往往是討論效能的第一步。Async Rust的一個關鍵好處是,大多數使用它的系統都經過了大量的調整,以便為面向延遲的工作負載提供良好的效能,

如果你想處理更多面向吞吐量的工作負載,像rayon這樣的非async crates通常更適合。

效能:最佳化
Async Rust將併發性和並行性相互分離。有時這兩者會相互混淆,但事實上它們是不同的。

並行性是一種資源,併發性是一種計算排程方式

最好把 "並行性 "看成是一個最大值。例如,如果你的計算機有兩個核心,那麼你擁有的最大平行度可能是兩個8。 但平行度與併發性不同:計算可以在單個核心上交錯進行,所以當我們在等待網路執行工作時,我們可以執行一些其他的計算,直到我們有一個回應。即使在單執行緒機器上,計算也可以是交錯的和併發的。反之亦然:我們把事情安排成並行的並不意味著計算是交錯的。無論我們在多少個核心上執行,如果執行緒透過等待一個單一的、共享的鎖來輪流執行,那麼邏輯執行實際上可能仍然是按順序進行的。

讓我們來看看在async和非async Rust中的併發工作負載的例子。在非async Rust中,最常見的是使用執行緒來實現併發執行9。但由於執行緒也是實現並行執行的抽象,這意味著在非async Rust中,併發性和並行性往往是緊密相連的。

在async Rust中,我們可以把併發性和並行性分開。如果一個工作負載是併發的,這並不意味著它也是可並行的。這為執行提供了更精細的控制,這也是async Rust的關鍵優勢。讓我們比較一下非同步和同步Rust的併發性。

// jthread-based concurrent computation
let x = thread::spawn(|| 1 + 1);
let y = thread::spawn(|| 2 + 2);
let (x, y) = (x.join(), y.join()); // wait for both threads to return

// async-based concurrent computation
let x = async { 1 + 1 };
let y = async { 2 + 2 };
let (x, y) = (x, y).await;  // resolve both futures concurrently


這似乎是一個非常愚蠢的例子:計算是同步的,所以兩者都做同樣的事情,但非非同步變體有需要產生實際執行緒的開銷。它並不止於此:因為第二個示例不需要執行緒,編譯器的內聯會啟用啟動,並且可能能夠將其最佳化為以下10

// 編譯器最佳化的基於非同步的併發計算
 let (x, y) = ( 2 , 4 );


相比之下,編譯器可能對基於執行緒的變體執行的最佳最佳化是:

// 基於執行緒的併發計算
let x = thread::spawn(|| 2 );
讓y = thread::spawn(|| 4 );
讓(x,y)=(x.join (),y.join ());// 等待兩個執行緒返回


將併發性與並行性分開可以對計算進行更多最佳化。async在 Rust 中,基本上是一種建立狀態機的奇特方式,巢狀async/.await呼叫允許將型別編譯成單個狀態機。
有時我們可能想要分離狀態機,但這是非同步 Rust 為我們提供的那種控制,使用非非同步 Rust 更難實現。

生態系統
在結束之前,我們應該指出人們可能選擇非同步 Rust 的最後一個原因:生態系統的規模。如果沒有關鍵字泛型,庫作者可能需要大量工作來發布和維護在非同步和非非同步 Rust 中都可以工作的庫。通常只發布非同步或非非同步庫是最簡單的,而不考慮其他用例。但是 crates.io 上的許多與網路相關的庫都使用 async Rust,這意味著在此之上構建的庫也將使用 async Rust。反過來,那些希望在不從頭開始重寫所有內容的情況下構建網站的人在使用非同步 Rust 時通常會有更大的生態系統可供選擇。
網路效應是真實的,需要在這種情況下得到承認。不是每個想要建立網站的人都會考慮語言功能,而可能只是考慮他們在生態系統方面的選擇。這也是使用 async Rust 的完全正當理由。

總結
Async Rust賦予我們控制執行的能力,這在非async Rust中是不可能的。坦率地說,在許多其他具有async/.await的程式語言中也不可能實現。事實上,一個async fn編譯成一個懶惰的狀態機,而不是一個急切的管理任務,這是一個關鍵的區別。這意味著我們可以完全在庫程式碼中編寫併發原語,而不需要在編譯器或執行時中構建它。

在Async Rust中,它所實現的功能是相互關聯的。下面是對它們之間關係的簡要總結:

Yosh 的
        Rust 非同步能力層次結構
    ┌──────────────────────────────┐ 
3. │ 超時、定時器、訊號 │ …h然後可以組合成… 
    ├─────────────┬────────────────┤ 
2. │ 取消 │ 併發 │ …依次enable... 
    ├───────────────┴────────────────┤ 
1. │ 控制執行 │ 核心future啟用... 
    └── ──────────────────────────────┘


一般來說,當你做非同步IO時,它的效能會比非非同步Rust高。但這主要是在底層系統API為之準備的情況下,這通常包括網路API,最近也開始包括磁碟IO。
 

相關文章