深入解構非同步核心:C++20/23 協程與 Rust Async/Future 的設計哲學與架構差異
從 Push-driven 與 Pull-driven 模型、記憶體佈局、取消語義、自我引用位址到 io_uring / epoll I/O 架構適應矛盾的全面對比
在現代系統級程式設計中,面對高並發、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 的底層架構、記憶體佈局、執行調度模型與哲學取捨截然相反 。
本文將從計算機系統底層與語言編譯器實作的角度,深度拆解這兩大現代系統級語言在非同步設計上的核心分歧。
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::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 非同步架構最具實質效能與資源差異的戰場。
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) 。
然而在實務上:
HALO 只是 Best-Effort 最佳化,不是語言標準保證 。
編譯器支援不一 :截至目前,GCC 尚未實作 coroutine elision;Clang 雖然有 CoroElide pass,但要求協程本體、get_return_object、awaiter 與解構邏輯必須在同一個編譯單元(TU)內被完整內聯(Inlined) ,一旦跨編譯單元或出現未內聯的虛擬函式呼叫,HALO 就會立即失效並退化為 Heap new。
業界正在透過屬性(如 [[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 = [0 u8 ; 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++ 在這方面非常自然:
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 ,
}
Unpin auto 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> 與 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 = [0 u8 ; 4 ];
// 若 select! 在此處 read_exact 讀完 header 後、下一步讀 body 之前發生逾時並 Drop:
stream.read_exact(& mut header).await .unwrap();
let mut body = vec! [0 u8 ; 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、macOS kqueue。核心只通知「檔案描述符何時可讀/可寫 」,實際的資料搬移由應用層在就緒後呼叫 read()/write() 完成。
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)
在 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. 標準庫與生態哲學的對決
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)都各自實作了一套互不相容的 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 等具備獨立堆疊的綠色執行緒):
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 協程的場景:
極致硬體控制與自製輕量 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. 延伸閱讀與經典文獻
經典設計理論
C++ 協程與標準演進
Rust 非同步與安全性契約
作業系統 I/O 架構
使用 C++23 與協程打造極簡高效的 OpenAI 相容 AI Gateway
深入剖析 Stackless 協程、Linux epoll 非同步架構、C++23 現代化與 SSE 串流實戰
在當今生成式 AI 與 AI coding agent(例如 Claude Code、Aider 等 agent,或透過 OpenAI SDK / curl 等客戶端)蓬勃發展的時代,開發者與企業內部往往需要調度多個不同的模型後端——從本地執行的 Ollama、vLLM、TGI,到雲端 API。為了提供統一的存取介面、負載平衡、容錯重試以及模型名稱映射,AI Gateway(人工智慧閘道) 扮演著關鍵的中樞角色。
然而,市面上許多閘道方案往往依賴體積龐大的執行環境(如 Python 或 Node.js)或是封裝複雜的 heavy-weight 框架,不僅啟動與記憶體佔用可觀,在處理高並發 Server-Sent Events (SSE) 串流時也常帶來額外的延遲與 context switch 開銷。
這篇文章將完整分享我們最近開源的 C++23 AI Gateway 專案——一個基於 C++23 Stackless 協程、Linux epoll、零重型框架相依,並採用現代 C++ 套件管理器 Cabin 構建的極簡高效 OpenAI 相容閘道服務。
1. 動機與設計哲學:為何選擇 C++23 與 Cabin?
在構想這個閘道時,我們設定了幾個核心設計目標:
極致輕量與零框架相依(Zero-Framework Overhead) :不引入 Boost.Asio 等重量級網路庫,而是直接基於 Linux 原生 epoll 與 non-blocking socket 打造專屬的非同步協程執行期(runtime),將二進位檔體積與執行期記憶體控制在數 MB 以內。
直觀清晰的協程代碼(Async/Await Syntax) :透過 C++20/C++23 的 co_await 與 Task<T>,讓非同步 I/O 與網路串流轉發像同步程式碼一樣線性流暢,徹底告別傳統 callback hell。
充分利用 C++23 語言與標準庫新特性 :廣泛使用 std::expected 與 monadic 操作進行零成本錯誤處理、以 std::string_view::contains 簡化字串比對,並以 std::unreachable() 消除無效分支、輔助編譯器最佳化。
無縫相容 OpenAI API 生態 :完整支援 /v1/chat/completions、/v1/completions、/v1/embeddings、/v1/models 與 /healthz,無論是官方 OpenAI Python/Node SDK 或是 curl 都能直接隨插即用。
低延遲 SSE 串流 Pass-Through :針對大語言模型(LLM)的 token 串流輸出(stream: true),實現零應用層堆積分配的即時 Pass-Through 轉發,並原生支援新一代推理模型(Reasoning Models)的思考鏈串流。
現代化建置體驗 :使用如 Rust Cargo 般簡潔好用的 Cabin (cabin.toml) 管理依賴(nlohmann_json、spdlog、picohttpparser、catch2),告別繁瑣的 CMake 配置地獄。
flowchart TD
Client["📱 Downstream Client OpenAI SDK / Agent CLI / curl"]
subgraph Gateway["⚡ C++23 AI Gateway (Port 8080)"]
direction TB
EL["🔄 Linux epoll EventLoop Task<T> 協程調度 · 最小堆定時器"]
Parser["⚡ picohttpparser 零拷貝解析 HTTP/1.1 Server · Chunked StreamWriter"]
Router["🧭 Model Router & Load Balancer std::expected · 萬用字元 · 加權輪詢 · 容錯重試"]
ClientCore["🌐 Async HTTP Client 即時 SSE Pass-Through · 16MB 緩衝防禦"]
EL -->|非同步 I/O 排程| Parser
Parser -->|提取模型與路由請求| Router
Router -->|分發至目標上游節點| ClientCore
end
subgraph Upstreams["🖥️ Upstream Inference Clusters"]
direction TB
Ollama["🦙 Ollama (Local) 127.0.0.1:11434 (gemma4:26b / llama3.3)"]
VLLM["🚀 vLLM / TGI 10.0.0.5:8000"]
Cloud["☁️ Cloud API Fallback api.openai.com"]
end
Client ==>|POST /v1/chat/completions| Parser
ClientCore ==>|連線轉發請求| Upstreams
Upstreams -.->|SSE Token Chunks 串流| ClientCore
ClientCore -.->|即時 Pass-Through| Client
classDef clientNode fill:#e0f2fe,stroke:#0284c7,stroke-width:2px,color:#0c4a6e;
classDef gwNode fill:#f5f3ff,stroke:#6366f1,stroke-width:2px,color:#312e81;
classDef upNode fill:#ecfdf5,stroke:#059669,stroke-width:2px,color:#064e3b;
class Client clientNode;
class EL,Parser,Router,ClientCore gwNode;
class Ollama,VLLM,Cloud upNode;
style Gateway fill:#faf5ff,stroke:#a5b4fc,stroke-width:2px,stroke-dasharray:4 4,color:#312e81;
style Upstreams fill:#f0fdf4,stroke:#6ee7b7,stroke-width:2px,stroke-dasharray:4 4,color:#064e3b;
2. C++20/C++23 協程核心觀念深度剖析
C++ 協程是現代 C++ 中最強大但門檻也最高的語言特性之一。要寫出正確、高效且無記憶體洩漏的非同步 runtime,必須深刻理解其底層機制。
2.1 Stackless 協程與 Coroutine Frame
C++ 採用的是 Stackless (無堆疊) 協程 ,這與 Go 的 goroutine 或 Lua 協程 (Stackful) 有本質區別:
Stackful 協程 :每個協程擁有自己獨立預先分配的呼叫堆疊(例如 2KB–8KB),可以在任何深度的巢狀函式呼叫中暫停。但代價是較高的記憶體開銷與複雜的堆疊切換組合語言程式碼。
Stackless 協程 :協程本身被編譯器轉換為一個 狀態機 (State Machine) 。協程的暫停點(co_await、co_yield、co_return)必須位於協程函式本體之內(可位於巢狀的迴圈、條件分支與區塊中);若某個輔助函式需要暫停,它本身必須也是協程,無法跨越一般非協程函式呼叫邊界暫停。當協程暫停時,它的局部變數與執行狀態保存在堆積(heap)上分配的 Coroutine Frame (協程訊框) 中。
flowchart TD
subgraph CoroutineFrame["📦 Coroutine Frame (堆積記憶體佈局)"]
direction TB
Header["🏷️ Function Pointers & State Resume Fn · Destroy Fn · Suspend Index"]
PromiseObj["🎯 promise_type (TaskPromise<T>) continuation_ · exception_ · result_"]
ArgsLocals["💾 Parameters & Local Variables req, buffer, total_read, sock..."]
end
Handle["🔗 std::coroutine_handle<promise_type> (非擁有指標,指向 Frame 起始位址)"]
TaskObj["🛡️ Task<T> (RAII 封裝) 持有 handle_ · 解構時 handle_.destroy()"]
Handle -->|指向| Header
TaskObj -->|包裝| Handle
classDef frameNode fill:#f8fafc,stroke:#475569,stroke-width:1.5px,color:#0f172a;
classDef handleNode fill:#e0f2fe,stroke:#0284c7,stroke-width:2px,color:#0c4a6e;
classDef taskNode fill:#ecfdf5,stroke:#059669,stroke-width:2px,color:#064e3b;
class Header,PromiseObj,ArgsLocals frameNode;
class Handle handleNode;
class TaskObj taskNode;
style CoroutineFrame fill:#f1f5f9,stroke:#94a3b8,stroke-width:2px,stroke-dasharray:4 4,color:#0f172a;
HALO 最佳化(Heap Allocation eLision Optimization)
雖然 Coroutine Frame 預設透過 operator new 在堆積上分配,但在編譯器最佳化(如 Clang 的 CoroElide Pass,需開啟 -O2/-O3 且協程與呼叫端可被內聯於同一編譯單元)下,若編譯器能證明協程的生命週期嚴格包含在呼叫者(caller)之內,就有機會觸發 HALO(堆積分配消除最佳化) ,直接將 Coroutine Frame 內聯到 caller 的堆疊上,達到零堆積分配開銷。需要注意的是,HALO 目前屬於編譯器的 best-effort 最佳化而非 C++ 標準保證(GCC 目前尚未實作 coroutine heap elision,業界亦正透過如 [[clang::coro_inplace_task]] 等屬性推動確定性 elision 提案)。
2.2 協程四大核心元件
C++ 協程基礎設施主要由以下四個元件相互協同運作:
promise_type :協程內部的狀態控制器與結果接收者。它決定協程啟動時是否立即暫停(initial_suspend)、結束時如何清理(final_suspend)、如何捕獲返回值(return_value / return_void)與處理未捕獲異常(unhandled_exception)。
std::coroutine_handle<P> :一個輕量級、型別安全的裸指標,指向 Coroutine Frame。可用來執行 resume()、destroy()、檢查 done(),以及透過 handle.promise() 存取關聯的 promise 物件。
Coroutine Frame :編譯器在背後自動生成的資料結構,存放狀態機索引、函式參數、局部變數與 promise 實例。
Awaiter(等待器) :任何實作了 Awaiter 概念 的物件,包含三個核心函式:
bool await_ready():回傳 true 表示結果已備妥,不暫停直接繼續;回傳 false 則進入暫停流程。
auto await_suspend(coroutine_handle<>):當協程準備暫停時被呼叫(僅在 await_ready() 回傳 false 時),可將當前 handle 註冊至 event loop 或傳遞給下一個排程者。
decltype(auto) await_resume():當協程被喚醒時被呼叫,其回傳值即為 co_await expr 的運算式結果。
2.3 為何將 Return Object (Task<T>) 與 promise_type 解耦?
在設計非同步協程庫時,初學者常困惑:為什麼 Task<T> 不直接就是 promise_type,而要分開宣告?
將 Task<T> 與 promise_type 解耦是現代 C++ 協程架構的標準範式,背後有三大關鍵考量:
RAII 資源生命週期管理 :promise_type 存活於 Coroutine Frame 內部,它無法安全管理外部呼叫者的擁有權。Task<T> 是一個 純移動 (move-only) 的外部 handle 物件。當 Task<T> 超出作用域或被解構時,它的解構子可以明確且安全地呼叫 handle_.destroy() 釋放 Coroutine Frame,防止懸空指標與記憶體洩漏。
公開 API 與內部狀態隔離 :呼叫者只需要關心 co_await task、release()、valid() 等公開介面,不需要看到內部的 continuation 鏈結指標(continuation_)、異常指標(exception_)或底層 variant 結果。
零拷貝傳值與單次消費意圖 :透過將 operator co_await() 限定為 rvalue 參照(operator co_await() &&)並刪除 lvalue 運算子,能強制要求 co_await std::move(task),在語法層面明確表達單次消費語意,防止意外重複排程。
2.4 核心實作:Task<T> 與 from_promise(*this)
以下是我們專案中 include/aigw/coroutine/task.hpp 的精華實作(包含 Task<T> 與針對 Task<void> 的特化支援):
#pragma once
#include <coroutine>
#include <exception>
#include <stdexcept>
#include <utility>
#include <variant>
namespace aigw:: coro {
template < typename T = void >
class [[nodiscard]] Task ;
namespace detail {
struct TaskPromiseBase {
std:: coroutine_handle<> continuation_{nullptr };
std:: exception_ptr exception_{nullptr };
struct FinalAwaiter {
bool await_ready () const noexcept { return false; }
template < typename PromiseType>
std:: coroutine_handle<> await_suspend(std:: coroutine_handle< PromiseType> h) noexcept {
// 當協程執行完畢,若有等待中的父協程,直接對稱轉移 (Symmetric Transfer) 喚醒父協程
if (h.promise().continuation_) {
return h.promise().continuation_;
}
return std:: noop_coroutine();
}
void await_resume () noexcept {}
};
// Lazy 模式:建立協程時先暫停,直到被 co_await 時才開始執行
std:: suspend_always initial_suspend() noexcept { return {}; }
FinalAwaiter final_suspend () noexcept { return {}; }
void unhandled_exception () noexcept { exception_ = std:: current_exception(); }
};
template < typename T>
struct TaskPromise : TaskPromiseBase {
std:: variant< std:: monostate, T> result_;
Task< T> get_return_object() noexcept ;
template < typename U>
requires std:: convertible_to< U, T>
void return_value(U&& value) {
result_.template emplace< T> (std:: forward< U> (value));
}
T& result() {
if (exception_) {
std:: rethrow_exception(exception_);
}
return std:: get< T> (result_);
}
};
// Task<void> 特化:無需儲存回傳值,提供 return_void()
template <>
struct TaskPromise < void > : TaskPromiseBase {
Task< void > get_return_object() noexcept ;
void return_void () noexcept {}
void result () {
if (exception_) {
std:: rethrow_exception(exception_);
}
}
};
} // namespace detail
template < typename T>
class [[nodiscard]] Task {
public :
using promise_type = detail:: TaskPromise< T> ;
using handle_type = std:: coroutine_handle< promise_type> ;
Task() noexcept : handle_(nullptr ) {}
explicit Task (handle_type handle) noexcept : handle_(handle) {}
// Move-only 語意,嚴禁拷貝
Task(const Task& ) = delete ;
Task& operator = (const Task& ) = delete ;
Task(Task&& other) noexcept : handle_(std:: exchange(other.handle_, nullptr )) {}
Task& operator = (Task&& other) noexcept {
if (this != & other) {
if (handle_) handle_.destroy();
handle_ = std:: exchange(other.handle_, nullptr );
}
return * this ;
}
~ Task() {
if (handle_) {
handle_.destroy();
}
}
struct Awaiter {
handle_type handle_;
bool await_ready () const noexcept {
return ! handle_ || handle_.done();
}
std:: coroutine_handle<> await_suspend(std:: coroutine_handle<> awaiting_coroutine) noexcept {
// 將等待者 (caller) 的 handle 記錄進 promise,以便執行完畢時回調
handle_.promise().continuation_ = awaiting_coroutine;
return handle_; // 對稱轉移執行子協程
}
decltype (auto ) await_resume() {
if (! handle_) {
throw std:: runtime_error("Attempted to await invalid coroutine task" );
}
return handle_.promise().result();
}
};
auto operator co_await () const & = delete ;
auto operator co_await () && noexcept {
return Awaiter{handle_};
}
handle_type release () noexcept { return std:: exchange(handle_, nullptr ); }
private :
handle_type handle_;
};
namespace detail {
// 透過 std::coroutine_handle::from_promise(*this) 將 promise 反向轉換為型別安全的 handle
template < typename T>
Task< T> TaskPromise< T>:: get_return_object() noexcept {
return Task< T> {std:: coroutine_handle< TaskPromise< T>>:: from_promise(* this )};
}
inline Task< void > TaskPromise< void >:: get_return_object() noexcept {
return Task< void > {std:: coroutine_handle< TaskPromise< void >>:: from_promise(* this )};
}
} // namespace detail
} // namespace aigw::coro
當一個協程函式被呼叫時:
編譯器在堆積上分配 Coroutine Frame,並構造 TaskPromise<T>。
呼叫 promise.get_return_object(),內部利用 std::coroutine_handle<TaskPromise<T>>::from_promise(*this) 取得指向當前 Frame 的 handle,封裝成 Task<T> 並立即回傳給呼叫端。
由於 initial_suspend() 回傳 std::suspend_always,協程會先停在起始處,直到呼叫端執行 co_await task 時才啟動執行。
3. AI Gateway 架構設計與 C++23 現代化實作
有了堅固的協程基礎,接下來我們看看 AI Gateway 如何構建高性能的 HTTP 轉發服務,並深度結合 C++23 的語言特性。
3.1 自行實作 Linux epoll EventLoop 與非同步 Awaiter
我們在 include/aigw/coroutine/event_loop.hpp 中實作了單執行緒非阻塞的 EventLoop,結合 Linux epoll 與 eventfd 喚醒機制,並維護一個以 std::priority_queue 搭配自訂比較器(std::greater)實作的最小堆定時器佇列:
struct AsyncReadable {
int fd;
EventLoop* loop{nullptr };
bool await_ready () const noexcept { return false; }
void await_suspend (std:: coroutine_handle<> handle) {
if (! loop) loop = EventLoop:: current();
if (loop) {
loop-> register_read(fd, handle);
} else {
throw std:: runtime_error("AsyncReadable awaited outside EventLoop thread" );
}
}
void await_resume () const noexcept {}
};
當呼叫 async_read 時,若 read() 回傳 EAGAIN 或 EWOULDBLOCK,協程會透過 co_await AsyncReadable{fd} 自動將自身暫停並向 epoll 註冊 EPOLLIN 事件;當核心通知 socket 可讀時,EventLoop 便立即將協程 handle 推入 ready_queue_ 恢復執行,全程零 CPU 忙碌等待(busy-waiting)。
3.2 picohttpparser 零記憶體拷貝 HTTP 解析
為了達到最高的解析速度,我們採用了知名且極簡的 C 語言 HTTP 解析函式庫 picohttpparser 。
在解析 HTTP Header 時,picohttpparser 直接回傳指向輸入緩衝區的指標與長度,不產生任何不必要的 std::string 堆積分配:
ParseResult HttpParser:: parse_request(
const char * buf, size_t len, size_t last_len,
HttpRequest& out_req, size_t& header_len) {
const char * method;
size_t method_len;
const char * path;
size_t path_len;
int minor_version;
struct phr_header headers[MAX_HEADERS];
size_t num_headers = MAX_HEADERS;
int pret = phr_parse_request(
buf, len,
& method, & method_len,
& path, & path_len,
& minor_version,
headers, & num_headers,
last_len
);
if (pret > 0 ) {
header_len = static_cast < size_t> (pret);
out_req.method = from_string_view(std:: string_view(method, method_len));
// ... 解析路徑、Query String 與 Headers
return ParseResult:: Complete;
} else if (pret == - 1 ) {
return ParseResult:: Error;
}
return ParseResult:: Incomplete;
}
3.3 即時 SSE (Server-Sent Events) Pass-Through 串流
在 LLM 對話生成場景中,若等所有 token 生成完畢再打包回傳,首字延遲(Time to First Token, TTFT)會非常糟糕。
我們在 HttpClient::stream_request 中設計了雙重回調機制:
on_header :一旦上游回傳 HTTP 200 與 Content-Type: text/event-stream,立即向用戶端下發 SSE 回應標頭(Transfer-Encoding: chunked、Cache-Control: no-cache)。
on_chunk :後續 async_read 讀到的每一個原始 chunk,直接透過協程管道寫入客戶端 socket。
sequenceDiagram
autonumber
actor Client as 📱 Downstream Client (SDK)
participant GW as ⚡ AI Gateway (C++23)
participant Upstream as 🦙 Upstream LLM (Ollama/vLLM)
Client->>GW: POST /v1/chat/completions (stream: true)
Note over GW: picohttpparser 解析 JSON std::expected 路由比對 -> 選擇 Upstream
GW->>Upstream: POST /v1/chat/completions (stream: true)
Upstream-->>GW: HTTP/1.1 200 OK (Transfer-Encoding: chunked)
GW-->>Client: HTTP/1.1 200 OK (Transfer-Encoding: chunked)
loop Real-Time Token & Reasoning Streaming
Upstream-->>GW: data: {"choices":[{"delta":{"reasoning":"Thinking..."}}]}
GW-->>Client: data: {"choices":[{"delta":{"reasoning":"Thinking..."}}]}
Upstream-->>GW: data: {"choices":[{"delta":{"content":"Hi"}}]}
GW-->>Client: data: {"choices":[{"delta":{"content":"Hi"}}]}
end
Upstream-->>GW: data: [DONE]\r\n0\r\n\r\n
GW-->>Client: data: [DONE]\r\n0\r\n\r\n
3.4 C++23 型別安全路由:std::expected、Monadic 操作與 std::unreachable
在舊式設計中,函式失敗往往透過回傳 std::optional 或拋出 C++ 例外來處理。然而 std::optional 缺乏具體的錯誤原因,而 C++ 例外則伴隨著顯著的 stack unwinding 執行期開銷。
在 C++23 中,我們全面改用 std::expected<RouteDecision, RoutingError> 進行路由解析:
enum class RoutingError {
NoMatchingRoute,
PoolNotFound,
PoolEmpty
};
inline std:: string to_string(RoutingError err) {
switch (err) {
case RoutingError:: NoMatchingRoute: return "No route matched for requested model" ;
case RoutingError:: PoolNotFound: return "Target upstream pool not found in configuration" ;
case RoutingError:: PoolEmpty: return "Upstream pool has no configured target nodes" ;
}
std:: unreachable(); // C++23: 告知編譯器此路徑不可能發生,消除無效 jump branch
}
std:: expected< RouteDecision, RoutingError> ModelRouter:: route(const std:: string& requested_model) const {
for (const auto & r : config_.routes) {
if (match_pattern(r.model_pattern, requested_model)) {
auto it = pools_.find(r.pool);
if (it != pools_.end()) {
if (it-> second-> nodes().empty()) {
return std:: unexpected(RoutingError:: PoolEmpty);
}
RouteDecision decision;
decision.pool = it-> second;
decision.original_model = requested_model;
decision.model = r.rewrite_model.empty() ? requested_model : r.rewrite_model;
return decision;
} else {
return std:: unexpected(RoutingError:: PoolNotFound);
}
}
}
if (default_pool_) {
if (default_pool_-> nodes().empty()) {
return std:: unexpected(RoutingError:: PoolEmpty);
}
RouteDecision decision;
decision.pool = default_pool_;
decision.original_model = requested_model;
decision.model = requested_model;
return decision;
}
return std:: unexpected(RoutingError:: NoMatchingRoute);
}
這項 C++23 升級帶來了幾大核心優勢:
富型別錯誤狀態(Rich Typed Errors) :閘道層可根據 RoutingError 精確映射 HTTP 狀態碼(例如 NoMatchingRoute 映射為 404 Not Found,而 PoolNotFound/PoolEmpty 映射為 500 Internal Error),且全程零例外開銷。
C++23 Monadic 鏈式操作 :支援使用 .transform() 與 .and_then() 進行優雅的函數式鏈結:
// 優雅提取 Pool 名稱,無需多層巢狀 if-else
auto pool_name = router.route("llama3.3" ).transform([](const RouteDecision& d) {
return d.pool-> name();
});
C++23 std::string_view::contains :告別冗長易錯的 str.find("chunked") != std::string_view::npos,改用簡潔直觀的 te->contains("chunked") 與 ct->contains("text/event-stream")。
std::unreachable() 消除死代碼 :在列舉(enum)的 switch 中使用 std::unreachable(),能精確告知編譯器所有有效列舉值已被完全覆蓋,徹底消除「control reaches end of non-void function」警告並產生更緊湊的 jump table 組合語言。
3.5 加權輪詢(Weighted Round-Robin)與透明容錯重試
每個 Upstream Pool 支援配置多個節點,並可設定各自的權重(weight)、最大失敗次數(max_fails)與失敗冷卻時間(fail_timeout_sec)。
std:: shared_ptr< UpstreamNode> UpstreamPool:: select_node() {
auto candidates = get_healthy_candidates();
if (candidates.empty()) return nullptr ;
int total_weight = 0 ;
for (const auto & node : candidates) {
total_weight += std:: max(1 , node-> weight());
}
size_t idx = rr_index_.fetch_add(1 , std:: memory_order_relaxed) % static_cast < size_t> (total_weight);
int cumulative = 0 ;
for (const auto & node : candidates) {
cumulative += std:: max(1 , node-> weight());
if (idx < static_cast < size_t> (cumulative)) {
return node;
}
}
return candidates.front();
}
當某個節點連線逾時或回傳 5xx 錯誤時,閘道會自動將該節點標記為失敗,並在 max_retries 上限內自動挑選下一個健康的節點重試(重試機制適用於尚未開始向下游下發 SSE 標頭與串流 chunk 的初始階段),對下游客戶端完全透明無感。
3.6 現代化 Cabin 構建與 Catch2 測試
專案使用 Cabin 管理,宣告檔 cabin.toml 極其簡潔:
[package ]
name = "ai-gateway"
version = "0.1.0"
cxx-standard = "c++23"
[dependencies ]
nlohmann_json = { port = true , version = "^3.12.0" }
picohttpparser = { port = true , version = "^2026.4.6" }
spdlog = { port = true , version = "^1.17.0" }
[dev-dependencies ]
catch2 = { port = true , version = "^3.15.1" }
只要執行 cabin build 與 cabin test,就能自動抓取依賴、編譯並運行包含 EventLoop、協程異常傳遞、定時器、HTTP Parser、C++23 std::expected 路由與加權負載平衡的完整單元測試(70 個斷言全部通過)。
4. 代碼審查與強固化實戰(Hardening)
在初步完成功能開發後,我們進行了一輪全面的代碼審查(Code Review),針對安全性、邊界情況與潛在漏洞進行了深度的強固化改善:
4.1 使用 nlohmann::json 避免錯誤訊息 JSON Injection
隱患 :原先 HttpResponse::make_error 採用字串拼接方式建構 JSON:
// ❌ 危險:若 message 包含引號、反斜線或控制字元,將造成 JSON 語法損壞
std:: string json = "{ \" error \" :{ \" message \" : \" " + message + " \" ,...}}" ;
當上游回傳異常的 HTML 或非預期格式時,客戶端解析 JSON 將直接崩潰。
修復 :全面改用 nlohmann::json 安全序列化,自動處理字元跳脫:
// ✅ 安全:自動正確跳脫所有特殊字元
nlohmann:: json j;
j["error" ]["message" ] = message;
j["error" ]["type" ] = type;
j["error" ]["code" ] = "invalid_request_error" ;
return make_json (status, j.dump());
4.2 Async-Signal-Safe 信號處理與忽略 SIGPIPE
隱患 1 :在 UNIX signal handler 中呼叫 spdlog::info 或進行堆積記憶體分配是非 async-signal-safe 的行為,可能在特定情況下導致 deadlock。
修復 1 :改用 volatile sig_atomic_t 旗標與 eventfd 進行非同步安全通知:
volatile sig_atomic_t g_shutdown_requested = 0 ;
void signal_handler (int sig) {
(void )sig;
g_shutdown_requested = 1 ;
if (g_wakeup_fd >= 0 ) {
uint64_t val = 1 ;
:: write(g_wakeup_fd, & val, sizeof (val)); // async-signal-safe
}
}
隱患 2 :在 SSE 串流過程中,若客戶端中途關閉連線,伺服器繼續 write() 到已關閉的 socket 會觸發 SIGPIPE 信號,預設情況下會直接 終止整個行程 !
修復 2 :在程式啟動時明確忽略 SIGPIPE:
std:: signal(SIGPIPE, SIG_IGN);
4.3 async_connect 逾時支援與定時器取消
隱患 :原先 async_connect 雖然宣告了 timeout 參數,但在 AsyncWritable 上並未真正關聯定時器。若目標伺服器 SYN-ACK 遺失或被防火牆丟棄,協程將永久掛起。
修復 :實作專屬的 AsyncConnectAwaiter,在暫停時註冊定時器,在連線成功恢復時立即取消定時器(cancel_timer),並透過 getsockopt(SOL_SOCKET, SO_ERROR) 嚴格校驗連線狀態。
4.4 16MB 緩衝區上限防禦與 Content-Length 安全解析
隱患 :當面對惡意請求或異常的大型回應時,若緩衝區無限 resize(*2),可能導致伺服器 OOM。此外,若 Content-Length 包含負數或尾隨無效字元,解析不嚴格可能造成非預期行為。
修復 :
限制客戶端與伺服端緩衝區最大上限為 16MB(MAX_CLIENT_BUFFER)。
使用 std::from_chars(或 std::stoull 嚴格校驗非負數與尾隨字元完整性)安全解析 Content-Length,遇到非法數值或超出上限時立即中斷連線並回傳 400/502 錯誤。
4.5 SSE 串流 Chunked 編碼與客戶端掛起修復(Transfer-Encoding: chunked)
隱患 :在整合 OpenAI Python SDK(底層基於 httpx)測試即時串流時,我們發現連線雖然能正常接收所有 token,但在串流結束時 Python client 會 陷入無限等待(hang) ,直到連線逾時中斷。
原因剖析 :
上游推論後端(如 Ollama)是以 HTTP/1.1 Chunked Transfer Encoding 回傳 SSE 串流資料,每個資料區塊由長度標頭與內容組成,並以長度為 0 的終止區塊(0\r\n\r\n)標記傳輸結束。
由於我們的閘道採用零拷貝的 raw chunk 直接轉發,若在轉發 downstream HTTP Header 時將 Transfer-Encoding: chunked 標頭過濾或遺漏,客戶端的 HTTP 解碼器(httpx)便無法得知這是 chunked 資料流。
結果 httpx 會將其當作一般 stream,不會去解析 0\r\n\r\n 結束符號,而是一直傻傻等待 TCP socket 關閉(FIN)。但由於 Keep-Alive 長連線保持開啟,客戶端便發生無限 hang 的現象!
修復 :在轉發 SSE 回應標頭時,明確保留並設定 Transfer-Encoding: chunked:
if (s_hdr.is_chunked) {
resp_headers.set("Transfer-Encoding" , "chunked" );
}
resp_headers.set("Content-Type" , "text/event-stream" );
resp_headers.set("Cache-Control" , "no-cache" );
resp_headers.set("Connection" , "keep-alive" );
當 Transfer-Encoding: chunked 正確送達客戶端後,httpx 的 chunked decoder 在收到 0\r\n\r\n 時便會立即乾淨俐落地觸發串流結束事件,徹底解決掛起問題。
5. 快速上手與本地推論實機驗證
5.1 啟動與測試
透過 Cabin 編譯與啟動服務:
# 編譯專案
cabin build
# 執行單元測試
cabin test
# 啟動閘道服務
cabin run -- --port 8080 --log-level debug --config config.json
設定檔 config.json 範例:
{
"host" : "0.0.0.0" ,
"port" : 8080 ,
"log_level" : "info" ,
"default_pool" : "local_ollama" ,
"pools" : [
{
"name" : "local_ollama" ,
"timeout_ms" : 60000 ,
"max_retries" : 2 ,
"targets" : [
{ "host" : "127.0.0.1" , "port" : 11434 , "weight" : 1 }
]
}
],
"routes" : [
{
"model" : "gemma*" ,
"pool" : "local_ollama"
},
{
"model" : "llama*" ,
"pool" : "local_ollama"
}
]
}
5.2 與 OpenAI Python SDK 整合:本地 Ollama、gemma4:26b 與思考過程串流
我們使用本地啟動的 Ollama 載入具備強大推理能力(Reasoning / Thinking)的開源模型 gemma4:26b 。新一代推理模型在輸出最終答案前,會先串流輸出內部的思考鏈過程(透過 delta.reasoning 或 delta.reasoning_content 傳遞)。
我們撰寫了一段 Python 測試腳本 scripts/openai-client.py,展示如何透過閘道即時呈現思考過程與最終回答:
from openai import OpenAI
import sys
# 將 base_url 指向本機 C++23 AI Gateway
client = OpenAI(
base_url= "http://localhost:8080/v1" ,
api_key= "none"
)
# 測試即時串流對話 (使用具備 Reasoning 能力的 gemma4:26b)
response = client. chat. completions. create(
model= "gemma4:26b" ,
messages= [{"role" : "user" , "content" : "請用繁體中文以兩句話解釋 C++20 協程。" }],
stream= True
)
in_thinking = False
for chunk in response:
if not chunk. choices:
continue
delta = chunk. choices[0 ]. delta
# 處理思考/推理輸出 (Thinking / Reasoning tokens)
reasoning = getattr(delta, "reasoning" , None ) or getattr(delta, "reasoning_content" , None )
if reasoning:
if not in_thinking:
print(" \033 [90m[思考中...] \033 [0m " , end= "" , flush= True )
in_thinking = True
print(f " \033 [90m { reasoning} \033 [0m" , end= "" , flush= True )
elif delta. content:
if in_thinking:
print(" \n\n\033 [32m[回答] \033 [0m \n " , end= "" , flush= True )
in_thinking = False
print(delta. content, end= "" , flush= True )
print()
執行輸出效果如下:
[思考中...] 使用者希望用繁體中文以兩句話解釋 C++20 協程。第一句說明協程的核心機制(無堆疊、可暫停與恢復執行的特殊函數),第二句說明其主要優勢(以同步直觀的邏輯處理非同步任務)。
[回答]
C++20 協程是一種能夠在執行過程中暫停並保存狀態,隨後能從原位恢復執行的特殊函數。
它讓開發者可以用撰寫同步程式碼般的直觀邏輯,來高效地處理複雜的非同步與併發任務。
實測結果顯示:
極低轉發延遲 :在本地或區域網路環境下,閘道自身的請求解析、std::expected 模型路由與非同步排程轉發開銷在 微秒(microseconds) 等級。
流暢的思考鏈串流 :推理模型輸出思考 token 與最終回答 token 均無延遲無阻塞即時傳達終端。
記憶體極致輕量 :在維持高並發長連線串流下,整個閘道行程記憶體佔用穩定控制在數 MB 範圍內。
6. 結語
透過這次從零打造 C++23 AI Gateway 的實踐,我們體會到現代 C++(特別是 C++20/C++23 協程、std::expected 與標準庫演進)在系統級網路程式設計上帶來的巨大躍進:
優雅與效能兼得 :我們不再需要在「Node.js/Go 的易讀非同步代碼」與「C/C++ 的極致效能」之間做妥協。透過自訂的 Task<T> 與 epoll awaiter,我們能用最簡潔的 co_await 語法寫出極具競爭力的高並發伺服器。
掌控力與安全性 :擺脫了笨重龐大的框架相依,每一行 I/O 排程、記憶體配置與轉發邏輯都清晰透明,配合現代化的代碼審查與強固化防護,建構出真正輕盈、穩健且具備生產級品質的基礎架構軟體。
如果你對高並發網路程式設計、C++ 協程原理或自託管 AI 基礎設施感興趣,非常推薦親自動手體驗!
用 C++23 與 Cabin 開發 herdr 排序外掛
如 Rust Cargo 般的現代 C++ 開發體驗與開源生態複用
隨著 AI coding agent(如 Claude Code、Codex、Pi、Aider 等)的普及,現代軟體開發的工作模式正在經歷巨大轉變:我們不再只是單線程地在單一終端裡寫程式,而是傾向於同時將多個獨立子任務平行分派給多個 AI agent 。
在這個背景下,專為 AI agent 設計的現代終端多工器(terminal multiplexer)——herdr (被許多人稱為「AI agent 時代的 tmux」)應運而生。
1. 痛點與核心設計目標
1.1 為什麼 herdr 會迅速累積大量工作區(workspaces)?
在傳統開發中,我們頂多開 2 到 3 個 tmux 視窗;但在使用 herdr 時,使用者往往會同時開啟 10 到 30+ 個工作區(workspaces) 。這源於 herdr 的幾個核心架構特色:
內建 Git worktree 原生隔離 :herdr 提供了原生的 worktree 整合(如 herdr worktree create --branch <name>)。當你要讓 agent 處理一個 issue 或進行重構時,herdr 會在獨立的 Git worktree 中建立專屬 workspace。這讓多個 agent 能在各自獨立的目錄分支下並行編譯與測試,完全不會產生 Git 鎖定或分支衝突。
多 agent 高並行分派(agent-first orchestration) :開發者習慣為不同任務建立獨立 workspace,例如同時跑 feat/issue-2、feat/issue-10、bugfix/auth、docs/api、refactor/db。
程式化與 agent-to-agent 自動化開區 :herdr 提供本地 socket API 與 CLI。負責統籌的主 agent(lead agent)或自動化腳本能透過程式化指令(herdr workspace create / herdr worktree create)動態建立工作區、派發任務,並監聽 agent 的即時工作狀態。
常駐背景守護(session persistence) :herdr 的背景 daemon 會持續託管所有終端 session。即使筆電休眠、SSH 連線中斷或關閉前端視窗,所有執行中的 agent 依然在背景持續運作,導致工作區列表隨著多日專案持續累積。
flowchart TD
D["🖥️ herdr daemon (常駐背景服務) 託管多個平行 session 與 Git worktree"]
W["📦 20+ 平行工作區 (workspaces & worktrees) ● working · ▲ blocked · ✓ done · ○ idle"]
P["⚠️ 工作區組織與切換痛點 ❌ 傳統字典序:issue-10 排在 issue-2 前面 ❌ 狀態混雜:等待輸入 (blocked) 難以察覺 ❌ 分支散落:不同 Git 倉庫缺乏層級整理"]
D ==>|背景持續運行| W
W ==>|缺乏排序機制| P
classDef daemonNode fill:#0c4a6e,stroke:#38bdf8,stroke-width:2px,color:#f0f9ff;
classDef wsNode fill:#1e293b,stroke:#fb923c,stroke-width:2px,color:#fff7ed;
classDef probNode fill:#450a0a,stroke:#f87171,stroke-width:2px,color:#fef2f2;
class D daemonNode;
class W wsNode;
class P probNode;
linkStyle default stroke:#38bdf8,stroke-width:2.5px,fill:none;
linkStyle 1 stroke:#f43f5e,stroke-width:3px,fill:none;
1.2 排序與組織痛點
當工作區膨脹到數十個時,側邊欄的瀏覽與切換體驗會急劇下降:
傳統字典序混亂 :常規字母排序會將 feat/issue-10 排在 feat/issue-2 前面。
agent 狀態難以一目了然 :正在處理(working)或等待人類輸入(blocked)的關鍵 agent 散落在列表中,與已經完成(done)或閒置(idle)的工作區混在一起。
跨專案 worktree 分散 :不同專案的 Git worktrees 缺乏依倉庫名稱或工作路徑的層級整理。
為了解決這個痛點,我用現代 C++23 開發了一個高效、穩健的 herdr 排序與工作區管理外掛:herdr-sort-workspaces 。
在開發這個專案的過程中,有兩個體驗讓我非常驚艷:
Cabin 建置系統帶來如 Rust Cargo 般的現代開發體驗 :告別寫了幾十年依舊繁瑣且容易踩坑的 CMakeLists.txt,僅靠一份簡潔的聲明式 cabin.toml,就能自動解析 ports 套件依賴、管理子模組,並以 cabin build 與 cabin test 一鍵編譯與測試。
現代 C++23 與高品質開源生態的完美結合 :結合 std::expected、std::ranges、std::lexicographical_compare_three_way、std::format,並複用社群頂級函式庫(CLI11、nlohmann_json、FTXUI、Catch2 v3),在保證極致效能與零額外抽象開銷的同時,寫出具備高型別安全、高可讀性與完整測試覆蓋的現代系統程式。
1.3 五大核心需求與系統架構
在打造這款外掛時,我設定了幾個核心需求:
自然字母數字排序(natural alphanumeric sort) :數字區塊需視為整數比較,確保 workspace-2 排在 workspace-10 之前;同時支援不區分大小寫的初級比較與嚴格大小寫的平手裁決,且必須能處理任意長度的大數值而不發生整數溢位。
多維度排序策略 :
natural:自然數字排序(預設)。
label / alpha:純字典字母排序。
status:依照 agent 工作狀態優先權排序(working > blocked > done > idle > unknown)。
repo:依據 Git 倉庫根目錄分組,並整理旗下各 worktree 分支。
path:依據工作目錄(CWD)層級排序。
panes / tabs:依據終端分割窗格或分頁數量排序(快速找出最活躍的工作區)。
reverse:反轉現有工作區順序。
最小移動次數計算(minimal move reordering) :herdr 的 RPC 提供 workspace.move(id, insert_index) 介面。我們不能暴力重置所有工作區,而必須透過置換模擬演算法計算出最少的移動步驟,減少畫面閃爍與 IPC 負擔。
宣告式互動 TUI(live preview terminal UI) :利用 FTXUI 打造即時互動終端介面,支援快捷鍵 [1-8] 切換策略、[r] 反轉、[f] 置頂當前焦點工作區,並以 ANSI 顏色徽章和位置偏移指示器(如 +2, -1, 0)提供即時重排預覽。
Unix domain socket IPC 與容錯機制 :優先透過 /tmp/herdr.sock 或 ~/.config/herdr/herdr.sock 與 herdr 背景守護程序(daemon)通訊,若 socket 不可用則無縫退回呼叫 herdr CLI。
以下是整個系統的通訊與模組架構圖:
flowchart TD
subgraph UI ["1. 使用者介面與觸發 (UI & CLI)"]
direction LR
Pal["herdr command palette (快捷鍵 / 外掛選單)"] -->|啟動| CLI["CLI 命令列 (CLI11) sort / list / hook / bench"]
TUI["FTXUI 互動終端介面 (即時預覽 / [1-8] 快捷鍵)"]
BenchTool["bench-natural-sort (專屬效能評測二進位檔)"]
end
subgraph Core ["2. 核心排序與評測模組 (C++23 Sorter & Bench Engine)"]
direction LR
Sorter["⚙️ Sorter 策略排程器 (std::ranges::stable_sort / partition)"]
NatSort["🔤 Natural Sort 比較器 (自然字母數字分塊比對)"]
Model["📦 資料模型映射 (nlohmann_json 序列化)"]
BenchCore["⚡ 效能基準評測引擎 (do_not_optimize / 9 種資料分佈)"]
Sorter --- NatSort
Sorter --- Model
BenchCore --- NatSort
BenchCore --- Sorter
end
subgraph PlannerSub ["3. 置換規劃模組 (Reorder Planner)"]
Planner["📐 最小移動規劃器 (前綴不變量置換狀態機演算法)"]
end
subgraph Transport ["4. 通訊傳輸層 (herdrClient)"]
direction LR
Sock["⚡ Unix domain socket (JSON-RPC / 5 秒逾時保護)"]
CliFallback["🐚 herdr CLI 子程序 (popen 管道容錯回退)"]
Sock -.->|連線失敗時回退| CliFallback
end
subgraph Daemon ["5. herdr 執行時環境 (daemon)"]
HerdrCore["🖥️ herdr core daemon (workspace.list / workspace.move / notification.show)"]
end
CLI ==>|傳入排序或壓測請求| Sorter
TUI ==>|即時預覽與套用| Sorter
BenchTool ==>|執行全場景基準評測| BenchCore
Sorter ==>|輸出目標排序清單| Planner
Planner ==>|生成最小移動指令序列| Sock
Sock ==>|socket 通訊| HerdrCore
CliFallback ==>|CLI 指令管道| HerdrCore
classDef uiNode fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#ecfdf5;
classDef coreNode fill:#0c4a6e,stroke:#38bdf8,stroke-width:2px,color:#f0f9ff;
classDef planNode fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#e0e7ff;
classDef transNode fill:#431407,stroke:#fb923c,stroke-width:2px,color:#fff7ed;
classDef daemonNode fill:#1e293b,stroke:#94a3b8,stroke-width:2px,color:#f8fafc;
class Pal,CLI,TUI,BenchTool uiNode;
class Sorter,NatSort,Model,BenchCore coreNode;
class Planner planNode;
class Sock,CliFallback transNode;
class HerdrCore daemonNode;
style UI fill:#022c22,stroke:#10b981,stroke-width:2px,color:#6ee7b7
style Core fill:#082f49,stroke:#0284c7,stroke-width:2px,color:#7dd3fc
style PlannerSub fill:#1e1b4b,stroke:#6366f1,stroke-width:2px,color:#a5b4fc
style Transport fill:#271007,stroke:#ea580c,stroke-width:2px,color:#fdba74
style Daemon fill:#0f172a,stroke:#64748b,stroke-width:2px,color:#cbd5e1
linkStyle default stroke:#38bdf8,stroke-width:2.5px,fill:none;
linkStyle 0 stroke:#6ee7b7,stroke-width:2px,fill:none;
linkStyle 1,2,3,4 stroke:#38bdf8,stroke-width:1.5px,stroke-dasharray:3 3,fill:none;
linkStyle 5 stroke:#fb923c,stroke-width:2px,stroke-dasharray:4 4,fill:none;
linkStyle 6,7,8 stroke:#34d399,stroke-width:2.5px,fill:none;
linkStyle 9 stroke:#38bdf8,stroke-width:2.5px,fill:none;
linkStyle 10 stroke:#818cf8,stroke-width:2.5px,fill:none;
linkStyle 11 stroke:#f97316,stroke-width:2.5px,fill:none;
linkStyle 12 stroke:#fb923c,stroke-width:2px,fill:none;
2. 亮點:Cabin 建置系統 — C++ 如 Rust Cargo 般的現代體驗
長期以來,C++ 專案的建置系統與依賴管理一直是開發者心中的痛。CMake 雖然功能強大,但其語法晦澀、歷史包袱沉重,要引入外部相依套件通常得在 FetchContent、find_package、vcpkg、Conan 或手動編譯之間痛苦掙扎,動輒數十行的樣板程式碼更讓人心力交瘁。
在這個專案中,我全面採用了新一代 C++ 套件管理與建置系統:Cabin (cabinpkg)。
2.1 簡潔優雅的 cabin.toml
Cabin 借鑑了 Rust Cargo 的設計哲學,使用單一 TOML 檔宣告專案資訊、編譯標準、相依套件與建置目標。看看 herdr-sort-workspaces 的完整 cabin.toml:
[package ]
name = "herdr-sort-workspaces"
version = "0.1.0"
cxx-standard = "c++23"
[dependencies ]
nlohmann_json = { port = true , version = "^3.12.0" }
ftxui = { path = "third_party/ftxui" }
CLI11 = { port = true , version = "^2.6.2" }
[target .herdr-sort-workspaces ]
type = "executable"
sources = [
"src/main.cc" ,
"src/model.cc" ,
"src/natural_sort.cc" ,
"src/sorter.cc" ,
"src/client.cc" ,
"src/tui.cc" ,
"src/benchmark.cc"
]
include-dirs = ["include" , "third_party/ftxui/include" ]
deps = ["nlohmann_json" , "ftxui" , "CLI11" ]
[target .test-sorter ]
type = "test"
sources = [
"tests/test_sorter.cc" ,
"src/model.cc" ,
"src/natural_sort.cc" ,
"src/sorter.cc" ,
"src/benchmark.cc"
]
include-dirs = ["include" ]
deps = ["nlohmann_json" , "catch2" ]
[target .bench-natural-sort ]
type = "executable"
sources = [
"benchmarks/bench_main.cc" ,
"src/benchmark.cc" ,
"src/model.cc" ,
"src/natural_sort.cc" ,
"src/sorter.cc"
]
include-dirs = ["include" ]
deps = ["nlohmann_json" , "CLI11" ]
[dev-dependencies ]
catch2 = { port = true , version = "^3.15.1" }
2.2 Cabin 的關鍵優勢
宣告式 ports 生態(port = true) :
如 nlohmann_json、CLI11 和 catch2,只需標註 port = true 與版本範圍語意(如 ^3.12.0),Cabin 會自動從官方 ports 倉庫解析、下載、快取並編譯對應版本,完全不需手動配置 CMake 或下載標頭檔。
多目標清晰隔離 :
將主執行檔(executable)、測試套件(test)與專屬壓測目標(bench-natural-sort)分開宣告,dev-dependencies(如 Catch2)僅會在編譯測試目標時被拉取與鏈結,避免污染最終的發布二進位檔案。
無縫整合本地子模組 :
對於需要深度客製化或特定分支的函式庫(例如終端圖形庫 ftxui),可以直接使用 path = "third_party/ftxui" 引入,Cabin 會自動處理包含目錄與原始碼建置。
標準化的命令列工作流 :
cabin build:一鍵以 Debug 模式編譯所有目標(背後以 Ninja 高速並行編譯)。
cabin build --release:產生極致最佳化的發布二進位檔案。
cabin test:自動編譯並執行 Catch2 測試套件,直接在終端輸出美觀的測試進度與摘要。
cabin run --release --bin bench-natural-sort:直接以 release 模式執行高效能基準測試。
相較於傳統 CMake 專案動輒上百行的 CMakeLists.txt,Cabin 讓 C++ 開發者終於擁有了與現代 Rust、Go 相同的極簡心智模型。
3. 現代 C++23 特性深度實踐
C++23 帶來了許多革命性的標準庫與語言特性,讓系統級程式碼在維持零執行期開銷的同時,表達力與安全性得到質的飛躍。
3.1 std::expected 與 monadic 錯誤處理
在 IPC 網路通訊、JSON 解析與排序選項驗證中,傳統的錯誤處理通常有兩種極端:
C 風格錯誤碼 / 輸出參數 :型別不安全,呼叫端容易忽略錯誤檢查,且函式簽名冗長。
C++ 例外(exceptions) :隱式控制流、跨執行緒與 IPC 不易捕獲,且可能帶來額外的二進位體積與堆疊展開(stack unwinding)開銷。
C++23 引入的 std::expected<T, E> 提供了一種值語意(value-based)的 monadic 錯誤處理機制。當運算成功時回傳 T,失敗時回傳包裝在 std::unexpected 中的錯誤資訊 E。
在 include/herdr/sorter.hpp 與 src/client.cc 中,所有可能出錯的操作均採用 std::expected 簽名:
// include/herdr/sorter.hpp
[[nodiscard]] std:: expected< SortStrategy, std:: string> parse_sort_strategy(
std:: string_view sv
) noexcept ;
[[nodiscard]] std:: expected< std:: vector< Workspace> , std:: string> sort_workspaces(
std:: span< const Workspace> current,
const SortOptions& options
);
在實作時,解析錯誤時回傳 std::unexpected,呼叫端則以乾淨的值判斷進行處理:
// src/sorter.cc
std:: expected< SortStrategy, std:: string> parse_sort_strategy(std:: string_view sv) noexcept {
if (sv == "natural" || sv == "nat" || sv == "numeric" ) return SortStrategy:: NaturalAsc;
if (sv == "status" || sv == "agent" || sv == "active" ) return SortStrategy:: Status;
if (sv == "repo" || sv == "git" || sv == "worktree" ) return SortStrategy:: Repo;
if (sv == "panes" || sv == "pane-count" ) return SortStrategy:: PanesDesc;
// ... (其他策略如 label, path, tabs, reverse 等省略)
// 返回攜帶清晰錯誤字串的 unexpected
return std:: unexpected(std:: format("Unknown sort strategy: '{}'" , sv));
}
在呼叫端(如 main.cc)中,我們能以簡潔、型別安全的方式解包或傳播錯誤:
auto strat_res = herdr:: parse_sort_strategy(strategy_str);
if (! strat_res.has_value()) {
std:: cerr << std:: format(" \033 [31mError: \033 [0m {} \n " , strat_res.error());
return 1 ;
}
SortStrategy strategy = * strat_res;
3.2 宣告式 std::ranges 演算法管線
C++20/23 的 std::ranges 演算法擺脫了傳統 std::sort(vec.begin(), vec.end()) 的繁瑣迭代器語法,直接接受容器或 view,並支援安全的原地排列與管線操作。
在 src/sorter.cc 的 sort_workspaces 核心流程中,我們運用了 std::ranges::stable_sort、std::ranges::reverse 與 std::ranges::stable_partition:
std:: expected< std:: vector< Workspace> , std:: string> sort_workspaces(
std:: span< const Workspace> current,
const SortOptions& options
) {
std:: vector< Workspace> result(current.begin(), current.end());
if (result.empty()) return result;
// 1. 根據策略執行穩定排序(保留同鍵值時的原有順序)
switch (options.strategy) {
case SortStrategy:: NaturalAsc:
std:: ranges:: stable_sort(result, compare_natural_asc);
break ;
case SortStrategy:: NaturalDesc:
std:: ranges:: stable_sort(result, [](const Workspace& a, const Workspace& b) {
return compare_natural_asc (b, a);
});
break ;
case SortStrategy:: Status:
std:: ranges:: stable_sort(result, compare_status);
break ;
case SortStrategy:: Repo:
std:: ranges:: stable_sort(result, compare_repo);
break ;
case SortStrategy:: PanesDesc:
std:: ranges:: stable_sort(result, compare_panes_desc);
break ;
case SortStrategy:: Reverse:
std:: ranges:: reverse(result);
break ;
}
// 2. 全域反轉旗標處理
if (options.reverse && options.strategy != SortStrategy:: Reverse) {
std:: ranges:: reverse(result);
}
// 3. 置頂焦點工作區或特定前綴工作區(使用穩定分割演算法)
if (options.pinned_first || ! options.pin_prefix.empty()) {
std:: ranges:: stable_partition(result, [& ](const Workspace& w) {
if (options.pinned_first && w.focused) return true;
if (! options.pin_prefix.empty() && w.label.starts_with(options.pin_prefix)) return true;
return false;
});
}
return result;
}
std::ranges::stable_partition 確保被置頂的工作區移到前面時,其他工作區彼此之間的相對排序嚴格保持不變,展現了現代標準庫演算法的高度表達力。
3.3 三向比較運算子與 std::lexicographical_compare_three_way
在純字典字母排序中,我們希望達成:
主要比較 :不區分大小寫(case-insensitive),例如 'a' 與 'A' 視為相同。
平手仲裁(tie-breaker) :若字母相同,以 ASCII 大小寫順序(大寫優先於小寫)作為平手仲裁,避免不同大小寫字串被判定為完全相等而產生未定義的隨機順序。
在 C++23 中,我們可以直接使用 <compare> 標頭檔中的 std::lexicographical_compare_three_way 與太空船運算子(<=>):
bool compare_label_asc (const Workspace& a, const Workspace& b) {
std:: string_view sa = a.display_title_sv();
std:: string_view sb = b.display_title_sv();
auto cmp = std:: lexicographical_compare_three_way(
sa.begin(), sa.end(),
sb.begin(), sb.end(),
[](char c1, char c2) {
char l1 = to_lower_char(c1);
char l2 = to_lower_char(c2);
if (l1 != l2) return l1 <=> l2; // 主要不區分大小寫比較
return c1 <=> c2; // 平手時以原始大小寫仲裁
}
);
if (cmp != 0 ) return cmp < 0 ;
return a.workspace_id < b.workspace_id; // 最終以 workspace_id 字典序作為唯一平手仲裁
}
這段程式碼將原本需要寫十幾行雙迴圈、大小寫轉換與指標推進的繁瑣邏輯,濃縮成兼具極致編譯器最佳化與數學嚴謹性的三向比較表達式。
告別易引發記憶體安全問題的 snprintf 與繁複且低效的 std::ostringstream,C++23 的 std::format 在編譯期檢查格式化字串型別,並提供極致的字串組合效率。
例如在終端列表輸出與 RPC 請求 ID 生成中:
// 格式化請求序號與通知內容
const std:: string req_id = std:: format("sort_req_{}" , ++ request_seq_);
std:: string msg = std:: format("Sorted {} workspace{} by {}." ,
count, (count == 1 ? "" : "s" ), strategy_name);
// 終端對齊輸出表格
std:: cout << std:: format(" {:2}. \033 [36m{:<4} \033 [0m {} {:<10} {:>2}p/{:>1}t {:<32} \033 [90m{} \033 [0m \n " ,
i + 1 , w.workspace_id, focus_mark, w.status_badge_ansi(),
w.pane_count, w.tab_count, w.display_title(), w.cwd
);
3.5 零拷貝檢視:std::string_view 與 std::span 的生命週期管理
在整個外掛的排序管線中,工作區標籤(label)、目錄路徑(CWD)與工作區 ID 需要頻繁進行字串比對與切片。為了徹底消除短命 std::string 的堆積記憶體配置(heap allocation):
std::string_view :在字串自然比對器 natural_compare(std::string_view lhs, std::string_view rhs) 中,直接操作字串指標與長度,不產生任何記憶體複製。
std::span<const Workspace> :在接收工作區列表時,函式接收唯讀的非擁有式切片(span),無論底層是 std::vector 還是靜態陣列,皆可零成本傳遞。
同時,在 Workspace 模型中,我們精確區分了檢視方法與產生字串的方法:
struct Workspace {
std:: string workspace_id;
std:: string label;
// ...
// 零拷貝檢視(生命週期依附於 Workspace 實例)
std:: string_view display_title_sv() const noexcept {
return ! label.empty() ? std:: string_view(label) : std:: string_view(workspace_id);
}
// 需產生 ANSI 格式化字串時才進行拷貝
[[nodiscard]] std:: string status_badge_ansi() const ;
[[nodiscard]] std:: string display_title() const ;
};
4. 開源生態的高品質複用
現代 C++ 開發絕非閉門造車。透過複用經過社群嚴格考驗的開源函式庫,我們能以極少的程式碼實現強大的工業級功能。
函式庫
角色與責任
為專案帶來的價值
nlohmann_json
JSON 序列化與 RPC 通訊
透過 ADL to_json / from_json 實現 Workspace 與 WorktreeInfo 的自動雙向序列化,處理 herdr socket 回傳的複雜樹狀快照。
CLI11
命令列解析與子命令架構
支援 sort、list、interactive、hook 四大子命令,提供豐富的參數校驗(如 CLI::IsMember 檢查合法排序策略)與內建色彩 help 格式化。
FTXUI
終端互動式 UI 元件
採用 Functional Reactive 模式構建全螢幕 TUI,包含 radio menu、checkbox、table、即時按鍵監聽器與 ANSI 彩色渲染。
Catch2 v3
單元測試與微基準評測
提供強大的 TEST_CASE、SECTION 階層測試與 BENCHMARK / BENCHMARK_ADVANCED 微基準評測,涵蓋 185+ 斷言 與隔離統計計時,驗證正確性與極致效能。
5. 核心演算法與架構解析
5.1 自然字母數字排序演算法(natural sort algorithm)
常規字串比對是逐字元比較 ASCII 碼,導致 "item10" 比 "item2" 小(因為 '1' < '2')。
在 src/natural_sort.cc 中,我實作了一套支援任意長度大數值 與前導零平手仲裁 的高效分塊演算法:
flowchart TD
Start["natural_compare(lhs, rhs)"] --> Loop{"雙指標 i, j 尚未到底?"}
subgraph Branch ["每輪字元分塊比對"]
direction TB
IsDigit{"lhs[i] 與 rhs[j] 皆為數字?"}
NumBranch["🔢 數字區塊比較 1. 計算並跳過前導零 2. 量測有效數字長度 (長者大) 3. 等長則逐位比較數值 4. 完全相同則記錄前導零 bias"]
CharBranch["🔤 非數字字元比較 1. 轉小寫比對 (相異定勝負) 2. 相同則記錄大小寫 bias"]
IsDigit -- 是 --> NumBranch
IsDigit -- 否 --> CharBranch
end
Diff{"分塊是否相異?"}
RetDiff["🛑 回傳分塊勝負結果 (-1 / +1)"]
Next["推進指標 i, j"]
subgraph EndCheck ["尾端長度與平手仲裁 (Loop 結束)"]
direction TB
LenCheck{"兩字串長度不同?"}
RetLen["長度較長者為大 (±1)"]
RetBias["回傳累積之 Bias (前導零 / 大小寫) 若完全相同則回傳 0"]
LenCheck -- 是 --> RetLen
LenCheck -- 否 --> RetBias
end
Loop -- 是 --> IsDigit
NumBranch --> Diff
CharBranch --> Diff
Diff -- 相異 --> RetDiff
Diff -- 相同 --> Next
Next --> Loop
Loop -- 否 (比對完畢) --> EndCheck
classDef startNode fill:#0c4a6e,stroke:#38bdf8,stroke-width:2px,color:#f0f9ff;
classDef decisionNode fill:#431407,stroke:#fb923c,stroke-width:2px,color:#fff7ed;
classDef procNode fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc;
classDef retNode fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#ecfdf5;
classDef retNegNode fill:#450a0a,stroke:#f87171,stroke-width:2px,color:#fef2f2;
class Start startNode;
class Loop,IsDigit,Diff,LenCheck decisionNode;
class NumBranch,CharBranch,Next procNode;
class RetLen,RetBias retNode;
class RetDiff retNegNode;
style Branch fill:#0f172a,stroke:#38bdf8,stroke-width:1.5px,color:#7dd3fc
style EndCheck fill:#111827,stroke:#34d399,stroke-width:1.5px,color:#6ee7b7
linkStyle default stroke:#38bdf8,stroke-width:2.5px,fill:none;
演算法核心程式碼精華
// src/natural_sort.cc
int natural_compare (std:: string_view lhs, std:: string_view rhs) noexcept {
size_t i = 0 , j = 0 ;
const size_t len1 = lhs.size(), len2 = rhs.size();
int bias = 0 ;
while (i < len1 && j < len2) {
char c1 = lhs[i], c2 = rhs[j];
if (is_digit(c1) && is_digit(c2)) {
// 1. 統計並略過前導零
size_t z1 = 0 , z2 = 0 ;
while (i < len1 && lhs[i] == '0' ) { ++ z1; ++ i; }
while (j < len2 && rhs[j] == '0' ) { ++ z2; ++ j; }
// 2. 找出有效數字區塊長度
size_t start1 = i;
while (i < len1 && is_digit(lhs[i])) ++ i;
size_t num_len1 = i - start1;
size_t start2 = j;
while (j < len2 && is_digit(rhs[j])) ++ j;
size_t num_len2 = j - start2;
// 長度不同者,長度大者數值必大(支援任意超長數值,不發生整數溢位!)
if (num_len1 < num_len2) return - 1 ;
if (num_len1 > num_len2) return 1 ;
// 3. 長度相同時,逐位元比對 ASCII 數值
for (size_t k = 0 ; k < num_len1; ++ k) {
if (lhs[start1 + k] < rhs[start2 + k]) return - 1 ;
if (lhs[start1 + k] > rhs[start2 + k]) return 1 ;
}
// 4. 數值完全相等但前導零數量不同時,記錄平手仲裁 bias(較少前導零者優先:7 < 007)
if (bias == 0 && z1 != z2) {
bias = (z1 > z2) ? 1 : - 1 ;
}
} else {
char l1 = to_lower(c1), l2 = to_lower(c2);
if (l1 != l2) return (l1 < l2) ? - 1 : 1 ;
if (bias == 0 && c1 != c2) {
bias = (c1 < c2) ? - 1 : 1 ; // 大小寫平手仲裁
}
++ i; ++ j;
}
}
if (i < len1) return 1 ;
if (j < len2) return - 1 ;
return bias;
}
這個演算法不僅完美處理了 "item2" < "item10"、"v1.2" < "v1.10",連多達 30 位的巨型數字字串(例如 Git SHA、長 issue ID)也能在微秒內完成精確比對。
5.2 最小移動置換演算法(minimal move permutation planner)
當我們計算出目標工作區順序後,必須將現有順序轉換為一組最小的 workspace.move(id, insert_index) 操作序列。
因為每呼叫一次 workspace.move(id, idx),目標工作區會被抽出並插入到指定位置,進而改變後續工作區的索引偏移。
在 src/sorter.cc 中,我實作了一套前綴不變量置換狀態機 (prefix-invariant permutation simulation):
// src/sorter.cc
std:: vector< ReorderMove> calculate_reorder_moves(
std:: span< const Workspace> current,
std:: span< const Workspace> target
) {
std:: vector< ReorderMove> moves;
std:: vector< std:: string> state;
state.reserve(current.size());
for (const auto & w : current) {
state.push_back(w.workspace_id);
}
// 依序滿足 target[0], target[1], ..., target[N-1] 的位置
for (size_t i = 0 ; i < target.size(); ++ i) {
const std:: string& target_id = target[i].workspace_id;
auto it = std:: ranges:: find(state, target_id);
if (it != state.end()) {
size_t cur_idx = static_cast < size_t> (std:: distance(state.begin(), it));
if (cur_idx != i) {
// 將元素從當前位置移動到索引 i
std:: string item = std:: move(* it);
state.erase(it);
state.insert(state.begin() + static_cast < std:: ptrdiff_t> (i), item);
moves.push_back(ReorderMove{
.workspace_id = target_id,
.insert_index = static_cast < uint32_t > (i),
.label = target[i].label
});
}
}
}
return moves;
}
置換演算法工作原理演示
假設目前工作區順序為 [w1, w2, w3, w4, w5],排序後目標順序為 [w5, w3, w1, w4, w2]:
$i=0$ :目標是 w5,當前 w5 位於索引 4。執行 move(w5, 0),狀態變為 [w5, w1, w2, w3, w4]。
$i=1$ :目標是 w3,當前 w3 位於索引 3。執行 move(w3, 1),狀態變為 [w5, w3, w1, w2, w4]。
$i=2$ :目標是 w1,當前 w1 已經位於索引 2,無需移動。
$i=3$ :目標是 w4,當前 w4 位於索引 4。執行 move(w4, 3),狀態變為 [w5, w3, w1, w4, w2]。
$i=4$ :目標是 w2,當前 w2 已經位於索引 4,無需移動。
透過這個演算法,原本看似混亂的 5 元素完全亂序,僅用 3 次移動 即達成目標,大幅減少了 IPC 通訊與 UI 重繪次數。
5.3 Unix domain socket IPC 通訊與 RAII 守護
在 Linux 與 macOS 環境下,與 herdr 守護程序通訊最快的方式是 Unix domain socket。
為了防止 socket 檔案描述符(file descriptor)洩漏,我們設計了 RAII 資源管理器 SocketGuard,並透過 poll 機制設定 5 秒逾時,確保不會因 daemon 無回應而導致 CLI 永久卡死:
// src/client.cc
struct SocketGuard {
int fd{- 1 };
~ SocketGuard() {
if (fd >= 0 ) :: close(fd);
}
};
std:: expected< nlohmann:: json, std:: string> HerdrClient:: send_socket_request(
std:: string_view method,
const nlohmann:: json& params
) {
int fd = :: socket(AF_UNIX, SOCK_STREAM, 0 );
if (fd < 0 ) return std:: unexpected(std:: format("Socket creation error: {}" , errno));
SocketGuard guard{fd}; // 函式退出時保證自動 close(fd)
// ... 連線與發送 JSON-RPC 請求 ...
struct pollfd pfd{ .fd = fd, .events = POLLIN, .revents = 0 };
int pr = :: poll(& pfd, 1 , 5000 ); // 5 秒逾時保護
if (pr <= 0 ) {
return std:: unexpected(pr == 0 ? "Timeout waiting for herdr socket" : "Poll error" );
}
// 接收並解析 JSON 回應
// ...
}
若 socket 連線失敗(例如 herdr 守護程序未以 socket 模式啟動),HerdrClient 會自動無縫退回呼叫 herdr workspace list 與 herdr notification show 等 CLI 管道,保證外掛在任何環境下皆能 100% 正常運作。
6. FTXUI 互動式終端介面與 herdr 外掛整合
除了命令列指令,專案還透過 FTXUI 提供了即時互動預覽介面(herdr-sort-workspaces interactive)。
6.1 FTXUI 介面特色
┌─ Sorting Strategies ──────────────────┐┌─ Live Reorder Preview ──────────────────────────────┐
│ ● 1. Natural Alphanumeric (A-Z) ││ 1. w3 ★ ● working 2p/3t alpha-2 /a/2 +2 │
│ ○ 2. Pure Alphabetical (A-Z) ││ 2. w2 ▲ blocked 4p/2t alpha-10 /a/10 0 │
│ ○ 3. Agent Status (Active first) ││ 3. w4 ✓ done 5p/1t beta /b -1 │
│ ○ 4. Git Repo & Worktrees ││ 4. w1 ○ idle 1p/1t zebra /z -3 │
│ [ ] Reverse order (Z-A) │└─────────────────────────────────────────────────────┘
│ [X] Pin focused workspace to top │
│ [ Apply & Reorder ] [ Cancel ] │
└───────────────────────────────────────┘
[1-8] Quick Strategy • [r] Reverse • [f] Pin • [Enter] Apply • [q] Exit
左側策略選單 :支援即時選取 8 種排序策略,並提供反轉與焦點置頂 checkbox。
右側即時預覽表格 :每次變更選項,表格會即時計算重排後的結果,並在最右側以綠色 +2 或紅色 -1 標註每個工作區相對於原本位置的位置位移量(delta) 。
直覺快捷鍵 :按下數字鍵 [1-8] 即可瞬間切換排序策略;按下 [r] 快速切換升降序;按下 [f] 切換焦點置頂;按下 [Enter] 立即套用並向 herdr 派發重排指令。
6.2 宣告式外掛清單:herdr-plugin.toml
為了讓 herdr 的 command palette 能夠直接喚起排序功能,專案提供了標準的 herdr-plugin.toml 清單:
id = "herdr-sort-workspaces"
name = "herdr Workspace Sorter"
version = "0.1.0"
min_herdr_version = "0.7.0"
description = "Sort herdr workspaces alphabetically, by status, path, active state, or custom criteria in modern C++23."
platforms = ["linux" , "macos" ]
[[build ]]
command = ["cabin" , "build" , "--release" ]
[[actions ]]
id = "sort-natural"
title = "Sort Workspaces: Natural (A-Z)"
contexts = ["workspace" , "global" ]
command = ["./build/release/packages/herdr-sort-workspaces/herdr-sort-workspaces" , "sort" , "--by" , "natural" , "--notify" ]
[[actions ]]
id = "sort-status"
title = "Sort Workspaces: Agent Status (Active first)"
contexts = ["workspace" , "global" ]
command = ["./build/release/packages/herdr-sort-workspaces/herdr-sort-workspaces" , "sort" , "--by" , "status" , "--notify" ]
[[actions ]]
id = "interactive"
title = "Sort Workspaces: Interactive Sorter"
contexts = ["workspace" , "global" ]
command = ["./build/release/packages/herdr-sort-workspaces/herdr-sort-workspaces" , "interactive" ]
[[panes ]]
id = "picker"
title = "Sort Workspaces"
placement = "overlay"
command = ["./build/release/packages/herdr-sort-workspaces/herdr-sort-workspaces" , "interactive" ]
透過 herdr plugin link . 指令,herdr 會自動將這些 action 註冊到快捷鍵與命令面板中;排序完成後,還會呼叫 herdr 的桌面原生 toast 通知使用者。
7. 效能基準測試與 CI 自動化
當開發者或背景排程 agent 在 herdr 中託管數十甚至上百個工作區時,每次建立 worktree、切換焦點或狀態變更,排序演算法都會在即時路徑(hot path)上被呼叫。
7.1 為什麼需要嚴格的基準測試?
自然字母數字排序(natural sort)與標準字串字典序比較不同:它需要動態辨識連續數字分塊 、去除前導零 、比較有效數字長度 與處理平手仲裁 。
相較於 C++ 標準函式庫純逐字元比較的 std::less<std::string_view>,自然排序邏輯較為複雜。為了確保演算法在任何極端情境下皆維持零動態記憶體配置(zero heap allocation)與極致輸送量,我們設計了全方位的雙軌基準測試體系 :
Catch2 v3 微基準測試(microbenchmark) :整合於單元測試套件中,提供隔離的奈秒級微基準量測。
自研 C++23 高輸送量評測引擎(include/herdr/benchmark.hpp & src/benchmark.cc) :零外部相依、支援 9 種真實開發分佈、提供基線倍率對比(vs std::less),並原生支援 ANSI 彩色終端表格、Markdown、JSON 與 CSV 多種輸出格式。
flowchart TD
subgraph Engine ["⚡ C++23 高輸送量評測引擎 (include/herdr/benchmark.hpp)"]
direction TB
DataGen["🎲 9 種真實資料分佈 (Shuffled, Branches, SemVer...)"]
Warmup["🔥 暖身運行 (Warmup Runs)"]
Opt["🛡️ do_not_optimize (防止編譯器死碼消除)"]
Timer["⏱️ steady_clock 奈秒級統計量測 (Mean / Median / P95 / P99)"]
DataGen --> Timer
Warmup --> Timer
Opt --> Timer
end
subgraph Scope ["🎯 評測範疇 (3 大層級)"]
direction LR
Micro["1️⃣ Micro (Pairwise) 13 組極限字串兩兩比對"]
Sort["2️⃣ Dataset Sort N=10 ~ 20,000 陣列排序"]
E2E["3️⃣ Sorter E2E 策略排序 + 最小移動置換"]
Micro --> Sort --> E2E
end
subgraph Output ["📊 多元輸出與 CI 整合"]
direction LR
CLI_Out["🖥️ ANSI 終端表格 (bench CLI 子命令)"]
GHA["📈 GitHub Actions ($GITHUB_STEP_SUMMARY)"]
Art["📁 Artifacts 保存 (JSON & CSV 報告)"]
end
Engine ==>|驅動基準評測| Scope
Scope ==>|匯出評測報告| Output
classDef engNode fill:#0c4a6e,stroke:#38bdf8,stroke-width:2px,color:#f0f9ff;
classDef scpNode fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#e0e7ff;
classDef outNode fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#ecfdf5;
class Warmup,Timer,Opt,DataGen engNode;
class Micro,Sort,E2E scpNode;
class CLI_Out,GHA,Art outNode;
style Engine fill:#082f49,stroke:#0284c7,stroke-width:2px,color:#7dd3fc
style Scope fill:#1e1b4b,stroke:#6366f1,stroke-width:2px,color:#a5b4fc
style Output fill:#022c22,stroke:#10b981,stroke-width:2px,color:#6ee7b7
7.2 雙軌評測設計:Catch2 BENCHMARK_ADVANCED vs 自研引擎
1. Catch2 BENCHMARK_ADVANCED 與 Chronometer 隔離測量
在微基準測試中,最常見的陷阱是把「資料準備與容器複製」的時間算入演算法耗時 。Catch2 v3 的 Catch::Benchmark::Chronometer 允許我們在計時器啟動前預先配置與複製資料:
// tests/test_sorter.cc
BENCHMARK_ADVANCED("Sort 100 Shuffled Workspace Labels" )(Catch:: Benchmark:: Chronometer meter) {
// 預先在計時範圍外準備好 meter.runs() 份資料副本
std:: vector< std:: vector< std:: string>> storage(meter.runs());
for (auto & s : storage) s = shuffled_100;
// meter.measure 僅量測真正的演算法核心區塊
meter.measure([& ](int i) {
std:: ranges:: stable_sort(storage[i], herdr:: natural_less);
return storage[i].size();
});
};
2. 自研 C++23 評測引擎與 do_not_optimize
自研引擎利用內嵌組合語言指令,防止 Clang/GCC 在 -O3 發布最佳化時將純函式呼叫視為死碼(dead code elimination)而最佳化抹除:
// include/herdr/benchmark.hpp
template < typename T>
inline void do_not_optimize(const T& val) noexcept {
#if defined(__GNUC__) || defined(__clang__)
asm volatile ("" : : "g" (val) : "memory" );
#else
const volatile void * volatile p = static_cast < const void *> (& val);
(void )p;
#endif
}
7.3 涵蓋 9 大真實開發情境的資料集分佈
為了真實反映 herdr 在不同工作流程下的負載,測試引擎內建了 9 種資料分佈產生器(DataDistribution):
GitBranches :模擬真實 Git 分支與 worktree 命名(如 feat/issue-10, feat/issue-2, bugfix/auth-v2)。
SemVerTags :語意化版本標籤(如 v1.2.3, v1.10.0, v2.0.0-rc1)。
HierarchicalPaths :多層級 POSIX 工作目錄路徑(如 /var/log/audit/2026/08/node-1)。
LeadingZeros :帶有補零編號的日誌或產出檔(如 job_00042.log)。
Shuffled / AlreadySorted / ReverseSorted / NearlySorted90 / PureNumeric :涵蓋最佳情況(0 次反序)、最差情況($N(N-1)/2$ 次反序)、90% 局部有序以及純大數值比對。
7.4 實測數據與效能分析
以下為在 x86_64 Linux 環境(-O3 --release)測得的代表性基準數據:
1. 兩兩字串比對(micro pairwise)
測試場景
資料長度
平均耗時 (Mean)
輸送量 (Throughput)
vs 字典序 (std::less)
Numeric Suffix (workspace-2 vs workspace-10)
1 pair
46.51 ns
21.50 Mops/s
25.99x
Large Integer (run-999999999999999 vs 1000000000000000)
1 pair
28.02 ns
35.69 Mops/s
15.70x
SemVer Multi-Segment (v1.10.4-rc2 vs v1.2.18-rc1)
1 pair
13.21 ns
75.73 Mops/s
7.55x
Pure Numeric (123456789 vs 123456790)
1 pair
11.70 ns
85.49 Mops/s
6.69x
Leading Zeros Bias (item007 vs item7)
1 pair
21.27 ns
47.00 Mops/s
12.17x
💡 核心洞察 :單次自然比對僅需 11 ~ 46 奈秒(ns) ,每秒可處理 2,000 萬至 8,500 萬次 比較!
2. 資料集排序(dataset sort)與端到端排程(sorter E2E)
評測場景
資料規模 $N$
排序平均耗時
換算輸送量
vs 字典序開銷比
Workspaces (Shuffled)
10
1.41 µs
7.07 M items/s
5.07x
Workspaces (Shuffled)
50
13.04 µs
3.83 M items/s
6.34x
Git Branches / Worktrees
50
7.17 µs
6.97 M items/s
7.97x
Semantic Versions (SemVer)
50
5.91 µs
8.47 M items/s
3.09x
sort_workspaces (NaturalAsc)
50
19.35 µs
2.58 M items/s
1.01x
calculate_reorder_moves
50
12.17 µs
4.11 M items/s
—
在一般的 herdr 使用情境(10 ~ 50 個工作區)下:
完整的自然排序僅耗時 13 ~ 19 微秒(µs) (相當於每秒能完成 5 萬次以上完整工作區重排)。
最小置換路徑計算僅需 12 微秒(µs) 。
相較於 Unix domain socket 的 IPC 通訊延遲(約 100 ~ 500 µs)或終端渲染重繪時間(數毫秒),排序演算法本身的 CPU 開銷完全可以忽略不計(亞毫秒級別)。
7.5 獨立 CLI 工具與 GitHub Actions CI 自動化整合
專案將基準測試包裝為兩個靈活的入口:
專屬評測二進位檔 :cabin run --release --bin bench-natural-sort -- --category all(支援 --markdown、--json、--csv)。
主程式子命令 :herdr-sort-workspaces bench --sizes 10,50,200。
在 GitHub Actions CI(.github/workflows/ci.yml)中,每次 PR 與 push 都會自動在 GCC 14 與 Clang 18 release 模式下執行評測管線:
# .github/workflows/ci.yml
- name : Run Natural Sort performance benchmarks
run : |
echo "## Natural Sort Benchmark Report (${{ matrix.compiler }})" >> $GITHUB_STEP_SUMMARY
./build/release/packages/herdr-sort-workspaces/bench-natural-sort --category all --sizes 10,50,200,1000 --markdown >> $GITHUB_STEP_SUMMARY
./build/release/packages/herdr-sort-workspaces/bench-natural-sort --category all --sizes 10,50,200,1000 --json > benchmark-results-${{ matrix.compiler }}.json
./build/release/packages/herdr-sort-workspaces/bench-natural-sort --category all --sizes 10,50,200,1000 --csv > benchmark-results-${{ matrix.compiler }}.csv
- name : Run Catch2 benchmark microbenchmarks
run : |
./build/release/packages/herdr-sort-workspaces/test-sorter "[!benchmark]"
- name : Upload benchmark reports
uses : actions/upload-artifact@v4
with :
name : benchmark-results-${{ matrix.compiler }}
path : |
benchmark-results-${{ matrix.compiler }}.json
benchmark-results-${{ matrix.compiler }}.csv
CI 的自動化成果:
$GITHUB_STEP_SUMMARY 即時儀表板 :自動將 Markdown 格式的完整評測表格附加在每輪 GitHub Actions 摘要頁面上,開發者無需點進 log 即可一目了然。
歷史數據歸檔(artifacts) :自動將 JSON 與 CSV 格式的評測數據打包上傳,方便追蹤跨編譯器(GCC 14 vs Clang 18)與版本演進時的效能趨勢。
8. 完整測試與跨編譯器 CI (GCC 14 + Clang 18)
系統工具的穩定性至關重要。專案透過 Catch2 v3 建立了詳盡的單元測試套件(位於 tests/test_sorter.cc),包含:
自然排序測試 :驗證遞移律(Transitivity: $a < b \land b < c \implies a < c$)、前導零仲裁、符號與分支路徑、以及超過 30 位的超長數值比對。
策略排序測試 :驗證 8 種策略的升降序行為、多窗格與多分頁排序、Git worktree 歸屬排序。
邊界情況測試 :空工作區列表、單一工作區、標籤為空時的 ID 回退機制、重複標籤時的穩定性。
置換模擬測試 :驗證 10 元素反轉與隨機洗牌置換模擬,確保計算出的 calculate_reorder_moves 在逐步套用後,狀態嚴格等於預期目標。
在 GitHub Actions CI(.github/workflows/ci.yml)中,我們在 Ubuntu 24.04 上建立了雙編譯器矩陣:
# .github/workflows/ci.yml
strategy :
matrix :
compiler : [gcc, clang]
include :
- compiler : gcc
cc : gcc-14
cxx : g++-14
- compiler : clang
cc : clang-18
cxx : /usr/local/bin/clang++-libcxx # 使用 clang++-18 -stdlib=libc++ 封裝腳本
透過快取 Cabin 二進位檔與 ports 快取目錄(~/.cache/cabin),整個 CI 矩陣在 GCC 14 與 Clang 18(搭配 LLVM libc++)下,從下載依賴、編譯到執行全部 185+ 測試斷言僅需不到 30 秒。
9. 結語與反思
透過開發 herdr-sort-workspaces,我對現代 C++ 的開發體驗有了全新的體認:
C++ 不再等於「繁瑣的 CMake」 :Cabin 證明了 C++ 也能擁有如同 Rust Cargo 般愉悅的相依套件管理與建置體驗。聲明式 TOML 讓專案設定清晰明瞭,新手與老手都能在幾秒鐘內輕鬆上手。
C++23 讓系統程式更加安全且優雅 :std::expected 終結了錯誤碼與例外之爭;std::ranges 與 std::lexicographical_compare_three_way 讓演算法更加精練;std::string_view 與 std::span 則在維持極致效能的同時避免了記憶體浪費。
強大且成熟的開源生態 :從 nlohmann_json 的優雅序列化,到 CLI11 的健全命令列解析,再到 FTXUI 的終端互動體驗,現代 C++ 社群的基礎設施已非常健全。
如果你也在使用 herdr 管理你的日常開發與 AI agent 工作區,歡迎試用並將專案 clone 下來體驗:
MiniCompose
手刻最小化 Jetpack Compose 渲染引擎、雙進程畫面隔離技術與 1000 節點微秒級動態基準測試
在上一篇文章 中,我們從 AndroidX 原始碼的視角,深入剖析了 Jetpack Compose 的底層渲染管線與動畫硬體加速原理。
不過,讀懂原始碼與自己真正掌握架構之間,往往隔著一層「動手做」的距離。為了驗證這些架構設計在真實運行時的表現,我用 Kotlin 從零手刻了一個最小化的教育型 Compose 渲染引擎:MiniCompose 。
這個專案不依賴任何 AndroidX Compose 函式庫,也不包含編譯器外掛(compiler plugin)或複雜的響應式狀態系統,而是專注於實現支撐 Compose 高效能渲染的 5 大關鍵架構決策 。更進一步地,為了徹底排除同一執行緒排程干擾與虛擬機垃圾回收(GC)對效能比較的污染,MiniCompose 引入了 Android 跨進程畫面嵌入技術(Multi-Process Embedded Rendering) ,在單一視窗中左右並排運行兩個完全隔離的 OS 獨立進程 ,進行千節點規模的微秒級(µs)即時基準測試。
1. 雙進程(Multi-Process)即時效能基準測試與實機展示
在深入底層實作前,我們先來看這個實驗 App 的對比設計與最新實機動態演示。
在 Android UI 開發中,移動一個元件通常有兩種常見方式:
Modifier.graphicsLayer :在繪製階段(Draw Phase)透過硬體層做幾何變換。
Modifier.offset :在排版階段(Layout Phase)修改座標位置。
以往若在同一個 Activity 或同一個進程內同時跑兩種極端負載的動畫,右側高密度的排版計算與頻繁產生的短生命週期物件,容易引發全進程的 ART 虛擬機 GC 暫停(Stop-The-World),或者霸佔主執行緒的 Choreographer,進而拖累左側的幀率。
為了解決這個干擾,MiniCompose 將左右兩側拆分為兩個獨立的 Linux OS 進程 :
flowchart LR
subgraph SingleWindow ["單一視窗:雙進程隔離即時基準測試"]
direction LR
subgraph LeftProc ["⚡ 左側進程 (:left_gpu / PID X)"]
direction TB
GPUCard["GPU 硬體繪製卡片100 / 500 / 1000 Nodes "]
GPULayout["Layout Phase: 0 µs ✓ 0 Passes / 秒(跳過 measureAndLayout)"]
GPUDraw["Draw Phase: ~280 µs ✓ 鎖定 60~62 FPS 絲滑運作"]
GPUCard --> GPULayout --> GPUDraw
end
subgraph RightProc ["⚠️ 右側進程 (:right_cpu / PID Y)"]
direction TB
CPUCard["CPU 排版計算卡片100 / 500 / 1000 Nodes "]
CPULayout["Layout Phase: ~14,700 µs ⚠️ 44 Passes / 秒(每幀全樹重排重測)"]
CPUDraw["Draw Phase: ~6,580 µs ⚠️ 總幀耗時 ~21 ms(幀率掉至 44 FPS)"]
CPUCard --> CPULayout --> CPUDraw
end
LeftProc ~~~ RightProc
end
style LeftProc fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46
style RightProc fill:#fff1f2,stroke:#f43f5e,stroke-width:2px,color:#881337
style GPUCard fill:#ffffff,stroke:#34d399,stroke-width:1.5px,color:#065f46
style CPUCard fill:#ffffff,stroke:#fb7185,stroke-width:1.5px,color:#881337
style GPULayout fill:#d1fae5,stroke:#059669,stroke-width:1px,color:#064e3b
style CPULayout fill:#ffe4e6,stroke:#e11d48,stroke-width:1px,color:#9f1239
style GPUDraw fill:#d1fae5,stroke:#059669,stroke-width:1px,color:#064e3b
style CPUDraw fill:#ffe4e6,stroke:#e11d48,stroke-width:1px,color:#9f1239
Your browser does not support the video tag.
實驗設計與即時控制維度
OS 級進程隔離(Process Isolation) :左側運行於 :left_gpu(PID 32691),右側運行於 :right_cpu(PID 32714),兩者擁有獨立的 ART 虛擬機堆積、Main Looper 與 RenderThread。
多層級節點樹(Tree Complexity) :支援即時切換 100 節點 、500 節點 與 1000 節點 的 LayoutNode 階層樹,每個節點包含文字量測(Paint.measureText)與 Flex 排版約束計算。
排版延遲注入(Layout Delay) :支援注入 0ms / 8ms / 20ms 的主執行緒排版負載,模擬重度業務計算。
繪製負載注入(Draw Load) :支援 Normal / +150 DL / +300 DL (Display List 繪製路徑),測試 GPU / CPU 繪製極限。
微秒級遙測抬頭顯示(HUD) :利用 System.nanoTime() 分別量測排版階段(Layout Phase)與繪製階段(Draw Phase)消耗的微秒時間,並跨進程透過 Binder IPC 即時匯總至主畫面。
實測數據與真實日誌分析
在開啟 1000 節點、8ms Layout Delay 與 +300 DL 的重度壓力測試下,我們從實機 HUD 與 Logcat 擷取到微秒級效能數據:
評測維度
⚡ Modifier.graphicsLayer (:left_gpu / PID 32691)
⚠️ Modifier.offset (:right_cpu / PID 32714)
效能差異與架構洞察
運作幀率 (FPS)
62 FPS (穩定維持滿幀)
44 FPS (掉幀、明顯卡頓)
左側完全不受右側進程卡頓影響
Layout Phase 耗時
0 µs (0 passes/s,完全跳過)
14,733 µs (~14.7 ms) (44 passes/s)
graphicsLayer 節省 100% 排版開銷
Draw Phase 耗時
~275 – 280 µs
~6,581 µs (~6.6 ms)
graphicsLayer 重用 Display List,無額外重錄開銷
單幀總 CPU 耗時
~280 µs (0.28 ms)
~21,314 µs (21.3 ms)
每幀節省超過 21,000 µs (21 ms) !
📌 關鍵實測日誌洞察與基準測試說明 :
跳過排版階段 :graphicsLayer 在動畫期間完全跳過 measureAndLayout()(0 passes/s),Layout Phase 耗時嚴格為 0 µs ;在 1000 節點規模下,僅需在 Draw Phase 耗時實測約 ~280 µs 進行屬性更新與繪製分發。
基準測試路徑說明(Caveat) :在 MiniCompose 基準測試中,右側 offset 每一幀都會標記整棵子樹 Dirty 並遍歷重算;這是為了演示極限排版開銷上限而刻意設計的最壞路徑(Worst-case Demo Path),並非真實 Jetpack Compose 中 Modifier.offset 的局部排版(Localized Relayout)預設行為 。
進程隔離帶來的純粹性 :當右側進程被 1000 節點重排與延遲塞滿、單幀耗時飆至 21.3 ms(幀率跌至 44 FPS)時,左側進程依然絲滑地以 62 FPS 高速旋轉與移動,驗證了硬體加速圖層在多進程/多執行緒下的抗干擾能力。
2. 核心技術解析:如何在單一視窗中以雙進程/多 Activity 隔離渲染畫面?
許多讀者可能會好奇:Android 原生介面中,如何讓兩個完全不同的 Linux 進程,把各自的 UI 同時渲染在同一個 Activity 的同一個視窗畫面上?
MiniCompose 結合了 Android 11 (API 30+) 引入的 SurfaceControlViewHost 與傳統的 SurfaceView + Binder IPC ,實現了這套跨進程畫面無縫嵌入架構。
2.1 為什麼需要雙進程隔離?
在常規 Android 開發中,所有 View 都運行在同一個主執行緒(UI Thread)與同一個 ART 虛擬機進程中。若要公正地對比「高負載 CPU 計算」與「GPU 硬體加速」:
共享 UI Looper 污染 :CPU 端的繁重排版會延遲 Choreographer.doFrame(),使同一畫面上的 GPU 動畫也被迫延遲掉幀。
共享 GC 暫停 :大量短命物件的頻繁分配會觸發全進程的垃圾回收暫停(GC Pause),干擾微秒級量測。
將兩者拆分至 :left_gpu 與 :right_cpu 獨立進程後,每個進程擁有自己的 Linux PID、獨立的虛擬機堆積、專屬的 Main Looper 與 RenderThread,達成了物理級別的效能隔離。
2.2 跨進程畫面嵌入架構:SurfaceControlViewHost
在 AndroidManifest.xml 中,我們宣告了兩個獨立進程:
<!-- Process 1: 主介面與左側 GPU Activity (:left_gpu) -->
<activity
android:name= ".MainActivity"
android:process= ":left_gpu"
android:hardwareAccelerated= "true" />
<!-- Process 2: 右側 CPU 渲染服務與獨立 Activity (:right_cpu) -->
<service
android:name= ".RightCpuService"
android:process= ":right_cpu"
android:exported= "false" />
<activity
android:name= ".RightCpuActivity"
android:process= ":right_cpu"
android:resizeableActivity= "true" />
整個跨進程渲染與控制的 IPC 握手流程如下:
sequenceDiagram
autonumber
participant Host as MainActivity (:left_gpu)
participant SF as SurfaceFlinger (System Compositor)
participant Remote as RightCpuService (:right_cpu)
Note over Host: 1. 建立 SurfaceView 並取得 hostToken
Host->>Remote: bindService() + Binder.transact(TRANSACTION_CREATE_SURFACE, hostToken, w, h)
Note over Remote: 2. 初始化 SurfaceControlViewHost(display, hostToken)
Note over Remote: 3. 將 MiniComposeView 設為 Root View
Note over Remote: 4. 取出 SurfacePackage (封裝 Remote SurfaceControl)
Remote-->>Host: Binder Parcel 回傳 (PID, SurfacePackage)
Note over Host: 5. surfaceView.setChildSurfacePackage(surfacePackage)
Host->>SF: 註冊跨進程圖層混合
Remote->>SF: :right_cpu RenderThread 直接送幀
Host->>SF: :left_gpu RenderThread 直接送幀
SF-->>Host: SurfaceFlinger 同步合成至單一螢幕!
rect rgb(240, 249, 255)
Note over Host, Remote: 6. 跨進程雙向互動與遙測同步 (Binder IPC)
Host->>Remote: transact(TRANSACTION_SET_COMPLEXITY / DELAY / LOAD)
Host->>Remote: transact(TRANSACTION_GET_STATS) -> 回傳 (FPS, LayoutUs, DrawUs)
end
2.3 核心程式碼實作
步驟 1:主端(:left_gpu)配置 SurfaceView 並發送 hostToken
在 MainActivity 中,右半部配置一個 SurfaceView,當 Surface 就緒後,將它的 hostToken 透過 Binder 傳給遠端服務:
// MainActivity.kt (:left_gpu process)
rightSurfaceView = SurfaceView(this ).apply {
setZOrderMediaOverlay(true )
holder.setFormat(PixelFormat .TRANSLUCENT)
holder.addCallback(object : SurfaceHolder .Callback {
override fun surfaceCreated (holder: SurfaceHolder) {
attachSurfaceIfReady()
}
override fun surfaceChanged (holder: SurfaceHolder, format: Int, width: Int, height: Int) {
attachSurfaceIfReady()
}
override fun surfaceDestroyed (holder: SurfaceHolder) {}
})
}
private fun attachSurfaceIfReady () {
val service = rightServiceBinder ?: return
val hostToken = rightSurfaceView.hostToken ?: return
val w = rightSurfaceView.width
val h = rightSurfaceView.height
if (w <= 0 || h <= 0 ) return
val data = Parcel .obtain()
val reply = Parcel .obtain()
try {
data .writeStrongBinder(hostToken)
data .writeInt(w)
data .writeInt(h)
service.transact(RightCpuService .TRANSACTION_CREATE_SURFACE, data , reply, 0 )
reply.readException()
rightPid = reply.readInt()
val hasPackage = reply.readInt()
if (hasPackage != 0 ) {
val surfacePackage = SurfaceControlViewHost .SurfacePackage .CREATOR .createFromParcel(reply)
// 將遠端進程的畫面包掛載進本地 SurfaceView!
rightSurfaceView.setChildSurfacePackage(surfacePackage)
}
} finally {
data .recycle()
reply.recycle()
}
}
步驟 2:遠端(:right_cpu)建立 SurfaceControlViewHost 並回傳 SurfacePackage
在 RightCpuService 內部,收到 hostToken 後建立 SurfaceControlViewHost,並將耗費 CPU 計算的 MiniComposeView 掛載進去:
// RightCpuService.kt (:right_cpu process)
@RequiresApi (Build .VERSION_CODES .R)
private fun createEmbeddedViewHierarchy (
hostToken: IBinder,
width: Int,
height: Int
): SurfaceControlViewHost .SurfacePackage? {
val displayManager = getSystemService(Context .DISPLAY_SERVICE) as DisplayManager
val display = displayManager.getDisplay(Display .DEFAULT_DISPLAY)
// 建立跨進程 View 宿主
val newHost = SurfaceControlViewHost(this , display, hostToken)
this .host = newHost
val newComposeView = MiniComposeView(this )
this .composeView = newComposeView
// 將 Compose 樹掛載至遠端 Host
newHost.setView(newComposeView, width, height)
setupComposeTree(width, height)
startAnimation()
// 取得可跨進程序列化傳輸的 SurfacePackage
return newHost.surfacePackage
}
步驟 3:跨進程 Binder 遙測與互動控制
為了讓主畫面的控制按鈕(如 100/500/1000 節點切換、延遲注入等)與 HUD 統計數據即時同步,兩進程間定義了一組輕量的 Binder Transaction:
// 跨進程控制碼定義
const val TRANSACTION_CREATE_SURFACE = 1
const val TRANSACTION_SET_COMPLEXITY = 2
const val TRANSACTION_SET_LAYOUT_DELAY = 3
const val TRANSACTION_GET_STATS = 4
const val TRANSACTION_SET_DRAW_LOAD = 5
const val TRANSACTION_SET_ANIMATING = 6
MainActivity 每秒定期呼叫 TRANSACTION_GET_STATS,跨進程讀取 :right_cpu 的 FPS 與微秒耗時,並繪製在主螢幕的 HUD 面板上。
補充:多 Activity 原生分割畫面(Split-Screen)
除了 SurfaceControlViewHost 視窗內嵌入外,專案中也提供了獨立的 RightCpuActivity 。透過設定 android:resizeableActivity="true" 與 launchMode="singleTask",在 Android 平板或多重視窗模式下,系統可以同時以左右分割畫面運行 MainActivity (:left_gpu) 與 RightCpuActivity (:right_cpu),同樣享有 100% 的進程與繪製隔離。
3. MiniCompose 的 5 大核心架構實作
除了雙進程隔離技術,MiniCompose 的核心價值在於用最精簡的 Kotlin 程式碼,完整還原 Jetpack Compose 團隊在渲染管線上的 5 大關鍵設計決策。
決策 1:為什麼 ComposeView 與 AndroidComposeView 必須是 ViewGroup?
在傳統 View 系統中,如果我們要客製化一個純粹繪製內容的元件,通常繼承 View 即可。但 Compose 的進入點卻是兩個 ViewGroup:
flowchart TD
AVTree["Android 原生 View 樹狀結構"] --> MCV["MiniComposeView (ViewGroup) • 對外公開的 API 容器 • 攔截非法 addView()"]
MCV -->|唯一合法子 View| MACV["MiniAndroidComposeView (ViewGroup) • 內部核心 Bridge & 樹狀結構 Owner"]
MACV -->|持有與調度| RootNode["Root LayoutNode • Compose 元件樹根節點"]
MACV -->|持有與管理| AVHandler["AndroidViewsHandler • 託管 AndroidView 嵌入的原生元件"]
style AVTree fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#0f172a
style MCV fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
style MACV fill:#fff7ed,stroke:#f97316,stroke-width:2px,color:#7c2d12
style RootNode fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46
style AVHandler fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#334155
其背後原因有兩個:
API 封裝 :MiniComposeView 對外暴露給開發者使用,它必須是一個 ViewGroup 才能透過 addView() 將唯一的內部核心元件 MiniAndroidComposeView 掛載進去。同時,它覆寫了公開的 addView() 方法,禁止外部隨意新增一般 View:
override fun addView (child: View?) {
if (!creatingComposition) {
throw UnsupportedOperationException(
"Cannot add views to MiniComposeView; use setContent {} instead."
)
}
super .addView(child)
}
互操作性(Interop) :當我們在 Compose 中使用 AndroidView 嵌入傳統原生元件(如 WebView 或 MapView)時,這些原生元件必須存在於 Android View 樹狀結構中。MiniAndroidComposeView 作為 ViewGroup,才能在內部建立一個 AndroidViewsHandler 來持有並管理這些原生子 View。
決策 2:空的 onDraw() 與攔截 dispatchDraw() 的 Z-Order 奧秘
在 Android 中,一個 View 的完整繪製流程如下:
$$\text{drawBackground()} \longrightarrow \text{onDraw()} \longrightarrow \text{dispatchDraw()} \longrightarrow \text{onDrawForeground()}$$
其中,onDraw() 是用來畫 View 自己的內容,而 dispatchDraw() 則是 ViewGroup 用來派發並繪製所有子 View。
💡 核心洞察 :如果 Compose 選擇在 onDraw() 中繪製 LayoutNode 元件樹,那麼隨後在 dispatchDraw() 繪製的原生嵌入元件(如 AndroidView)將會永遠覆蓋在 Compose UI 之上 ,破壞畫面的圖層順序(Z-ordering)。
因此,MiniAndroidComposeView 採取了明確的繪製順序:
將 onDraw() 留空 ,不在這個階段做任何繪製。
覆寫 dispatchDraw(canvas) :先畫完整棵 LayoutNode 樹,再調用 super.dispatchDraw(canvas) 繪製原生子 View :
class MiniAndroidComposeView (context: Context) : ViewGroup(context) {
val root = LayoutNode("Root" )
private val canvasHolder = CanvasHolder()
// 刻意留空!防止內容被 dispatchDraw 繪製的子 View 覆蓋
override fun onDraw (canvas: Canvas) {}
override fun dispatchDraw (canvas: Canvas) {
// 1. 若節點樹 Dirty,執行排版與量測計時
if (root.needsLayout || root.hasDirtyDescendants) {
val startNs = System .nanoTime()
val didWork = root.measureAndLayout(width, height)
if (didWork) {
layoutPassCount++
lastLayoutTimeUs = (System .nanoTime() - startNs) / 1000L
}
}
// 2. 在 dispatchDraw 中繪製整個 Compose LayoutNode 樹
val drawStartNs = System .nanoTime()
canvasHolder.drawInto(canvas) {
root.draw(this )
}
lastDrawTimeUs = (System .nanoTime() - drawStartNs) / 1000L
// 3. 接著讓 ViewGroup 繪製嵌入的原生 View
super .dispatchDraw(canvas)
}
}
決策 3:CanvasHolder 帶來的零物件分配(Zero-Allocation)
在 60 FPS 或 120 FPS 的高頻繪製迴圈中,任何短生命週期物件的頻繁分配,都會給垃圾回收器(Garbage Collector)帶來巨大壓力,導致 micro-stutter 卡頓。
Android 原生傳入 draw() 的是 android.graphics.Canvas,而 Compose 內部使用的是跨平台的 Canvas 抽象封裝。如果每一幀、每個節點都 new CanvasWrapper(canvas),GC 將不堪負荷。
MiniCompose 透過 CanvasHolder 模式解決了這個問題:
class CanvasHolder {
// 預先配置單一可重複使用的 MiniCanvas 實例
val miniCanvas = MiniCanvas()
inline fun drawInto (targetCanvas: Canvas, block: MiniCanvas .() -> Unit) {
miniCanvas.internalCanvas = targetCanvas
try {
miniCanvas.block()
} finally {
miniCanvas.internalCanvas = null
}
}
}
透過 inline 函式與內部引用置換,整個繪製管線在每一幀的物件分配數量嚴格為 0 。
決策 4:GraphicsLayer 與硬體 RenderNode 的記憶體分離
在 Android 10 (API 29+) 中,Google 開放了原生 C++ android.graphics.RenderNode API。MiniCompose 的 GraphicsLayer 正是封裝了這顆硬體加速的核心。
一個 RenderNode 在記憶體中被精確拆分為兩個獨立部分:
flowchart TD
subgraph RN ["RenderNode (Native C++ 物件結構)"]
direction TB
subgraph HP ["1. Header Properties (可變資料,更新耗時 < 1 µs)"]
direction TB
HPList["• translationX, translationY • scaleX, scaleY • rotationX, rotationY, rotationZ • alpha, elevation, pivotX, pivotY"]
end
subgraph DL ["2. Display List (繪製指令,錄製完成後不可變)"]
direction TB
DLList["• drawRect(0, 0, 100, 100) • drawText('Hello') • drawBitmap(...)"]
end
end
HP -.->|硬體矩陣變換| GPU["GPU RenderThread (直接套用 4x4 矩陣,重播 Display List)"]
DL -->|無需重新錄製| GPU
style RN fill:#f8fafc,stroke:#334155,stroke-width:2px,color:#0f172a
style HP fill:#eff6ff,stroke:#3b82f6,stroke-width:1.5px,color:#1e3a8a
style DL fill:#f1f5f9,stroke:#64748b,stroke-width:1.5px,color:#334155
style HPList fill:#ffffff,stroke:#93c5fd,stroke-width:1px,color:#1e3a8a
style DLList fill:#ffffff,stroke:#cbd5e1,stroke-width:1px,color:#334155
style GPU fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46
當我們在 MiniCompose 中使用 graphicsLayer 更新動畫時:
node.graphicsLayerBlock = { layer ->
// 直接寫入 native RenderNode 的 C++ 記憶體欄位!
// 耗時 < 1 微秒,不觸發 Display List 重錄,不觸發 Re-layout
layer.translationY = currentY
layer.rotationZ = animationProgress * 360f
}
繪製時,GPU 上的 RenderThread 可以直接套用新的 4×4 矩陣,重播完全沒有變動的 Display List。在動畫期間,graphicsLayer 做到不重排(跳過 Layout Phase)且不重錄 Display List ;即使在 1000 節點的規模下,每一幀也僅需在 Draw Phase 消耗實測約 ~280 µs 即可完成全部節點的屬性更新與繪製分發。
決策 5:Modifier.graphicsLayer vs Modifier.offset 的本質對比
Compose 的渲染管線包含三個階段:
$$\text{Composition (組件組合)} \longrightarrow \text{Layout (量測與擺放)} \longrightarrow \text{Draw (畫布繪製)}$$
比較維度
Modifier.graphicsLayer
Modifier.offset
執行階段
Draw Phase(繪製階段)
Layout Phase(排版階段)
底層動作
直接更新 native RenderNode 的 Float 欄位
標記 needsLayout = true,更新座標
節點樹負擔
0 節點遍歷 (跳過全樹重新排版)
遞迴遍歷整棵樹,重新量測子節點約束
Display List
完全不重新錄製 ,Display List 保持重用
標記 Dirty,整棵樹重新錄製繪製指令
1000 節點耗時
Layout: 0 µs / 總耗時: ~280 µs (節省 98%)
Layout: ~14.7 ms / 總耗時: ~21.3 ms
4. MiniCompose 專案模組結構
整個 MiniCompose 專案結構清晰,模組分工如下:
app/src/main/
├── AndroidManifest.xml # 宣告 :left_gpu 與 :right_cpu 雙獨立進程
└── java/com/example/minicompose/
├── CanvasHolder.kt # 零物件分配 Canvas 轉接器
├── GraphicsLayer.kt # 封裝 RenderNode 的硬體繪製圖層與 header 屬性更新
├── LayoutNode.kt # Compose 節點樹結構、Measure/Layout Policy 與繪製分發
├── MiniComposeView.kt # 對外公開的 MiniComposeView 與內部 Bridge MiniAndroidComposeView
├── MainActivity.kt # 雙進程協調器(運行於 :left_gpu,透過 SurfaceControlViewHost 嵌入右側畫面)
├── RightCpuService.kt # 遠端渲染服務(運行於 :right_cpu,提供 SurfacePackage 與 IPC 遙測數據)
└── RightCpuActivity.kt # 獨立 CPU 測試 Activity(支援 Android 原生多重視窗分割畫面)
5. 結語與學習心得
透過從零手刻 MiniCompose 並整合 Android 雙進程跨視窗渲染架構,我們獲得了兩個層面的深刻體悟:
Compose 架構設計的精準取捨 :ComposeView 繼承 ViewGroup 與清空 onDraw(),是為了在享受宣告式 UI 開發體驗的同時,兼顧與傳統 View 系統 100% 的 Z-Order 互操作性;而 CanvasHolder 與 GraphicsLayer 則是對 Android HWUI / RenderThread 底層管線的極致效能榨取。
進程隔離帶來的純粹基準測試 :透過 SurfaceControlViewHost,我們得以在單一視窗內將兩個截然不同的渲染策略隔絕在獨立的 Linux 進程中。無論右側的排版負載多麼沉重、引發多少 GC 暫停,左側的硬體加速動畫依然能以 60+ FPS 絲滑運轉。
如果你也想親自把玩這套雙進程架構並體驗微秒級的效能數據,歡迎造訪 GitHub - p47t/minicompose ,將專案 clone 下來在 Android Studio 中打開並運行於實機上!
在 Rust 中實驗本機語音助理
整合 Gemma-4 多模態模型、WebRTC APM 與 openWakeWord (Rust 52 Projects #52)
在桌面上運行一個簡易的語音助理,一直是我很想嘗試的學習專案。市面上的商業語音助理(如 Alexa 或 Siri)背後有極為龐大的雲端基礎設施與複雜的工程團隊;但作為個人學習 Rust 與本機 AI 技術的練習,我們也可以用幾百行 Rust 程式碼,把麥克風音訊擷取、WebRTC 降噪、喚醒詞辨識、多模態 LLM 與語音合成組裝成一個本機運行的終端機實驗原型。
這就是 voice-assistant 專案的由來,也是 Rust 52 Projects 挑戰的第 52 篇專案(完結篇)。
這個專案的主要目的是學習如何整合各個獨立的 Rust crate 與 C/C++ FFI 綁定 。特別的是,為了實驗多模態模型的可能性,專案嘗試繞過了傳統「語音轉文字 (STT, Whisper) $\to$ 文字 LLM $\to$ 文字轉語音 (TTS)」的三階段流程,改為將麥克風錄製的語音音訊直接作為多模態向量(audio tensor embedding)輸入給 Gemma-4 Multimodal Audio 模型,進行端到端推論。
這篇文章記錄了這個實驗專案的架構設計、模組組合與學習心得。
1. 專案學習重點與組件構成
作為一個學習練習,這個專案將幾個常見的語音處理組件整合在一起:
本機流程實驗 (Local Pipeline) :全程在本地電腦執行麥克風採樣、降噪、喚醒詞辨識與模型推論,方便在沒有網路連線時進行測試與調試。
WebRTC APM 音訊預處理 (Noise Suppression & AGC2) :利用 sonora 封裝的 WebRTC 引擎,將麥克風採樣率重採樣至 16kHz 單聲道,並進行基礎的背景雜音消除與自動增益調整。
喚醒詞偵測 (openWakeWord) :使用 oww-rs 在背景持續監聽 1280 個樣本 (sample) 的音訊視窗,判定是否觸發喚醒詞(預設支援 Alexa 或 Hey Mycroft 測試模型)。
基礎 RMS 能量 VAD (Voice Activity Detection) :喚醒後進入錄音模式,以 10ms (160 樣本) 視窗計算音訊方均根 (RMS) 能量,當持續低於門檻 1.5 秒或達到 8 秒上限時停止錄音。
多模態語音推論嘗試 :透過 llama-cpp-4 與 Vulkan GPU 加速加載 Gemma-4 多模態模型 (gemma-4-E4B-it + mmproj-gemma-4-E4B-it-BF16.gguf),將 WAV 音訊轉為向量直接由 LLM 進行解碼與串流生成。
原生 PowerShell TTS 與簡單迴路防護 :呼叫 Windows PowerShell 內建的 System.Speech 進行語音朗讀;並在朗讀結束前清理輸入緩衝區,避免麥克風收錄到喇叭播放的聲音而造成誤觸發。
2. 系統狀態機架構(state machine)
程式的主體架構是一個簡單的狀態機(state machine),負責在不同的音訊處理階段之間進行切換:
%%{init: { 'themeVariables': { 'fontSize': '16px', 'subGraphTitleFontSize': '18px' } }}%%
flowchart TD
subgraph Listening ["1. State::Listening (背景監聽)"]
direction TD
A1["cpal 麥克風音訊擷取 (16kHz 重採樣)"] --> A2["WebRTC APM (降噪 NS + AGC2 增益)"]
A2 --> A3["openWakeWord (1280 樣本視窗比對)"]
end
subgraph Recording ["2. State::Recording (使用者口述錄音)"]
direction TD
B1["10ms 視窗計算 RMS 音訊能量"] --> B2["終端機即時繪製彩色音量波形條"]
B2 --> B3["VAD 判定: 靜音 > 1.5s 或 時間 > 8.0s"]
end
subgraph Processing ["3. State::Processing (Gemma-4 多模態推論)"]
direction TD
C1["寫入錄音至 temp_query.wav"] --> C2["Gemma-4 Multimodal Projector (Audio Embedding)"]
C2 --> C3["llama-cpp-4 (Vulkan GPU 加速串流生成)"]
end
subgraph Speaking ["4. State::Speaking (語音反饋與重置)"]
direction TD
D1["Windows PowerShell System.Speech 語音朗讀"] --> D2["清空音訊緩衝區 & 防範自激迴音"]
end
A3 -->|"偵測到喚醒詞 (High Beep)"| Recording
B3 -->|"錄音完成 (Low Beep)"| Processing
C3 -->|"推論完成"| Speaking
D2 -->|"恢復背景監聽"| Listening
3. 核心模組實現拆解
3.1 音訊擷取與 WebRTC APM 降噪預處理 (preprocessor.rs)
不同麥克風設備的預設採樣率(例如 44.1kHz 或 48kHz)與聲道數各不相同。AudioPreprocessor 的作用是將這些異構音訊轉化為 openWakeWord 與 LLM 模型所需的 16,000 Hz 單聲道格式 ,並經過 WebRTC 的降噪與增益模組處理:
pub struct AudioPreprocessor {
input_sample_rate: u32 ,
apm: Option< AudioProcessing> ,
mic_samples_accumulator: Vec< f32 > ,
apm_input_buffer: Vec< f32 > ,
}
impl AudioPreprocessor {
pub fn feed_and_process (& mut self, raw_samples: & [f32 ], channels: usize ) -> Result< Vec< f32 >> {
if raw_samples.is_empty() { return Ok(Vec::new()); }
// 1. 單聲道混音 (Channel Mixing)
let mono_samples = convert_channels_to_mono(raw_samples, channels);
self.mic_samples_accumulator.extend_from_slice(& mono_samples);
// 2. 線性重採樣至 16,000 Hz
let mic_samples_needed = (160.0 * (self.input_sample_rate as f32 / 16000.0 )).ceil() as usize ;
while self.mic_samples_accumulator.len() >= mic_samples_needed {
let chunk_to_resample = self.mic_samples_accumulator.drain(0 .. mic_samples_needed).collect::< Vec< _>> ();
let resampled_16k = resample_linear(& chunk_to_resample, self.input_sample_rate, 16000 );
self.apm_input_buffer.extend_from_slice(& resampled_16k);
}
// 3. WebRTC APM 處理 10ms 影格 (160 樣本)
let apm_frame_size = 160 ;
let mut processed_samples = Vec::new();
while self.apm_input_buffer.len() >= apm_frame_size {
let frame: Vec< f32 > = self.apm_input_buffer.drain(0 .. apm_frame_size).collect();
let processed_frame = if let Some(ref mut apm_engine) = self.apm {
let mut dest = vec! [0.0 f32 ; apm_frame_size];
apm_engine.process_capture_f32(& [& frame], & mut [& mut dest])? ;
dest
} else {
frame
};
processed_samples.extend_from_slice(& processed_frame);
}
Ok(processed_samples)
}
}
3.2 openWakeWord 喚醒詞偵測 (detector.rs)
openWakeWord 是一個輕量級的開源喚醒詞模型。這裡使用 oww-rs 將處理後的 16kHz 音訊累積至 1280 個樣本(約 80ms)的固定區塊後傳入模型進行推論:
pub struct WakewordDetector {
oww_model: OwwModel ,
oww_chunk_buffer: Vec< f32 > ,
}
impl WakewordDetector {
pub fn feed_and_detect (& mut self, samples: & [f32 ]) -> bool {
self.oww_chunk_buffer.extend_from_slice(samples);
let mut triggered = false ;
while self.oww_chunk_buffer.len() >= OWW_MODEL_CHUNK_SIZE {
let chunk: Vec< f32 > = self.oww_chunk_buffer.drain(0 .. OWW_MODEL_CHUNK_SIZE ).collect();
let result = self.oww_model.detection(chunk);
if result.detected {
triggered = true ;
}
}
triggered
}
}
3.3 基礎 RMS 靜音檢測 (VAD) (vad.rs)
專案中採用了較簡單的能量門檻法作為 VAD 實作。每 10ms 影格計算 Root Mean Square (RMS) 音訊能量:
$$RMS = \sqrt{\frac{1}{N} \sum_{i=1}^{N} x_i^2}$$
若 RMS 持續低於設定門檻(預設 0.003)達到指定影格數(1.5 秒),或總錄音長度超過最大上限(8 秒),則終止錄音。雖然簡單的 RMS 門檻在吵雜環境或說話停頓較長時可能會誤判,但作為教學概念驗證已經足夠直觀:
pub fn process_frame (& mut self, frame: & [f32 ]) -> (bool , f32 ) {
self.recording_samples.extend_from_slice(frame);
self.total_frames_count += 1 ;
let mut sum_squares = 0.0 f32 ;
for & sample in frame {
sum_squares += sample * sample;
}
let rms = (sum_squares / frame.len() as f32 ).sqrt();
if rms < self.vad_threshold {
self.silence_frames_count += 1 ;
} else {
self.silence_frames_count = 0 ;
}
let is_silent = self.silence_frames_count >= self.silence_limit_frames
&& self.total_frames_count >= self.min_recording_frames;
let hit_max = self.total_frames_count >= self.max_recording_frames;
(is_silent || hit_max, rms)
}
3.4 Gemma-4 多模態音訊直通推論 (engine.rs)
在這個學習實驗中,最有趣的部分是嘗試用 llama-cpp-4 的 MtmdContext(多模態上下文)將音訊檔案 (temp_query.wav) 編碼為 Embeddings(MtmdBitmap),並直接傳給 Gemma-4 模型進行推論:
pub fn run_multimodal (
& mut self,
prompt: & str ,
audio_path: & Path ,
max_tokens: u32 ,
seed: Option< u32 > ,
mut stream_callback: impl FnMut(& str ) + Send,
) -> Result< String> {
let marker = MtmdContext::default_marker();
let full_prompt = format! (" {} {} " , prompt, marker);
// 1. 將音訊轉換為多模態 Bitmap
let bitmap = MtmdBitmap::from_file(& self.mtmd_ctx, audio_path)? ;
// 2. 切分文字 prompt 與多模態標記
let text = MtmdInputText::new(& full_prompt, true , true );
let bitmaps = [& bitmap];
let mut chunks = MtmdInputChunks::new();
self.mtmd_ctx.tokenize(& text, & bitmaps, & mut chunks)? ;
// 3. 一次性評估音訊 tokens
let mut lctx = self.session.model.new_context(& self.session.backend, self.loaded_context_params.clone())? ;
let mut n_past = 0 i32 ;
self.mtmd_ctx.eval_chunks(lctx.as_ptr(), & chunks, 0 , 0 , lctx.n_batch() as i32 , true , & mut n_past)? ;
// 4. 自迴歸串流生成回應文字
let mut sampler = LlamaSampler::chain_simple([
LlamaSampler::dist(seed.unwrap_or(42 )),
LlamaSampler::greedy(),
]);
// ...逐 token 解碼並觸發 stream_callback(&piece)...
}
這個方式省去了整合 STT 模型的步驟,讓我們能直接在 Rust 中實驗多模態模型對語音輸入的回應效果。
3.5 Windows PowerShell TTS 整合 (speech.rs)
為了保持專案輕量、避免引進額外的 C/C++ 語音合成依賴,語音輸出部分選擇直接透過 std::process::Command 呼叫 Windows 內建的 PowerShell System.Speech 進行朗讀:
pub fn speak (text: & str ) {
if text.trim().is_empty() { return ; }
let script = format! (
"Add-Type -AssemblyName System.Speech; \
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer; \
$synth.Speak([Console]::In.ReadToEnd())"
);
let child = std::process::Command::new("powershell" )
.args(["-NoProfile" , "-Command" , & script])
.stdin(std::process::Stdio::piped())
.spawn().ok();
if let Some(mut c) = child {
if let Some(mut stdin) = c.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
let _ = c.wait();
}
}
在測試過程中發現,如果 TTS 播放時麥克風仍處於接收狀態,喇叭發出的聲音很容易再次觸發喚醒或錄音邏輯。因此,程式在 Speaking 狀態下會阻塞等待 TTS 結束,並在重新進入 Listening 前呼叫 input_buffer.lock().unwrap().clear() 清空累積的音訊緩衝區。
4. 測試與執行方式
準備好 Gemma-4 GGUF 模型與 Multimodal Projector 檔案後,即可執行此練習專案:
cargo run --release -- --wakeword alexa --threshold 0.5
可用選項參數:
Usage: voice-assistant [OPTIONS]
Options:
-m, --model <MODEL> GGUF 模型路徑
-p, --mmproj <MMPROJ> Multimodal Projector GGUF 路徑
-w, --wakeword <WAKEWORD> 喚醒詞模型 [default: alexa] [alexa, mycroft]
-t, --threshold <THRESHOLD> 喚醒詞信心門檻 (0.0 - 1.0) [default: 0.5]
--no-apm 停用 WebRTC APM 降噪預處理
-v, --vad-threshold <THRESHOLD> VAD 靜音門檻 (RMS) [default: 0.003]
-d, --max-duration <SECONDS> 最長單次錄音秒數 [default: 8.0]
-s, --silence-duration <SECONDS> 靜音結束判定秒數 [default: 1.5]
5. Rust 52 Projects 學習之旅總結 💡
完成這個專案,也代表著 Rust 52 Projects 個人學習挑戰告一段落。
當初發起這個挑戰,目的只是希望能強迫自己每週透過動手寫一個小專案,從實務中學習 Rust 的不同領域。回看這 52 個練習專案,涵蓋了許多過去不曾涉足的方向:
系統與模擬器學習 :嘗試練習了 6502 CPU 與 NES 主機模擬器的邏輯解構。
圖形與 UI 框架 :接觸了 wgpu (WGSL Shaders)、GPUI 與 Egui 等不同的繪圖與桌面 GUI 框架。
FFI 與電腦視覺 :學習如何在 Rust 中呼叫 OpenCV 5、MediaPipe ONNX 模型與系統級輸入 API。
機器學習與本地 LLM :從解析 GGUF 格式、手寫基礎 Tensor 算子,到調用多模態大模型。
這 52 個專案絕大多數都只是簡單的概念驗證 (PoC) 或學習實驗,離成熟或生產級的軟體仍有很長的路要走。但過程中深刻體會到了 Rust 強大的型別安全、零成本抽象以及豐富的社群生態系(ecosystem)。
特別想感嘆與感謝的是,能夠順利堅持並完成這整整 52 個專案,AI 輔助開發(AI-assisted coding) 的進步絕對功不可沒。不論是快速搭建範例原型、除錯 C/C++ FFI 綁定、理解音訊與機器學習演算法,還是探索陌生的套件,AI 都扮演了隨時隨地的結對編程(pair programming)夥伴,大幅降低了跨領域學習的門檻,讓我也能一步步把這個原本看似遙遠的 52 篇系列挑戰圓滿完成。
專案的原始碼都已整理並開源在 GitHub 上,希望能給同樣在學習 Rust 的朋友提供一些參考與啟發:
👉 p47t/rust-52-projects (voice-assistant)