在現代系統級程式設計中,面對高並發、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語法,以標準庫核心 traitcore::future::Future為契約,配合 Tokio、smol 等社群 runtime,成為構建雲原生網路基礎設施的熱門選擇。
表面上看,兩者都提供了直觀的 co_await / .await 語法,讓開發者能以撰寫線性同步程式碼的思維來處理複雜的非同步流程。然而,在語法糖的表象之下,C++ 與 Rust 的底層架構、記憶體佈局、執行調度模型與哲學取捨截然相反。
本文將從計算機系統底層與語言編譯器實作的角度,深度拆解這兩大現代系統級語言在非同步設計上的核心分歧。
1. 架構全景:Push-Driven 與 Pull-Driven 的核心對決
理解兩者差異的第一把鑰匙,在於其驅動協程狀態機前進的控制流方向:
協程暫停,儲存狀態至 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,在保持常數呼叫堆疊深度的同時完成協程切換。
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::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)。當網路封包抵達時:
epoll_wait喚醒 Reactor 執行緒。- Reactor 找到對應的
Waker並呼叫waker.wake()。 Waker將對應的Task(通常由Arc<Task>包裝)推入 Tokio Executor 的排程佇列(Run Queue)。- Worker Thread 提取 Task,呼叫最外層頂級 Future 的
poll()。 - 頂級 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 非同步架構最具實質效能與資源差異的戰場。
可透過 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:
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)。
然而在實務上:
- HALO 只是 Best-Effort 最佳化,不是語言標準保證。
- 編譯器支援不一:截至目前,GCC 尚未實作 coroutine elision;Clang 雖然有
CoroElidepass,但要求協程本體、get_return_object、awaiter 與解構邏輯必須在同一個編譯單元(TU)內被完整內聯(Inlined),一旦跨編譯單元或出現未內聯的虛擬函式呼叫,HALO 就會立即失效並退化為 Heapnew。 - 業界正在透過屬性(如
[[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):
自身內部緩衝區 (位址 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)。
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++ 在這方面非常自然:
- Coroutine Frame 一旦在 Heap(或透過 HALO 分配在特定 Stack)上生成,其位址在整個協程生命週期內絕對不會移動。
- 外部持有的
std::coroutine_handle<P>實質上就是一個指向 Frame 的不可變裸指標(Pointer-to-Frame)。 - 協程內部的局部變數指針與參照天然具備位址穩定性,因此 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,
}Unpinauto trait:絕大多數一般 Rust 型別(i32、String、Vec)都自動實作了Unpin,代表它們即便被 Pin 住也可以隨意移動。!Unpin(非非固定):編譯器生成的async fn狀態機自動被標記為!Unpin。- 安全契約:一旦一個
!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>與unsafeprojection 進行艱苦的博弈。
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 設計可分為兩大流派:
- Readiness 模型(反應器 Reactor):如 Linux
epoll、macOSkqueue。核心只通知「檔案描述符何時可讀/可寫」,實際的資料搬移由應用層在就緒後呼叫read()/write()完成。 - Completion 模型(前導器 Proactor):如 Linux
io_uring、WindowsIOCP。應用層預先提供 Buffer 將操作提交至核心佇列,核心於背景完成資料搬移後,才通知應用層「I/O 已完成」(詳見 Linux 核心維護者 Jens Axboe 的權威白皮書 Efficient IO with io_uring)。
短暫借用 &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-blockingread()。- 此時 Buffer 的所有權全程在應用層堆疊上,隨時可以安全 Drop。
6.2 io_uring 與 Rust Drop-to-Cancel 的巨大災難
然而,當面對代表 Linux 未來的新一代高效能 I/O 介面 io_uring 時,Rust 的 Pull + Drop 模型遭遇到嚴重的底層架構衝突(I/O 模型相容性矛盾):
Buffer 所在的 0x1000 記憶體被釋放回收 Kernel-->>Mem: 核心非同步完成 DMA 傳輸,直接寫入 0x1000 Note over Mem: 💥 Use-After-Free 記憶體毀損! (Undefined Behavior)
- 在
io_uring中,當你發起非同步讀取時,Linux 核心在操作完成前實質擁有該 Buffer 記憶體的寫入權。 - 若在核心完成傳輸之前,外層 Rust Future 觸發了
select!逾時被 Drop,Buffer 所在的記憶體會被立即回收甚至重新分配給其他資料結構。 - 稍後 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 提早釋放,帶來了顯著的複雜度與生態割裂。
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;
}
};buf存放在穩定的 Coroutine Frame 內,位址永不位移。- 提交
sqe時,直接將coroutine_handle作為user_data傳給核心。 - C++ 沒有語言級隱式的 Drop-to-cancel,Buffer 不會在核心未知的情況下被自動釋放(但開發者仍需注意:若手動銷毀 Frame,必須確保核心在途 I/O 已透過
IORING_OP_ASYNC_CANCEL取消或等待 CQE 回收)。 - 當核心完成 I/O 產生
cqe時,EventLoop 取得user_data,直接一條指令coroutine_handle::from_address(cqe->user_data).resume()恢復協程。 - 架構簡潔、零摩擦、零所有權搬移包裝,完美契合現代 Completion I/O!
7. 標準庫與生態哲學的對決
7.1 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)、執行緒池以及分散式排程器無縫融合。
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 等具備獨立堆疊的綠色執行緒):
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)與橋接包裝。
- Boost.Asio 染成了
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 協程的場景:
- 極致硬體控制與自製輕量 Runtime:如我們在前文介紹的 C++23 AI Gateway,不依賴任何外部框架,以數百行程式碼即可基於
epoll或io_uring打造專屬的極簡非同步核心,二進位檔體積僅數 MB。 - 深度依賴 Linux
io_uring/ Windows IOCP 的儲存與網路底座:在需要 Completion 模型與 Buffer 生命週期嚴密控制的高效能儲存引擎(如高效能 KV 資料庫、分散式檔案系統)中,C++ 協程能提供最低的抽象開銷。 - 異質運算管線(Heterogeneous Computing):當非同步任務需要跨越 CPU 執行緒、GPU Stream(CUDA/Vulkan)與專用加速器時,C++ 的 Push 模型與未來的 P2300 Senders/Receivers 能提供更自然的排程拓撲。
推薦選擇 Rust Async 的場景:
- 雲原生微服務、API 閘道與標準網路應用:面對典型基於
epoll的 HTTP/gRPC/WebSocket 服務,Rust 擁有成熟度無可匹敵的 Tokio/Axum/Tonic 生態,開發效率與社群函式庫支援遠勝 C++。 - 對記憶體安全與並發安全有絕對嚴格要求的系統:Rust 的型別系統在編譯期保證了非同步跨執行緒傳遞的
Send/Sync安全性,徹底消除了 Data Race 與未定義行為。 - 嵌入式無堆積(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::poll與Pin為基石,構建了一個零 Heap 分配、記憶體絕對安全的非同步王國,但在取消安全與io_uring等新興架構上面臨了設計哲學的挑戰。
深刻理解兩者在底層狀態機、記憶體佈局與 I/O 模型的取捨,不僅能讓我們在撰寫非同步程式碼時洞悉每一行 .await / co_await 背後的硬體代價,更能幫助我們在未來的系統架構設計中,做出最精準的工程決策!
11. 延伸閱讀與經典文獻
經典設計理論
- Bob Nystrom (2015): What Color is Your Function?
- withoutboats (2019): Why Async Rust?
C++ 協程與標準演進
- Lewis Baker: Coroutine Theory 系列專文與 C++20 Coroutines: Understanding Symmetric Transfer
- Gor Nishanov (2018): P0981R0: Halo: Coroutine Heap Allocation eLision Optimization
- WG21 提案: P2300:
std::execution(Senders/Receivers)
Rust 非同步與安全性契約
- withoutboats (2018): Pin
- Rust 官方文件:
std::pin::Pin - Tokio 官方教學: Cancellation Safety in
tokio::select!
作業系統 I/O 架構
- Jens Axboe (2019): Efficient IO with io_uring (PDF)