async 與 await 是 Rust 中相對較新但極為重要的功能,它們大幅簡化了非同步程式設計的複雜度。這兩個關鍵字讓開發者能夠編寫看起來像同步程式碼但實際以非同步方式執行的程式,顯著降低了直接使用 Future 特質的難度。掌握這些關鍵字的運作機制與使用時機,是構建高效能非同步系統的關鍵基礎。
async 與 await 基礎語法解析
在 Rust 的非同步程式設計體系中,async 關鍵字可以應用於函式、閉包或程式碼區塊,用來標示該段程式碼會回傳一個 Future。await 運算式則用於等待 Future 的結果,其本質是呼叫 Future 特質的輪詢方法並處理輪詢邏輯。這種設計讓開發者能夠以接近同步程式碼的方式編寫非同步邏輯,同時保留非同步執行的所有優勢。
使用這些關鍵字時必須理解一個關鍵概念:要使用 await 運算式,必須處於非同步上下文中。這意味著包含 await 的程式碼必須位於標記為 async 的區塊或函式內部,而且該非同步程式碼必須在非同步執行時上執行才能真正發揮作用。這個要求確保了非同步操作能夠正確地與執行時的排程機制整合,讓執行時能夠有效地管理多個並行的非同步任務。
當我們標記一個函式為 async 時,編譯器會將其轉換為一個回傳 Future 的普通函式。這個 Future 代表了一個可能尚未完成的運算,它會在適當的時機被執行時輪詢並推進執行。await 運算式則是告訴執行時我們需要等待這個 Future 完成,在等待期間執行時可以自由地切換去執行其他任務,充分利用 CPU 時間。
@startuml
!define PLANTUML_FORMAT svg
!define DISABLE_LINK
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
start
:開發者編寫 async 函式;
:編譯器轉換為 Future 回傳;
:程式碼包含 await 表示式;
if (處於非同步上下文?) then (是)
:Future 註冊到執行時系統;
:執行時開始輪詢 Future 狀態;
repeat
:呼叫 Future 輪詢方法;
if (Future 執行完成?) then (是)
:取得最終執行結果;
stop
else (否)
:註冊喚醒器回呼;
:執行時切換到其他任務;
endif
:等待喚醒器觸發通知;
repeat while (繼續輪詢直到完成)
else (否)
:編譯器回報錯誤;
stop
endif
@endumlFuture 執行行為的深入理解
為了真正理解 async 與 await 的運作方式,需要透過實際範例觀察 Future 的執行行為。考慮一個展示不同處理方式的程式,它能清楚說明 Future 的執行時機與條件。第一個非同步區塊直接使用 await 等待完成,因此其內部的程式碼一定會在其他輸出之前執行。這展示了 await 的阻塞性質,當我們等待一個 Future 時,目前的執行流程會暫停直到該 Future 完成。
第二個非同步區塊僅僅建立了 Future 但從未執行它。這個 Future 被指派給一個變數後就被忽略了,既沒有使用 await 等待,也沒有將其註冊到執行時上執行。這個範例揭示了一個關鍵概念:僅僅定義 Future 不會導致其執行,必須明確地透過 await 或將其交給執行時來觸發執行。這種惰性求值的特性是 Rust 非同步設計的重要特徵,它讓開發者能夠精確控制何時開始執行非同步操作。
第三個非同步區塊使用任務產生函式將任務註冊到執行時上執行。由於我們沒有等待它完成,這個任務會在背景執行,但其執行時機與完成順序都是不確定的。這種不確定性來自於執行時的排程機制,任務可能在主程式結束前完成,也可能來不及執行。最後一個輸出語句總是會執行,但它可能不是最後一個輸出,這取決於背景任務的執行速度。
@startuml
!define PLANTUML_FORMAT svg
!define DISABLE_LINK
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
participant "主執行緒" as Main
participant "執行時排程器" as Scheduler
participant "非同步任務一" as Task1
participant "非同步任務二" as Task2
participant "背景任務" as BgTask
Main -> Task1 : 建立並等待執行
activate Task1
Task1 -> Task1 : 執行任務邏輯並輸出
Task1 --> Main : 回傳執行結果
deactivate Task1
Main -> Task2 : 僅建立 Future 實例
note right
建立但從未執行
沒有呼叫輪詢方法
不會產生任何效果
end note
Main -> Scheduler : 產生背景任務
activate Scheduler
Scheduler -> BgTask : 註冊到任務佇列
activate BgTask
Main -> Main : 繼續執行主邏輯流程
Main -> Main : 輸出最後的訊息
alt 背景任務及時執行完成
BgTask -> BgTask : 執行並輸出結果
deactivate BgTask
else 主程式提前結束執行
Main -> Scheduler : 程式終止退出
Scheduler -> BgTask : 取消未完成任務
deactivate BgTask
end
deactivate Scheduler
@enduml這個範例生動展示了非同步程式設計中任務生命週期管理的重要性。開發者必須明確理解每個非同步操作的執行時機,確保重要的操作能夠在程式結束前完成。對於不需要等待結果的背景任務,需要考慮是否使用信號或其他機制來協調程式的退出時機,避免任務被意外取消。
非同步任務生命週期深度管理
在非同步程式設計實務中,理解並妥善管理任務的生命週期至關重要。當使用任務產生函式建立獨立任務時,這些任務會在背景執行而不會阻塞主程式流程。但這種便利性也帶來了複雜性,需要仔細考慮任務的執行時機、完成條件與資源釋放。任務的生命週期從建立開始,經過執行、可能的暫停與恢復,最終到達完成或取消的狀態。
任務完成保證機制
在設計非同步系統時,經常需要決定是否等待特定任務完成。這個決定取決於任務的性質與系統需求。需要結果的任務必須使用 await 等待完成,確保我們能夠取得並處理執行結果。背景處理任務可以使用產生函式啟動並讓其獨立執行,特別適合日誌記錄、監控或其他不影響主要邏輯的操作。
即使不需要任務結果,有時也需要限制同時執行的任務數量,避免資源耗盡。這種情況下可能需要使用信號量或其他同步機制來控制並行度。理解這些權衡對於構建穩健的非同步系統至關重要。當包含非同步任務的作用域結束時,未完成的任務可能會被取消。這在資源管理方面具有重要影響,特別是當任務涉及檔案控制代碼、網路連線或其他需要正確關閉的資源時。
開發者必須確保適當的清理邏輯,避免資源洩漏或不一致狀態。這可能包括使用取消令牌來優雅地終止任務,或是確保關鍵的清理程式碼即使在任務被取消時也能執行。Rust 的所有權系統在這裡提供了一定程度的保護,但開發者仍需要明確處理非同步操作特有的生命週期問題。
從非同步環境建立任務
任務產生函式具有一個重要特性:它允許從非同步環境直接啟動非同步任務到執行時上,同時回傳一個可以像其他物件一樣傳遞的控制代碼。這個設計讓任務管理變得更加靈活,可以在不同的程式碼路徑中建立任務並在適當時機等待其完成。控制代碼本身實作了 Future 特質,因此可以使用 await 來等待任務完成。
即使是普通的非非同步函式,也能夠回傳控制代碼型別的值。這個函式雖然沒有標記為 async,但它能夠啟動非同步任務並回傳代表該任務的控制代碼。在主函式中,可以使用 await 等待這個控制代碼完成,確保任務執行完成。使用結果處理方法則可以優雅地忽略可能的錯誤結果,簡化錯誤處理邏輯。
這種模式的關鍵在於任務產生函式能夠在任何有效的執行時上下文中使用,不論呼叫者本身是否為非同步函式。這提供了極大的彈性,讓開發者能夠在同步與非同步程式碼之間建立清晰的界面。這種設計模式特別適合用於建構需要混合同步與非同步操作的複雜系統。
在同步環境中執行非同步程式碼
在某些情況下,需要在完全同步的環境中執行非同步程式碼。雖然這不是理想的設計模式,但有時候確實無法避免。執行時提供了阻塞執行方法來處理這種情況,它能夠阻塞目前執行緒直到非同步程式碼執行完成。使用這種方法需要先取得執行時的控制代碼,這個控制代碼可以被複製和分享,提供從同步環境存取非同步執行時的能力。
透過取得目前執行時控制代碼的方法可以取得目前非同步執行時的控制代碼,這個控制代碼可以被移動到新建立的執行緒中使用。在新執行緒中,可以使用阻塞執行方法在目前執行緒上阻塞直到非同步程式碼執行完成。這種方法雖然有效,但並不優雅,應該盡量避免使用。在大多數情況下,應該優先使用 async 與 await 來處理非同步邏輯。
@startuml
!define PLANTUML_FORMAT svg
!define DISABLE_LINK
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
package "非同步執行環境結構" {
component "執行時系統" as Runtime
component "非同步任務池" as TaskPool
}
package "同步執行環境結構" {
component "主執行緒" as MainThread
component "工作執行緒" as WorkerThread
}
MainThread -down-> Runtime : 取得執行時控制代碼
MainThread -down-> WorkerThread : 建立新執行緒
WorkerThread -down-> Runtime : 阻塞執行非同步程式碼
Runtime -down-> TaskPool : 排程執行非同步任務
TaskPool -up-> WorkerThread : 回傳最終執行結果
note right of Runtime
執行時提供控制代碼
允許同步環境存取
但應該盡量避免
end note
note left of WorkerThread
阻塞等待非同步完成
失去非同步執行優勢
僅在必要時使用
end note
@enduml這種模式的主要問題在於它完全抵消了非同步程式設計的優勢。當執行緒被阻塞等待非同步操作時,它無法執行其他工作,這與非同步程式設計的核心理念相違背。因此這種方法應該僅在絕對必要時使用,例如在需要與舊有同步 API 整合的邊界處。
並行與平行處理的精確控制
在非同步程式設計中,並行與平行處理是兩個不同但相關的概念。理解這兩者的差異對於設計高效能系統至關重要。並行指的是多個任務在時間上交錯執行,它們可能在同一個 CPU 核心上透過快速切換來實現看似同時執行的效果。平行處理則是指多個任務真正同時在不同的 CPU 核心上執行,實現真正的同時運算。
Tokio 執行時的調度機制
執行時對平行處理沒有明確的細粒度控制,除了使用阻塞任務產生函式啟動阻塞任務外,這類任務總是在獨立的執行緒執行。開發者可以明確控制並行性,但無法直接控制個別任務的平行度,這些細節由執行時的排程器決定。執行時允許設定工作執行緒的數量,但會自動決定為每個任務使用哪些執行緒。
這種設計提供了效能與簡潔性之間的良好平衡,讓開發者能夠專注於業務邏輯而非底層的執行緒管理。執行時的排程器會根據工作負載動態地分配任務到不同的執行緒上,嘗試最大化 CPU 利用率。這種自動化的排程機制在大多數情況下都能提供良好的效能,但在某些極端情況下可能需要手動調整。
實現並行的三種主要方式
在程式碼中引入並行性可以透過幾種不同的方式實現。第一種是使用任務產生函式建立獨立的任務單元,這種方式建立的任務可以被執行時排程到不同的執行緒上執行。每個產生的任務都有自己的執行上下文,可以獨立於其他任務進行輪詢與執行。
第二種方式是使用合併巨集或合併函式來組合多個 Future。這種方式允許多個 Future 並行執行,但它們會在同一個任務上下文中執行,不會自動獲得平行性。合併操作會輪流輪詢每個 Future,當所有 Future 都完成時回傳所有結果。這種模式適合需要等待多個相關操作完成的場景。
第三種方式是使用選擇巨集,它允許等待多個並行的程式碼分支,並在其中一個完成時立即回傳。這種模式特別適合實作超時機制或取消邏輯。選擇巨集會並行輪詢所有分支,一旦有任何一個分支完成就立即回傳該分支的結果,其他分支會被取消。
平行處理的實現條件
要引入真正的平行處理,必須使用任務產生函式,但這並不保證明確的平行執行。當產生任務時,我們告訴執行時這個任務可以在任何執行緒上執行,但執行時仍然決定使用哪個執行緒以及何時執行。如果執行時只配置了一個工作執行緒,即使使用產生函式建立多個任務,所有任務也會在單一執行緒上執行。
@startuml
!define PLANTUML_FORMAT svg
!define DISABLE_LINK
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
state "執行模式選擇決策" as Choice
state "順序執行模式" as Sequential {
state "任務 A 執行階段" as SeqA
state "任務 B 執行階段" as SeqB
[*] --> SeqA
SeqA --> SeqB : 完成後才開始
SeqB --> [*]
}
state "並行執行模式" as Concurrent {
state "任務 A 開始執行" as ConA
state "任務 B 開始執行" as ConB
state "交錯執行階段" as Interleave
[*] --> ConA
[*] --> ConB
ConA --> Interleave
ConB --> Interleave
Interleave --> [*]
note right of Interleave
單執行緒上交錯執行
使用合併巨集或者
非阻塞式的操作
end note
}
state "平行執行模式" as Parallel {
state "任務 A 在核心一" as ParA
state "任務 B 在核心二" as ParB
[*] --> ParA
[*] --> ParB
ParA --> [*]
ParB --> [*]
note right of ParB
多執行緒同時執行
使用任務產生函式
需要多個工作執行緒
end note
}
[*] --> Choice
Choice --> Sequential : 直接使用 await
Choice --> Concurrent : 使用合併巨集
Choice --> Parallel : 使用產生函式
@enduml這種情況下可以獲得並行性但沒有平行性,任務會交錯執行但不會同時在不同核心上執行。要實現真正的平行執行,必須配置多個工作執行緒並使用任務產生函式來建立可以在不同執行緒上執行的任務。這種配置讓執行時能夠將任務分散到多個核心上,實現真正的平行計算。
阻塞與非阻塞操作的效能差異
透過實際範例可以清楚看到阻塞與非阻塞操作在並行執行中的差異。當使用阻塞的睡眠函式時,即使使用合併巨集嘗試並行執行,任務仍然會順序執行。這是因為阻塞操作會完全佔據執行緒,不給執行時切換任務的機會。阻塞操作期間,執行緒無法執行其他工作,這完全違背了非同步程式設計的理念。
相對地,使用非阻塞的睡眠函式時,即使在單執行緒環境中也能實現真正的並行執行。當一個任務進入等待狀態時,它會透過 await 將控制權交還給執行時,允許其他任務繼續執行。這種協作式多工模式是非同步程式設計效能優勢的關鍵。非阻塞操作會在等待資源可用時主動讓出執行權,讓執行時能夠充分利用 CPU 時間。
只有當使用任務產生函式並且執行時配置了多個工作執行緒時,才能獲得真正的平行執行。在這種情況下,多個任務可以同時在不同的 CPU 核心上執行,最大化硬體資源的利用率。觀察執行時序的輸出可以清楚看到這些差異,順序執行時每個任務必須等待前一個完成,並行執行時任務開始訊息會先於完成訊息出現,平行執行時多個任務的開始訊息幾乎同時出現。
非同步觀察者模式的完整實作
觀察者模式在非同步程式設計中特別有用,但在 Rust 中實作非同步觀察者模式面臨一些獨特的挑戰。這些挑戰主要來自 Rust 的型別系統限制以及非同步特質的特殊要求。觀察者模式允許物件在狀態改變時通知其他物件,這在事件驅動的非同步系統中是一個常見的需求。
非同步特質的語法限制
在撰寫本文時,Rust 的非同步支援存在一個重要限制:無法在特質中直接宣告非同步方法。這個限制使得直接定義非同步特質變得困難,需要透過其他方式來實現等效的功能。理解這個限制的關鍵在於認識到 async 與 await 只是處理 Future 的便捷語法糖。當宣告一個 async 函式或程式碼區塊時,編譯器會將該程式碼轉換為回傳 Future 的普通函式。
因此可以在特質中建立等效的非同步方法,只是需要明確地處理 Future 而不是使用語法糖。這種方法雖然稍微複雜一些,但提供了完整的控制與彈性。開發者需要手動指定方法回傳的 Future 型別,並處理相關的生命週期與型別約束。
從同步觀察者到非同步版本的演進
開始時可以參考標準的同步觀察者特質設計。這個設計定義了一個關聯型別代表被觀察的主體,以及一個觀察方法用於處理觀察邏輯。要將這個設計轉換為非同步版本,需要讓觀察方法回傳 Future。第一次嘗試可能是定義一個關聯型別,它具有 Future 特質約束,並讓觀察方法回傳這個型別。
@startuml
!define PLANTUML_FORMAT svg
!define DISABLE_LINK
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
class "Observer 特質同步版本" as SyncObserver {
關聯型別主體
觀察方法同步執行
}
class "Observer 特質初步嘗試" as AttemptObserver {
關聯型別主體
關聯型別輸出 Future
觀察方法回傳輸出
--
問題 Future 非具體型別
}
class "Observer 特質使用包裝" as BoxObserver {
關聯型別主體
關聯型別輸出
觀察方法回傳包裝 Future
--
問題未固定無法等待
}
class "Observer 特質最終版本" as FinalObserver {
關聯型別主體
關聯型別輸出
觀察方法回傳固定包裝 Future
--
完整功能的非同步觀察者
}
SyncObserver -down-> AttemptObserver : 嘗試轉換為非同步
AttemptObserver -down-> BoxObserver : 引入特質物件包裝
BoxObserver -down-> FinalObserver : 加入固定與生命週期
note right of FinalObserver
固定機制確保記憶體穩定
生命週期關聯自身引用
傳送約束允許跨執行緒
end note
@enduml這種設計看似合理且能夠編譯通過,但在實作時會遇到問題。因為 Future 只是一個特質而非具體型別,無法明確指定輸出的實際型別。這個問題的根源在於 Rust 的型別系統需要在編譯期知道所有型別的具體大小與布局,而特質物件無法提供這些資訊。
使用特質物件與固定機制解決問題
由於無法直接使用關聯型別,需要改用特質物件並將 Future 包裝起來。這種方式允許回傳一個動態分派的 Future,解決了型別具體化的問題。但這還不夠,因為 Future 特質的輪詢方法需要接收一個固定包裝的自身引用。固定機制的作用是確保 Future 在記憶體中的位置保持穩定,這對於包含自引用的 Future 至關重要。
當 Future 包含指向自身內部資料的引用時,移動這個 Future 會導致引用失效。固定機制透過型別系統防止這種移動,保證了記憶體安全。要獲得一個固定的 Future,需要將回傳型別改為固定包裝。使用固定函式可以方便地建立固定的包裝,這個函式會自動處理固定的細節並回傳正確的型別。
處理生命週期與執行緒安全
即使解決了固定機制的問題,還需要處理生命週期與執行緒安全性。當非同步方法需要引用自身或其他參數時,必須明確地標註生命週期參數,確保編譯器能夠驗證引用的有效性。此外還需要確保觀察者實例能夠安全地在執行緒間傳遞。這需要為觀察者特質增加傳送與同步超特質約束,確保實作該特質的型別滿足執行緒安全要求。
@startuml
!define PLANTUML_FORMAT svg
!define DISABLE_LINK
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
participant "可觀察主體" as Subject
participant "觀察者一" as Obs1
participant "觀察者二" as Obs2
participant "執行時排程器" as Scheduler
Subject -> Subject : 內部狀態發生改變
Subject -> Obs1 : 呼叫觀察方法並等待
activate Obs1
Subject -> Obs2 : 呼叫觀察方法並等待
activate Obs2
note over Obs1, Obs2
並行執行觀察處理邏輯
每個觀察者獨立處理任務
end note
Obs1 -> Scheduler : 執行非同步操作
activate Scheduler
Obs2 -> Scheduler : 執行非同步操作
Scheduler -> Obs1 : 完成通知回呼
Obs1 --> Subject : 觀察處理完成
deactivate Obs1
Scheduler -> Obs2 : 完成通知回呼
Obs2 --> Subject : 觀察處理完成
deactivate Obs2
deactivate Scheduler
note right of Subject
所有觀察者處理完成
可以繼續下一個更新
end note
@enduml回傳的 Future 也需要標記為可傳送,允許它在不同執行緒間移動執行。最終的特質定義包含了所有這些要素:固定機制確保記憶體穩定性,生命週期參數關聯引用的有效期間,傳送與同步約束保證執行緒安全。這些看似複雜的要求實際上都是為了在編譯時期確保程式的正確性與安全性。
可觀察特質的完整設計
對應的可觀察特質也需要類似的處理。更新方法負責通知所有已註冊的觀察者,它應該回傳一個固定的、可傳送的 Future。這個方法的生命週期應該與自身引用相關聯,確保在非同步操作期間觀察者清單保持有效。附加與分離方法用於管理觀察者的註冊與移除,這些方法接收可變引用是因為需要修改內部的觀察者清單。
設計時需要考慮如何安全地儲存觀察者實例,通常會使用原子引用計數與互斥鎖的組合來實現執行緒安全的共享所有權。實作這個模式時需要仔細處理非同步呼叫的細節。當更新方法被呼叫時,它需要迭代所有已註冊的觀察者並呼叫它們的觀察方法。這些呼叫應該並行執行以提高效能,可以使用合併函式或類似的工具來等待所有觀察者完成處理。
非同步程式設計的實務建議
在實務開發中應用非同步程式設計時,有幾個關鍵原則值得遵循。首先是理解任務生命週期的重要性,確保清楚知道每個非同步任務何時啟動、如何執行以及何時結束。這種理解對於避免資源洩漏與不一致狀態至關重要。開發者需要追蹤每個任務的狀態,確保重要的任務能夠完成,同時適當地清理不再需要的資源。
選擇適當的並行模型同樣重要。根據任務的性質決定使用並行還是平行處理,對於輸入輸出密集型任務並行通常已經足夠,而處理器密集型任務則更需要平行處理。理解這些差異能夠幫助做出正確的架構決策。輸入輸出操作通常涉及等待外部資源,這種等待時間可以透過並行執行其他任務來有效利用,而處理器密集型任務則需要真正的平行執行才能提升效能。
避免阻塞執行時是另一個關鍵原則。在非同步上下文中使用阻塞操作會完全抵消非同步程式設計的優勢,如果必須使用阻塞操作應該考慮使用阻塞任務產生函式將其隔離到專用的執行緒池中。這種隔離確保阻塞操作不會影響其他非同步任務的執行,保持整體系統的回應性。
合理使用系統資源包括設定適當數量的工作執行緒,這個數字應該基於實際工作負載與硬體特性來決定。過多的執行緒會增加上下文切換開銷,過少則無法充分利用硬體資源。錯誤處理在非同步程式設計中需要特別注意,非同步操作可能在未來某個時間點失敗,需要確保有適當的機制來捕獲與處理這些錯誤。
使用結果型別與問號運算子能夠讓錯誤處理邏輯更加清晰。最後是測試策略的考量,非同步程式碼的測試比同步程式碼更具挑戰性,需要使用特殊的測試框架與技巧。執行時提供了測試執行時與相關工具,能夠簡化非同步程式碼的測試過程。
掌握 Rust 的 async 與 await 機制需要時間與實踐,但這項投資是值得的。這些工具讓開發者能夠構建高效能、可擴展的系統,同時保持程式碼的可讀性與可維護性。透過深入理解底層機制並遵循最佳實踐,可以充分發揮 Rust 非同步程式設計的威力,建構出既安全又高效的現代化系統軟體。非同步程式設計是現代軟體開發的重要技能,而 Rust 提供了一個既安全又高效的非同步程式設計模型,值得每位系統程式開發者深入學習與掌握。