featured.svg

在現代系統級程式設計中,面對高並發、I/O 密集型以及分散式微服務等場景,傳統的「一執行緒對一連線(Thread-per-Connection)」模型因其高昂的堆疊記憶體佔用與 OS context switch 開銷已無法滿足百萬級連線(C1000K)的需求。為了實現極致的吞吐量與低延遲,現代語言紛紛在無垃圾回收(Zero-GC)的前提下,引進了語言級別的非同步抽象:

  • C++ 在 C++20 引入了無堆疊協程(Stackless Coroutines),並在 C++23 進一步完善標準庫(如 std::generatorstd::expected),奠定了現代高效能網路伺服器與運算管線的基石。
  • Rust 自 1.39 穩定化了 async/await 語法,以標準庫核心 trait core::future::Future 為契約,配合 Tokio、smol 等社群 runtime,成為構建雲原生網路基礎設施的熱門選擇。

表面上看,兩者都提供了直觀的 co_await / .await 語法,讓開發者能以撰寫線性同步程式碼的思維來處理複雜的非同步流程。然而,在語法糖的表象之下,C++ 與 Rust 的底層架構、記憶體佈局、執行調度模型與哲學取捨截然相反

本文將從計算機系統底層與語言編譯器實作的角度,深度拆解這兩大現代系統級語言在非同步設計上的核心分歧。


1. 架構全景:Push-Driven 與 Pull-Driven 的核心對決

理解兩者差異的第一把鑰匙,在於其驅動協程狀態機前進的控制流方向

flowchart TB subgraph CPP["⚡ C++20/23 Coroutines (Push-Driven / Resumption)"] direction TB CP_Suspend["1. co_await awaitable
協程暫停,儲存狀態至 Frame"] CP_Register["2. await_suspend(handle)
將 coroutine_handle 註冊給 EventLoop / Driver"] CP_Push["3. Event Ready ➜ handle.resume() / 對稱轉移
精確直接喚醒:直接跳轉至中斷點續行"] CP_Suspend --> CP_Register --> CP_Push end subgraph Rust["🦀 Rust Async / Future (Pull-Driven / Polling)"] direction TB RS_Poll["1. Executor 呼叫頂層 Future::poll(cx)
狀態機由上而下遞迴評估"] RS_Pending["2. 未就緒 ➜ 回傳 Poll::Pending
底層 Leaf Future 將 Waker 註冊至 Reactor (epoll)"] RS_Pull["3. Event Ready ➜ waker.wake()
重新拉取:Task 放回佇列,Executor 再次從頂層 poll"] RS_Poll --> RS_Pending --> RS_Pull end classDef cppBox fill:#f0f9ff,stroke:#0284c7,stroke-width:2px,color:#0c4a6e; classDef rustBox fill:#fff7ed,stroke:#ea580c,stroke-width:2px,color:#7c2d12; classDef stepNode fill:#ffffff,stroke:#94a3b8,stroke-width:1px,color:#0f172a; class CPP cppBox; class Rust rustBox; class CP_Suspend,CP_Register,CP_Push,RS_Poll,RS_Pending,RS_Pull stepNode;
  • C++ 是 Push-Driven(推動式 / 延續傳遞模型):當 I/O 事件就緒或子任務完成時,執行期持有目標協程的 coroutine_handle,直接呼叫 handle.resume() 或透過 對稱轉移(Symmetric Transfer) 將控制權「推(Push)」給目標協程。協程從中斷處精確甦醒,無需自頂向下重新遍歷呼叫樹。
  • Rust 是 Pull-Driven(拉取式 / 輪詢模型):Future 本身是不具備自驅動力的純粹資料結構。只有當外部 Executor 呼叫 poll() 時,它才會嘗試向前推進一步。若遇到未就緒的 I/O,則回傳 Poll::Pending 並在底層註冊 Waker;當事件就緒時,Waker 僅通知 Executor「該 Task 已準備好」,由 Executor 再次從頂層 Future 重新發起 poll()「拉(Pull)」出最新進度。

2. 核心執行模型深度剖析

2.1 C++ 協程:基於 co_await、Awaiter 與對稱轉移的精確恢復

在 C++ 中,協程被編譯器轉譯為包含 promise_type 與暫停點索引的狀態機。每一次暫停與恢復,都是透過標準的 Awaiter 概念 精確控制:

// C++ Awaiter 概念與對稱轉移
struct SocketReadAwaiter {
    int fd;
    EventLoop* loop;
    char buffer[1024]{};

    bool await_ready() const noexcept { return false; }

    // 當 await_ready 回傳 false 時呼叫
    std::coroutine_handle<> await_suspend(std::coroutine_handle<> coroutine) noexcept {
        // 將當前協程的 handle 直接註冊到底層 epoll / driver
        loop->register_read_event(fd, coroutine);
        
        // 回傳 noop_coroutine() 暫停當前執行緒控制權回給呼叫者,
        // 或回傳另一個 handle 進行零堆疊開銷的對稱轉移 (Symmetric Transfer)
        return std::noop_coroutine();
    }

    ssize_t await_resume() noexcept {
        // 喚醒時直接從此處執行,回傳結果
        return ::read(fd, buffer, sizeof(buffer));
    }
};

對稱轉移(Symmetric Transfer)的威力

在早期的協程實作中,子協程完成後喚醒父協程往往透過直接遞迴呼叫 parent_handle.resume()。若有一連串連續完成的非同步鏈結,呼叫堆疊會不斷加深,最終導致 Stack Overflow。

如同 Lewis Baker 在其經典專文 C++20 Coroutines: Understanding Symmetric Transfer 中所深入解析,C++20 引入了 對稱轉移(Symmetric Transfer)await_suspend 可以回傳一個 std::coroutine_handle<>。編譯器會將其優化為一條簡單的組合語言 tail jump(尾跳躍),直接切換暫存器指向的 Frame,在保持常數呼叫堆疊深度的同時完成協程切換。

flowchart LR Child["📦 子協程 Frame
final_suspend()"] -->|await_suspend 回傳 parent_h| TailJump["⚡ Symmetric Transfer
Tail Call / JMP 暫存器切換"] TailJump --> Parent["🎯 父協程 Frame
直接恢復 (零多餘堆疊開銷)"] classDef cNode fill:#f0f9ff,stroke:#0284c7,stroke-width:1.5px,color:#0c4a6e; classDef tNode fill:#f5f3ff,stroke:#6366f1,stroke-width:2px,color:#312e81; classDef pNode fill:#ecfdf5,stroke:#059669,stroke-width:1.5px,color:#064e3b; class Child cNode; class TailJump tNode; class Parent pNode;

2.2 Rust Async:基於 Future::pollContextWaker 的輪詢機制

Rust 的非同步抽象極其精簡,核心定義於 core::future::Future

pub trait Future {
    type Output;

    // 核心輪詢介面
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> {
    Ready(T),
    Pending,
}

一個 async fn 函式在編譯期會被展開為一個包含所有局部變數的 enum 狀態機:

// 概念上的編譯展開偽代碼
enum MyTaskStateMachine {
    Start,
    WaitingOnSocket {
        socket: TcpStream,
        buf: [u8; 1024],
    },
    WaitingOnTimer {
        timer: Sleep,
        read_bytes: usize,
    },
    Done,
}

impl Future for MyTaskStateMachine {
    type Output = Result<()>;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        loop {
            match *self {
                State::Start => {
                    // 進入第一階段...
                }
                State::WaitingOnSocket { ref mut socket, .. } => {
                    match socket.poll_read(cx) {
                        Poll::Ready(n) => { /* 轉移至 WaitingOnTimer */ },
                        Poll::Pending => return Poll::Pending, // 原路返回
                    }
                }
                // ...
            }
        }
    }
}

Waker 與 Reactor 喚醒鏈

當底層 I/O(如 Socket)尚未就緒時,poll_read 會將 cx.waker() 複製並註冊至作業系統事件驅動器(如 Linux epoll 的 Mio Reactor)。當網路封包抵達時:

  1. epoll_wait 喚醒 Reactor 執行緒。
  2. Reactor 找到對應的 Waker 並呼叫 waker.wake()
  3. Waker 將對應的 Task(通常由 Arc<Task> 包裝)推入 Tokio Executor 的排程佇列(Run Queue)。
  4. Worker Thread 提取 Task,呼叫最外層頂級 Future 的 poll()
  5. 頂級 Future 沿著巢狀組合的 .await 樹狀路徑由上而下重新 poll(),直到抵達就緒節點。

2.3 執行模型對比:精確恢復 vs. 頂層向下重評估

維度 C++20/23 協程 (Push 模型) Rust Async (Pull 模型)
推進機制 事件發生時,直接 resume() 指定的 handle 事件發生時,wake() 通知排程器重新 poll()
呼叫路徑開銷 $O(1)$ 常數開銷:直接跳轉至暫停點指令 $O(D)$ 深度開銷:從根節點遍歷巢狀 Future 呼叫鏈
組合子開銷 複雜組合子(如 when_all)需自行維護計數與 Continuation 鏈 join! / select! 天然由單一 poll() 聚合分發
執行緒安全依賴 Handle 可跨執行緒傳遞,需由自訂 Promise 確保同步 依賴 Send / Sync 編譯期自動推導保證執行緒安全

3. 記憶體佈局與配置:Coroutine Frame vs. 匿名 State Machine

記憶體配置是 C++ 與 Rust 非同步架構最具實質效能與資源差異的戰場。

flowchart LR subgraph CP_Mem["⚡ C++ Coroutine Frame (動態配置)"] direction TB CF1["🏷️ 函式指標 (Resume / Destroy Fn)"] CF2["🎯 promise_type 實例 (結果、Continuation 指標)"] CF3["💾 捕獲之函式參數與跨暫停點區域變數"] CF4["🔢 狀態機暫停點索引 (Suspend Index)"] CF_Note["📌 預設配置於 Heap (operator new)
可透過 HALO 最佳化內聯至 Stack (Best-Effort)"] CF1 --- CF2 --- CF3 --- CF4 --- CF_Note end subgraph RS_Mem["🦀 Rust 匿名 State Machine (編譯期確定)"] direction TB RF1["🏷️ Variant 0 (初始狀態)"] RF2["⏸️ Variant 1 (暫停點 1 + 局部變數)"] RF3["⏸️ Variant 2 (暫停點 2 + 局部變數)"] RF4["🔢 Discriminant 標籤 (Tag)"] RF_Note["📌 編譯期確定大小 (Zero Heap 保證)
大小由最大 Variant 決定 (空間可能膨脹)"] RF1 --- RF2 --- RF3 --- RF4 --- RF_Note end CP_Mem ~~~ RS_Mem classDef cppMem fill:#f0f9ff,stroke:#0284c7,stroke-width:2px,color:#0c4a6e; classDef rustMem fill:#fff7ed,stroke:#ea580c,stroke-width:2px,color:#7c2d12; classDef itemNode fill:#ffffff,stroke:#cbd5e1,stroke-width:1px,color:#1e293b; classDef noteNode fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,stroke-dasharray:3 3,color:#334155; class CP_Mem cppMem; class RS_Mem rustMem; class CF1,CF2,CF3,CF4,RF1,RF2,RF3,RF4 itemNode; class CF_Note,RF_Note noteNode;

3.1 C++ Coroutine Frame:動態分配、生命週期脫離與 HALO 限制

為什麼 C++ 協程 Frame 預設必須配置在 Heap?

在 C++ 中,協程函式在呼叫後會立即回傳一個 Task<T> 物件給呼叫者,而協程本體則可能被排程到背景 EventLoop 繼續執行。這意味著協程內部局部變數的生命週期,天然長於啟動它的呼叫端堆疊訊框(Caller Stack Frame)

因此,C++ 編譯器預設會在呼叫協程時,透過 operator new 在堆積上分配一塊 Coroutine Frame

flowchart TB subgraph Frame["📦 Coroutine Frame (堆積記憶體佈局)"] direction TB F1["🏷️ Function Pointers
Resume Function · Destroy Function"] F2["🎯 promise_type 實例
結果儲存 · continuation_ 鏈結 · exception_ptr"] F3["💾 Captured Parameters
傳入之函式引數 (值或參照捕獲)"] F4["🗄️ 跨越暫停點之區域變數
跨 co_await 存活之局部變數與臨時物件"] F5["🔢 Suspend Index
狀態機暫停點索引"] F1 --- F2 --- F3 --- F4 --- F5 end classDef frameBox fill:#f0f9ff,stroke:#0284c7,stroke-width:2px,color:#0c4a6e; classDef fieldNode fill:#ffffff,stroke:#94a3b8,stroke-width:1px,color:#0f172a; class Frame frameBox; class F1,F2,F3,F4,F5 fieldNode;

HALO(Heap Allocation eLision Optimization)的現實限制

C++ 標準允許編譯器在能夠證明「協程的生命週期完全嚴格包含在 Caller 生命週期之內」時,將 Coroutine Frame 內聯到 Caller 的堆疊上,消除 Heap 分配。這項技術由 Gor Nishanov 提出,正式定義於提案 P0981R0: Halo (Coroutine Heap Allocation eLision Optimization)

然而在實務上:

  1. HALO 只是 Best-Effort 最佳化,不是語言標準保證
  2. 編譯器支援不一:截至目前,GCC 尚未實作 coroutine elision;Clang 雖然有 CoroElide pass,但要求協程本體、get_return_object、awaiter 與解構邏輯必須在同一個編譯單元(TU)內被完整內聯(Inlined),一旦跨編譯單元或出現未內聯的虛擬函式呼叫,HALO 就會立即失效並退化為 Heap new
  3. 業界正在透過屬性(如 [[clang::coro_inplace_task]])推動確定性的堆疊內聯提案,但尚未正式入標。

3.2 Rust 匿名 State Machine:零 Heap 保證與 Enum 空間膨脹

編譯期確定大小的保證

Rust 的 async fn 完全不進行任何隱式的 Heap 分配。編譯器會為每個 async 區塊生成一個具體的、未命名的 impl Future 結構體(實質上是一個 enum)。

當你在 Rust 中寫下巢狀 async 呼叫時:

async fn step_one() { /* ... */ }
async fn step_two() { /* ... */ }

async fn parent_task() {
    step_one().await;
    step_two().await;
}

parent_task 的狀態機大小就是 sizeof(step_one_future)sizeof(step_two_future) 與自身局部變數聯集的總和。所有的記憶體在最外層被 tokio::spawn(或在 Stack 上定義)時一次性分配完畢,整個執行過程 0 次額外 Heap Allocation。

致命代價:Enum 記憶體浪費與 Stack Overflow 風險

因為 Rust 將所有暫停點表示為 enum 的 Variant,整個 Future 的大小取決於體積最大(Largest Variant)的那一個暫停點

async fn dangerous_task() {
    {
        let huge_buffer = [0u8; 64 * 1024]; // 64KB
        do_io(&huge_buffer).await; // 暫停點 1:佔用 64KB + overhead
    }
    {
        let small_val = 42;
        other_io(small_val).await; // 暫停點 2:此時雖然只需要 4 bytes,但 enum 依然佔用 64KB!
    }
}

若一個深層呼叫鏈中有多個含有較大緩衝區的 async fn 彼此組合,整個頂層 Future 的體積可能達到數百 KB。當在執行緒堆疊上直接傳遞時,極易引發 Stack Overflow。在 Rust 中,開發者必須具備高度意識,主動使用 Box::pin(huge_future) 將大型狀態機移至堆積。


4. 自我引用與記憶體位址穩定性

在同步函式中,變數儲存在 CPU 堆疊上,若建立一個指向區域變數的指標(如 let p = &local_buf;),函式結束前堆疊訊框不會被移動,指標永遠安全。

但在非同步世界中,協程暫停時資料必須留存於狀態機中。若協程在跨越 await 點時保留了一個指向自己內部其他欄位的參照,這個狀態機就變成了 自我引用結構(Self-Referential Struct)

flowchart LR subgraph SelfRef["📦 Self-Referential Struct (自我引用狀態機)"] direction TB Buf["💾 buf: [u8; 1024]
自身內部緩衝區 (位址 0x1000)"] Ptr["🔗 ptr: *const u8
內部指標欄位 (指向 0x1000)"] Ptr -->|指向同結構內部欄位| Buf end classDef srBox fill:#fff7ed,stroke:#ea580c,stroke-width:2px,color:#7c2d12; classDef innerNode fill:#ffffff,stroke:#cbd5e1,stroke-width:1px,color:#1e293b; class SelfRef srBox; class Buf,Ptr innerNode;

如果這個狀態機在記憶體中被移動(Move / Copy),ptr 指向的仍是舊的記憶體位址,一旦解參照就會造成記憶體損壞(Use-After-Free / Invalid Access)。

flowchart LR subgraph MoveHazard["⚠️ 自我引用記憶體移動陷阱"] direction TB Old["舊記憶體位址 0x1000
buf: [Data]
ptr: 指向 0x1000 (自身 buf)"] New["移動至新位址 0x2000 (memcpy)
buf: [Data]
ptr: 仍指向 0x1000 (懸空損壞!)"] Old -->|未固定移動| New end classDef warnBox fill:#fef2f2,stroke:#ef4444,stroke-width:2px,color:#991b1b; classDef nodeBox fill:#ffffff,stroke:#f87171,stroke-width:1px,color:#7f1d1d; class MoveHazard warnBox; class Old,New nodeBox;

4.1 C++ 的處理方式:天然的 Frame 位址穩定性

C++ 在這方面非常自然:

  1. Coroutine Frame 一旦在 Heap(或透過 HALO 分配在特定 Stack)上生成,其位址在整個協程生命週期內絕對不會移動
  2. 外部持有的 std::coroutine_handle<P> 實質上就是一個指向 Frame 的不可變裸指標(Pointer-to-Frame)。
  3. 協程內部的局部變數指針與參照天然具備位址穩定性,因此 C++ 不需要引入任何額外的型別系統標記來處理自我引用問題。

4.2 Rust 的處理方式:Pin<&mut Self>Unpin 的型別安全革命

Rust 的根本設計哲學是 所有型別預設都是可移動的(Movable by default via memcpy。為了在不破壞所有權體系的前提下支援自我引用狀態機,Rust 核心團隊(由 withoutboats 主導設計)在標準庫中引進了最精妙但也最令人頭疼的抽象:std::pin::Pin(詳見 withoutboats 的經典專文 Pin)。

// core::pin::Pin 定義
pub struct Pin<Ptr> {
    pointer: Ptr,
}
  1. Unpin auto trait:絕大多數一般 Rust 型別(i32StringVec)都自動實作了 Unpin,代表它們即便被 Pin 住也可以隨意移動。
  2. !Unpin(非非固定):編譯器生成的 async fn 狀態機自動被標記為 !Unpin
  3. 安全契約:一旦一個 !Unpin 的 Future 被包裝進 Pin<&mut F>,Rust 的型別系統便完全剝奪了獲取 &mut F 可變參照的能力(除非使用 unsafe)。沒有了 &mut F,開發者就無法呼叫 std::mem::swapstd::mem::replace 將其移出,從而在編譯期徹底杜絕了移動自我引用結構的可能性!

代價Pin 帶來了龐大的心智負擔。任何手動實作 Future、操作底層串流(Stream)或撰寫自訂組合子的開發者,都必須與 pin_projectPin<&mut Self>unsafe projection 進行艱苦的博弈。


5. 取消語義:協同式檢查 vs. Drop-to-Cancel

非同步任務的取消在分散式系統、逾時控制與競態處理(Race / Select)中無處不在。兩種語言在此處體現了「顯式協同」與「隱式結構化」的哲學對立。

5.1 C++ 協同式取消(Cooperative Cancellation)

C++20/23 採用以 std::stop_token / std::stop_source 為基礎的顯式協同取消模型:

Task<void> handle_request(std::stop_token stoken, Socket sock) {
    while (!stoken.stop_requested()) {
        auto data = co_await sock.async_read();
        if (!data) co_return;
        
        // 必須主動感知取消請求並乾淨退出
        co_await sock.async_write(process(data));
    }
}
  • 優點:協程狀態轉移永遠受控,資源清理邏輯與一般控制流完全一致,不會在任意未知的暫停點突兀暴斃。
  • 缺點:非強制性。若協程內部深層迴圈或某個 Awaiter 漏掉了檢查 stoken.stop_requested(),取消請求將被完全忽視(Hang 住)。

5.2 Rust 結構性取消:Drop-to-Cancel 與「取消安全性」陷阱

Rust 的取消機制極致簡潔有力:直接 Drop 該 Future

在 Rust 中,當 tokio::select! 的某一個分支先完成,或者 tokio::time::timeout 觸發時,尚未完成的 Future 會直接脫離作用域,觸發其解構函式(Drop::drop):

// 典型的 select! 結構
tokio::select! {
    res = read_socket_packet(&mut socket) => {
        process(res);
    }
    _ = tokio::time::sleep(Duration::from_secs(5)) => {
        println!("逾時!read_socket_packet 的 Future 直接被 Drop 清理!");
    }
}

致命暗坑:取消安全性

Drop-to-Cancel 雖然優雅,卻在非同步生態中埋下了無數難以察查的 Bug。

什麼是取消不安全(Cancellation Unsafe)? 若一個 Future 的操作跨越了多個內部 .await 暫停點,當它在中間某個點被 Drop 時,已讀取或已計算的中間狀態直接蒸發,導致資料損壞或協定不同步!

// ❌ 取消不安全範例:LinesCodec / AsyncReadExt::read_exact
async fn process_command(stream: &mut TcpStream) {
    let mut header = [0u8; 4];
    // 若 select! 在此處 read_exact 讀完 header 後、下一步讀 body 之前發生逾時並 Drop:
    stream.read_exact(&mut header).await.unwrap(); 
    
    let mut body = vec![0u8; u32::from_be_bytes(header) as usize];
    // 下一次外部再次呼叫 process_command 時,TCP stream 中的 Header 已經不見了!協定解析直接崩潰!
    stream.read_exact(&mut body).await.unwrap(); 
}

在 Rust 非同步生態中,所有函式庫(特別是 Tokio)都必須在其官方文件中嚴格標註每個 API 是否具備 Cancellation Safety。若非安全,開發者必須手動在迴圈外保存狀態,或改用背景 Task 搭配 mpsc 通道解耦。


6. 核心 I/O 模型適應性矛盾:epoll (Readiness) 與 io_uring (Completion)

作業系統核心的非同步 I/O 設計可分為兩大流派:

  1. Readiness 模型(反應器 Reactor):如 Linux epoll、macOS kqueue。核心只通知「檔案描述符何時可讀/可寫」,實際的資料搬移由應用層在就緒後呼叫 read()/write() 完成。
  2. Completion 模型(前導器 Proactor):如 Linux io_uring、Windows IOCP。應用層預先提供 Buffer 將操作提交至核心佇列,核心於背景完成資料搬移後,才通知應用層「I/O 已完成」(詳見 Linux 核心維護者 Jens Axboe 的權威白皮書 Efficient IO with io_uring)。
flowchart TD subgraph Readiness["📡 Readiness 模型 (Linux epoll / macOS kqueue)"] direction TB E1["1. 註冊 fd 至 epoll"] --> E2["2. epoll_wait 通知: fd 可讀!"] E2 --> E3["3. 應用程式呼叫 read(fd, &mut buf) 抓取資料"] end subgraph Completion["📥 Completion 模型 (Linux io_uring / Windows IOCP)"] direction TB C1["1. 應用程式提交 SQE: 請核心把資料讀入 buf 指標"] C1 --> C2["2. Linux 核心接管 Buffer,非同步進行 DMA 寫入"] C2 --> C3["3. 核心完成寫入,向 CQE 發送完成通知"] end E3 ==>|天生契合| Pull["🦀 Rust Pull 模型 (Future::poll)
短暫借用 &mut [u8],隨時可安全 Drop"] C3 ==>|天生契合| Push["⚡ C++ Push 模型 (co_await await_suspend)
Buffer 於 Frame 中位址固定,直接交給核心"] classDef rBox fill:#ecfdf5,stroke:#059669,stroke-width:2px,color:#064e3b; classDef cBox fill:#f5f3ff,stroke:#6366f1,stroke-width:2px,color:#312e81; classDef rustTarget fill:#fff7ed,stroke:#ea580c,stroke-width:2px,color:#7c2d12; classDef cppTarget fill:#f0f9ff,stroke:#0284c7,stroke-width:2px,color:#0c4a6e; classDef innerNode fill:#ffffff,stroke:#94a3b8,stroke-width:1px,color:#0f172a; class Readiness rBox; class Completion cBox; class Pull rustTarget; class Push cppTarget; class E1,E2,E3,C1,C2,C3 innerNode;

6.1 epoll 與 Rust Pull 模型的完美結合

Rust 的 poll()epoll 是一對天作之合:

  • poll() 實質上就是「嘗試讀取一次」。
  • 若得到 EAGAIN / WouldBlock,就回傳 Poll::Pending,並將 Waker 掛上 epoll
  • epoll 通知可讀時喚醒 Task,再次呼叫 poll(),執行 non-blocking read()
  • 此時 Buffer 的所有權全程在應用層堆疊上,隨時可以安全 Drop。

6.2 io_uring 與 Rust Drop-to-Cancel 的巨大災難

然而,當面對代表 Linux 未來的新一代高效能 I/O 介面 io_uring 時,Rust 的 Pull + Drop 模型遭遇到嚴重的底層架構衝突(I/O 模型相容性矛盾)

sequenceDiagram autonumber actor App as 🦀 Rust Future participant Kernel as 🐧 Linux 核心 (io_uring) participant Mem as 💾 記憶體 (Buffer: 0x1000) App->>Kernel: 提交 SQE 讀取請求 (傳入 buffer: 0x1000 指標) Note over App,Mem: 觸發 select! 逾時 ➜ Future 立即被 Drop!
Buffer 所在的 0x1000 記憶體被釋放回收 Kernel-->>Mem: 核心非同步完成 DMA 傳輸,直接寫入 0x1000 Note over Mem: 💥 Use-After-Free 記憶體毀損! (Undefined Behavior)
  1. io_uring 中,當你發起非同步讀取時,Linux 核心在操作完成前實質擁有該 Buffer 記憶體的寫入權
  2. 若在核心完成傳輸之前,外層 Rust Future 觸發了 select! 逾時被 Drop,Buffer 所在的記憶體會被立即回收甚至重新分配給其他資料結構。
  3. 稍後 Linux 核心將網路封包寫入該記憶體位址,直接造成 記憶體損壞(Memory Corruption)與 Undefined Behavior

Rust 的妥協與代價

為了解決這個問題,Rust 傳統的 AsyncRead::poll_read(&mut self, buf: &mut [u8]) 介面在 io_uring 下完全無法使用。以 tokio-uringmonoio 為代表的函式庫被迫大幅重構:

  • 放棄借用參照,全面改用 所有權轉移介面(Owned Buffer)async fn read<B: IoBufMut>(self, buf: B) -> (Result<usize>, B)
  • 建立專屬的運行時緩衝池(Buffer Pool)與內核取消佇列,強行阻止被 Drop 的 Buffer 提早釋放,帶來了顯著的複雜度與生態割裂。

6.3 C++ co_await 與 Completion 模型的天作之合

雖然 co_await 語法本身是模型無關的(既可包裝 epoll,亦可包裝 io_uring),但其 基於 Continuation-passing 的 Awaiter 機制與天然穩定的 Frame 位址,讓 C++ 與 Completion 模型(io_uring / IOCP)結合得極為自然:

// C++ 與 io_uring 天然契合
struct IoUringReadAwaiter {
    io_uring* ring;
    int fd;
    void* buf;
    size_t len;
    ssize_t cqe_res{0}; // 由 EventLoop 在收割 CQE 時填入完成結果

    bool await_ready() const noexcept { return false; }

    void await_suspend(std::coroutine_handle<> h) noexcept {
        io_uring_sqe* sqe = io_uring_get_sqe(ring);
        io_uring_prep_read(sqe, fd, buf, len, 0);
        // 將 coroutine_handle 直接當作 user_data 交給核心!
        io_uring_sqe_set_data(sqe, h.address());
        io_uring_submit(ring);
    }

    ssize_t await_resume() noexcept {
        return cqe_res;
    }
};
  1. buf 存放在穩定的 Coroutine Frame 內,位址永不位移。
  2. 提交 sqe 時,直接將 coroutine_handle 作為 user_data 傳給核心。
  3. C++ 沒有語言級隱式的 Drop-to-cancel,Buffer 不會在核心未知的情況下被自動釋放(但開發者仍需注意:若手動銷毀 Frame,必須確保核心在途 I/O 已透過 IORING_OP_ASYNC_CANCEL 取消或等待 CQE 回收)。
  4. 當核心完成 I/O 產生 cqe 時,EventLoop 取得 user_data,直接一條指令 coroutine_handle::from_address(cqe->user_data).resume() 恢復協程。
  5. 架構簡潔、零摩擦、零所有權搬移包裝,完美契合現代 Completion I/O!

7. 標準庫與生態哲學的對決

flowchart TD subgraph CPPEco["⚡ C++ 生態:底層積木 · 標準庫留白"] direction TB C_Core["C++20: 核心機制 (co_await, promise_type, handle)"] C_Std["標準庫留白 (無官方 Task 與 EventLoop)"] C_Frag["生態分散:Boost.Asio / cppcoro / libcoro / Folly"] C_Color["彩色函式痛點:多重 Task 型別互不相容"] C_Future["C++26: P2300 std::execution 統一大一統抽象"] C_Core --> C_Std --> C_Frag --> C_Color --> C_Future end subgraph RustEco["🦀 Rust 生態:核心契約 · 社群事實標準"] direction TB R_Core["Rust 1.39: core::future::Future 核心契約"] R_Tokio["Tokio 成為事實標準 Runtime"] R_Rich["繁榮相容生態 (Hyper, Axum, Tonic, Reqwest)"] R_Lockin["代價:Tokio Runtime 鎖定"] R_Color["彩色函式痛點:async fn 語法傳染性"] R_Core --> R_Tokio --> R_Rich --> R_Lockin --> R_Color end classDef c1 fill:#f0f9ff,stroke:#0284c7,stroke-width:2px,color:#0c4a6e; classDef r1 fill:#fff7ed,stroke:#ea580c,stroke-width:2px,color:#7c2d12; classDef subNode fill:#ffffff,stroke:#94a3b8,stroke-width:1px,color:#0f172a; class CPPEco c1; class RustEco r1; class C_Core,C_Std,C_Frag,C_Color,C_Future,R_Core,R_Tokio,R_Rich,R_Lockin,R_Color subNode;

7.1 C++:極簡語言機制與遲來的 std::execution (P2300)

C++ 委員會在 C++20 採取了「先提供最底層語言機制,將上層抽象留給社群探索」的策略。

  • 代價:C++20 出爐時,標準庫甚至沒有提供一個官方的 std::task<T>(僅有 C++23 補上的 std::generator<T>)。每個函式庫(Boost.Asio、Seastar、Folly、cppcoro)都各自實作了一套互不相容的 TaskEventLoop,導致生態極度碎片化。
  • 未來展望:C++26 正在推進劃時代的 P2300 std::execution(Senders / Receivers 模型),試圖為 C++ 提供統一的非同步執行拓撲、排程器抽象與演算法管線,讓協程能與 GPU 運算(CUDA/HIP)、執行緒池以及分散式排程器無縫融合。

7.2 Rust:統一的 Future 契約與 Tokio 的天下

Rust 在語言誕生之初就確立了 core::future::Future 作為唯一的通用契約。

  • 優勢:任何第三方函式庫只要針對 Future 撰寫,就能無縫組合。這造就了 Rust 繁榮且高度一致的非同步生態系(Axum, Reqwest, Tonic, Tower)。
  • 代價Runtime Lock-in。雖然理論上 Runtime 可替換(async-std, smol),但 Tokio 幾乎壟斷了生態,非 Tokio 生態的庫寸步難行。

7.3 無堆疊協程的共同宿命:彩色函式

Bob Nystrom 於 2015 年發表的傳世名文 《What Color is Your Function?》,精闢揭示了非同步語言中函式被染色的痛苦現象。這個問題並非任何單一語言的特產,而是所有無堆疊協程(Stackless Coroutines)架構與生俱來的物理約束(相較於 Go 的 Goroutine 或 Java 的 Virtual Threads 等具備獨立堆疊的綠色執行緒):

flowchart LR subgraph SyncWorld["🔵 藍色世界 (同步函式)"] direction TB S1["一般同步函式
void do_work() / fn do_work()"] S2["❌ 無法直接呼叫 co_await / .await
因為缺乏獨立堆疊,無法跨一般函式訊框暫停"] S1 --- S2 end subgraph AsyncWorld["🔴 紅色世界 (非同步協程)"] direction TB A1["非同步協程函式
Task<void> / async fn"] A2["⚡ 型別傳染性 (Viral Propagation)
呼叫端必須一路向上改寫為協程,或透過 Blocking Runner 接管"] A1 --- A2 end SyncWorld -.->|必須透過 block_on / sync_wait 橋接| AsyncWorld classDef syncBox fill:#f0f9ff,stroke:#0284c7,stroke-width:2px,color:#0c4a6e; classDef asyncBox fill:#fef2f2,stroke:#ef4444,stroke-width:2px,color:#991b1b; classDef innerN fill:#ffffff,stroke:#94a3b8,stroke-width:1px,color:#0f172a; class SyncWorld syncBox; class AsyncWorld asyncBox; class S1,S2,A1,A2 innerN;
  • Rust 的染色(語法與型別單一染色): 在 Rust 中,函式標記為 async fn 後,回傳型別被包裝為 impl Future。普通同步函式不能直接 .await 它,必須一路將上層呼叫鏈都改寫為 async fn,直到 main 函式透過 #[tokio::main] 啟動 Runtime。其優點在於所有非同步函式都共享同一個 Future 契約,彼此無縫相容。
  • C++ 的染色(型別感染與「多重色系碎片化」): 在 C++ 中,函式本體只要出現 co_await,其回傳型別就必須改寫為對應的協程型別(如 Task<T>)。普通同步函式同樣不能直接 co_await,會一路向外傳染。 更痛苦的是,由於 C++ 標準庫未統一定義 std::task<T>,C++ 出現了「多重不相容的紅色」:
    • Boost.Asio 染成了 asio::awaitable<T>
    • Folly 染成了 folly::coro::Task<T>
    • 各家開源庫自製了專屬的 Task<T> 不同庫之間的協程無法直接 co_await 彼此,工程師必須撰寫大量的轉接層(Adapters)與橋接包裝。

8. 全方位架構對比總結表

比較維度 C++20/23 協程 (Coroutines) Rust 非同步 (Async / Future)
驅動模型 Push-Driven(推動式)
基於 Continuation 與 resume() 直接喚醒
Pull-Driven(拉取式)
基於 Future::poll() 與 Reactor 輪詢
執行恢復開銷 $O(1)$ 直達中斷點;支援對稱轉移尾呼叫最佳化 $O(D)$ 每次喚醒需從最外層 Future 遞迴向下重評估
狀態機記憶體 Coroutine Frame
預設 Heap 分配;HALO 最佳化為編譯器 Best-Effort
匿名結構體 / Enum
編譯期精確確定大小;保證 0 堆積配置
記憶體空間浪費 無(每個 Frame 精確容納自身狀態) Largest Variant 膨脹(取決於最大暫停點局部變數總和)
自我引用處理 指針天然穩定
Frame 在生命週期內位址固定不變
Pin<&mut Self> 機制
型別系統靜態封裝,防止安全移動
取消機制 (Cancellation) 協同式取消std::stop_token 顯式檢查) 結構性取消(Drop-to-Cancel 隱式解構)
取消風險 容易漏檢查導致取消請求被忽略 取消不安全性(Cancellation Safety)
狀態丟失與協定損壞陷阱
I/O 模型契合度 天生契合 Completion (Proactor)
io_uring / IOCP 零摩擦結合
天生契合 Readiness (Reactor)
epoll / kqueue 完美整合;在 io_uring 遭遇架構衝突與 Buffer 生命週期矛盾
彩色函式 (Function Coloring) 存在(多重色系碎片化)
回傳型別被強制改為 Task<T>,且跨庫型別互不相容
存在(單一色系統一)
async fn 語法傳染,但全生態共享 Future 契約
標準庫抽象 底層積木(標準庫留白;C++26 std::execution 整合中) 統一核心契約core::future::Future 全生態通用)
生態繁榮度 各家自立門戶(Boost.Asio、Folly、自製輕量 runtime) Tokio 高度統治(生態一致性極高,但有 Runtime 鎖定)

9. 系統架構師的技術選型指南

在面對實際工程專案時,如何在這兩種架構之間做出明智的技術選型?

推薦選擇 C++20/23 協程的場景:

  1. 極致硬體控制與自製輕量 Runtime:如我們在前文介紹的 C++23 AI Gateway,不依賴任何外部框架,以數百行程式碼即可基於 epollio_uring 打造專屬的極簡非同步核心,二進位檔體積僅數 MB。
  2. 深度依賴 Linux io_uring / Windows IOCP 的儲存與網路底座:在需要 Completion 模型與 Buffer 生命週期嚴密控制的高效能儲存引擎(如高效能 KV 資料庫、分散式檔案系統)中,C++ 協程能提供最低的抽象開銷。
  3. 異質運算管線(Heterogeneous Computing):當非同步任務需要跨越 CPU 執行緒、GPU Stream(CUDA/Vulkan)與專用加速器時,C++ 的 Push 模型與未來的 P2300 Senders/Receivers 能提供更自然的排程拓撲。

推薦選擇 Rust Async 的場景:

  1. 雲原生微服務、API 閘道與標準網路應用:面對典型基於 epoll 的 HTTP/gRPC/WebSocket 服務,Rust 擁有成熟度無可匹敵的 Tokio/Axum/Tonic 生態,開發效率與社群函式庫支援遠勝 C++。
  2. 對記憶體安全與並發安全有絕對嚴格要求的系統:Rust 的型別系統在編譯期保證了非同步跨執行緒傳遞的 Send/Sync 安全性,徹底消除了 Data Race 與未定義行為。
  3. 嵌入式無堆積(no_std / Bare-Metal)環境:在沒有動態記憶體配置器(Heap Allocator)的微控制器上,Rust Future 的編譯期確定大小與零 Heap 分配保證,是 C++ 協程難以企及的巨大優勢。

10. 結語

C++20/23 協程與 Rust Async/Future 代表了系統級非同步程式設計的兩座不同巔峰:

  • C++ 選擇了「機制優先(Mechanism over Policy)」與「極致靈活」,賦予工程師底層指標與控制流的完全掌控權,在 Completion I/O 與自訂排程上展現出純粹的高效與優雅,但代價是生態碎片化與需要工程師自行維護生命週期安全。
  • Rust 選擇了「型別安全優先」與「結構化約束」,以 Future::pollPin 為基石,構建了一個零 Heap 分配、記憶體絕對安全的非同步王國,但在取消安全與 io_uring 等新興架構上面臨了設計哲學的挑戰。

深刻理解兩者在底層狀態機、記憶體佈局與 I/O 模型的取捨,不僅能讓我們在撰寫非同步程式碼時洞悉每一行 .await / co_await 背後的硬體代價,更能幫助我們在未來的系統架構設計中,做出最精準的工程決策!


11. 延伸閱讀與經典文獻

經典設計理論

C++ 協程與標準演進

Rust 非同步與安全性契約

作業系統 I/O 架構