featured.svg

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

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

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

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

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

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

push-vs-pull-coroutines.svg
  • 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)」出最新進度。

核心執行模型深度剖析

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,在保持常數呼叫堆疊深度的同時完成協程切換。

symmetric-transfer.svg

實戰案例:AI Gateway 中的 Push-Driven 協程執行時序

為了具體理解 Push-Driven 模型的運作細節,以我們先前介紹的 C++23 AI Gateway 專案為例:當 Gateway 處理來自下游客戶端的 SSE 串流轉發請求時,從 Linux epoll 事件就緒、伺服器協程解析請求、啟動上游客戶端子協程、到接收上游 LLM(如 Ollama / vLLM)Token 串流並透過對稱轉移結束協程,整體控制流時序如下:

cpp-push-sequence.svg

時序關鍵解構:

  1. 精確推動喚醒(Push Resumption):當 epoll_wait 捕獲 upstream_fd 的 I/O 事件時,EventLoop 透過暫停時註冊的 coroutine_handle,直接呼叫 h_child.resume()。CPU 暫存器瞬間切換回 stream_request 的 Frame,跳過整個上層呼叫樹的遍歷($O(1)$ 開銷)。
  2. 對稱轉移無縫交接(Symmetric Transfer):父協程喚醒子協程、以及子協程在 final_suspend() 結束後喚醒父協程時,皆透過回傳目標 handle 執行 tail jump,控制權直接「推(Push)」給下一個協程,既不產生遞迴呼叫堆疊,也無需回流至 EventLoop 重新排程。

Rust Async:基於 Future::poll、Context 與 Waker 的輪詢機制

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(),直到抵達就緒節點。

實戰案例:Tokio / Rust 中的 Pull-Driven 協程執行時序

為了與 C++ Push 模型進行對稱比較,我們來看在 Rust(以 Tokio 生態為例)處理相同的 HTTP 請求轉發與 SSE 串流時,Pull-Driven 模型的完整執行時序:

rust-pull-sequence.svg

時序關鍵解構:

  1. 二階段間接喚醒(Reactor ➜ Queue ➜ Executor):當 I/O 事件就緒時,Reactor 無法直接跳入中斷點,而是透過 waker.wake() 將整個 Task 排入執行佇列,等待 Worker Thread 領取。
  2. 自頂向下重新評估($O(D)$ Top-down Re-polling):每一次喚醒,Executor 都必須從最外層的 Root Future 開始呼叫 poll(cx),並由狀態機依序向下轉發至內層 Future,直到抵達就緒節點。
  3. 無棧回退(Unwinding on Pending):遇到未就緒的 I/O 時,狀態機逐層回傳 Poll::Pending 讓出執行緒;完成時則逐層回傳 Poll::Ready(T),無需維護顯式的 Continuation 指標鏈。

執行模型對比:精確恢復 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 編譯期自動推導保證執行緒安全

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

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

frame-vs-state-machine.svg

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

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

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

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

coroutine-frame-layout.svg

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]])推動確定性的堆疊內聯提案,但尚未正式入標。

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) 將大型狀態機移至堆積。

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

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

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

self-referential-struct.svg

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

self-reference-move-hazard.svg

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

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

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

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 型別(i32、String、Vec)都自動實作了 Unpin,代表它們即便被 Pin 住也可以隨意移動。
  2. !Unpin(非非固定):編譯器生成的 async fn 狀態機自動被標記為 !Unpin。
  3. 安全契約:一旦一個 !Unpin 的 Future 被包裝進 Pin<&mut F>,Rust 的型別系統便完全剝奪了獲取 &mut F 可變參照的能力(除非使用 unsafe)。沒有了 &mut F,開發者就無法呼叫 std::mem::swap 或 std::mem::replace 將其移出,從而在編譯期徹底杜絕了移動自我引用結構的可能性!

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

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

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

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 住)。

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 通道解耦。

核心 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)。
readiness-vs-completion.svg

epoll 與 Rust Pull 模型的天然契合

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

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

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

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

io-uring-drop-hazard.svg
  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-uring、monoio 為代表的函式庫被迫大幅重構:

  • 放棄借用參照,全面改用 所有權轉移介面(Owned Buffer): async fn read<B: IoBufMut>(self, buf: B) -> (Result<usize>, B)
  • 建立專屬的運行時緩衝池(Buffer Pool)與內核取消佇列,強行阻止被 Drop 的 Buffer 提早釋放,帶來了顯著的複雜度與生態割裂。
  1. 核心直接操作 User Space Buffer:io_uring 是 Proactor 完成模型。當你提交 io_uring_prep_read 時,核心持有 Buffer 指標並開始背景 DMA 傳輸。
  2. Drop-to-Cancel 導致 Use-After-Free 災難:若 Future 在 I/O 尚未完成時被 tokio::select! Drop 銷毀,Buffer 所在的記憶體會被立刻釋放或重用!隨後 Linux 核心完成 DMA 寫入時,將直接覆寫已被回收的記憶體區域,引發嚴重的記憶體毀損或安全漏洞。
  3. Rust 生態的妥協與修補:
    • 傳統方案:強制將 Buffer 搬移至 Heap(Box / Arc)並將所有權轉移給 I/O 驅動,待完成時再傳回(帶來 Heap 分配開銷與借用檢查摩擦)。
    • 現代方案:如 compio 框架,不得不放棄標準 Future 抽象,改為專屬的 Proactor 運作模式。

io_uring 與 C++ Push 協程的天然共生

相反地,C++20/23 協程的設計原生完美適配 io_uring:

// 典型的 C++ io_uring Awaiter 模式
struct UringReadAwaiter {
    io_uring* ring;
    int fd;
    void* buf;
    unsigned nbytes;
    int cqe_res = 0;

    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, nbytes, 0);
        // 直接將協程 handle 位址作為 user_data
        io_uring_sqe_set_data(sqe, h.address());
        io_uring_submit(ring);
    }

    int await_resume() const 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 模型。

標準庫與生態哲學的對決

cpp-vs-rust-ecosystems.svg

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

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

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

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

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

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

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

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

viral-function-colors.svg
  • 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)與橋接包裝。

全方位架構對比總結表

比較維度 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 鎖定)

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

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

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

  1. 精確硬體控制與自製輕量 Runtime:如我們在前文介紹的 C++23 AI Gateway,不依賴任何外部框架,以數百行程式碼即可基於 epoll 或 io_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++ 協程難以企及的巨大優勢。

結語

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

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

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

延伸閱讀與經典文獻

經典設計理論

C++ 協程與標準演進

Rust 非同步與安全性契約

作業系統 I/O 架構