2.3 非同步程式設計:Future 狀態機與事件循環#
問題:併發的成本曲線#
服務一萬個連線的三種模型:
| 模型 | 記憶體成本 | 切換成本 | 程式模型 |
|---|---|---|---|
| 行程/執行緒 | ~8MB/執行緒(預設堆疊) | 核心態切換 | 同步、直觀 |
| 事件循環(Node.js) | O(連線數) | 使用者態 | 回呼地獄 |
| Rust async | O(狀態機大小),數百 bytes | 使用者態 | async/await 同步風格 |
Rust async 的目標:取得事件循環的資源效率,同時保留同步程式碼的可讀性。
Future:惰性的狀態機#
Rust 的 Future 與 JavaScript Promise 的根本差異:
- JS Promise:建立即執行(eager)
- Rust Future:惰性,被 poll 才前進(lazy)
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
enum Poll<T> { Ready(T), Pending }
每個 async fn 編譯後是一個狀態機:每個 .await 點是狀態轉移,函數的局部變數被存進狀態機結構體 —— 這就是為何 async 任務只需數百 bytes(對比執行緒的 MB 級堆疊)。
\[ \text{記憶體} = \text{狀態機大小} = \sum_{\text{局部變數}} \text{size} \quad \text{而非} \quad 8\,\text{MB 堆疊} \]
flowchart LR
A[async fn 呼叫] --> B[Future 建立\n尚未執行]
B --> C[執行器 poll]
C --> D{Ready?}
D -->|是| E[回傳結果]
D -->|否| F[註冊 waker\n掛起]
F --> G[I/O 事件到達\n喚醒 waker]
G --> CTokio:多執行緒工作竊取排程器#
Tokio 是 Rust 的事實標準執行期(runtime),包含:
- 反應器(reactor):以 epoll/kqueue 監聽 I/O 事件
- 排程器(scheduler):多 worker 執行緒 + 工作竊取(work-stealing)
- 計時器:階梯式 timer wheel
工作竊取:每個 worker 有本地佇列,閒置時從其他 worker 竊取任務 —— 這是多核心負載平衡的經典演算法(Java ForkJoinPool 同源),在「任務數多且不均」的 Web 負載下表現極佳。
async/await 的語意#
async fn get_user(pool: &PgPool, id: Uuid) -> Result<User> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id=$1", id)
.fetch_one(pool)
.await?; // await 點:讓出執行緒,狀態機暫停
Ok(user)
}
.await 的意義:「此處可能需要等待,期間把執行緒讓給別的任務」。注意兩個陷阱:
陷阱一:跨 await 持有鎖#
let guard = state.lock().await; // MutexGuard 跨越 await
do_something(&guard).await; // 若 Mutex 是 std::sync,編譯器會擋下
std 的 MutexGuard 不是 Send,跨 await 會編譯失敗 —— 型別系統再次拯救併發正確性。
陷阱二:在同步迴圈中 await#
// 錯誤:序列請求,總延遲 = n × RTT
for id in ids { get_user(&pool, id).await?; }
// 正確:併發,總延遲 ≈ max(RTT)
let users = futures::future::try_join_all(
ids.iter().map(|id| get_user(&pool, *id))
).await?;
\[ T_{\text{序列}} = \sum_i t_i \quad\quad T_{\text{併發}} = \max_i t_i \]
Send 與 'static:Axum Handler 的約束#
Axum 要求 handler 是 Send + 'static,因為任務會在多執行緒間移動。這帶來一條實戰規則:不要跨 await 持有非 Send 的引用。違反時的編譯錯誤資訊很長,核心通常是 future cannot be sent between threads safely。
與 Node.js 事件循環的對照#
| 面向 | Node.js | Rust + Tokio |
|---|---|---|
| 執行模型 | 單執行緒事件循環 | 多執行緒工作竊取 |
| 阻塞主執行緒 | 毀掉整個服務 | 只毀一個 worker |
| 顏色函數問題 | async 傳染(同樣存在) | 同樣存在,但型別系統顯式化 |
| CPU 密集任務 | worker_threads / 集群 | spawn_blocking / rayon |
習題#
- 畫出
async fn fetch_two(a, b)(兩個 sequential await)編譯後狀態機的狀態圖。 - 為什麼 Rust 選擇惰性 Future 而 JS 選擇 eager Promise?各舉一個因此產生的陷阱。
- 解釋工作竊取演算法,以及為何它比全域單一佇列有更好的快取局部性。