深入剖析 Wayland 運作架構:從 GTK、xdg_shell 到底層 IPC 機制

從像素畫布、狀態協商到零拷貝 buffer 共享的技術全貌
featured.svg

在 Linux 桌面圖形技術的演進史中,Wayland 取代有著數十年歷史的 X11,無疑是一場深刻的架構演進。

我自己過去在 Linux 上開發 GTK 桌面應用程式或客製化視窗時,常遇到一些令人好奇的底層問題:為什麼視窗在拖拉縮放時能做到完全不閃爍撕裂?為什麼應用程式無法像在 X11 下那樣隨意讀取全域螢幕座標?

回顧過去在 X11 時代,X Server 扮演著無所不在的中心角色——它既要處理網路協定、字型渲染、視窗幾何形狀,還要轉發輸入與剪貼簿資料,甚至在 Composite 擴充加入後,還得在 X Server、window manager 與 compositor 之間來回搬移畫面資料,造成了嚴重的架構冗餘與畫面撕裂(tearing)。

而 Wayland 的設計哲學非常純粹:「每一幀都是完美的(Every frame is perfect)」。它徹底廢除了傳統的 X Server,讓 compositor(如 GNOME 的 Mutter、KDE 的 KWin 或 Sway/Hyprland)直接兼任顯示伺服器與視窗管理器

這也帶來了許多底層開發者與架構愛好者的疑問:

  • 當我們寫一個 GTK 4 / GTK 3 應用程式時,它在 Wayland 下是如何繪製並顯示到螢幕上的?
  • 為什麼 Wayland 核心協定裡「沒有視窗」,而是透過 xdg_shell 來定義視窗行為?
  • 最底層的 wl_surface 是如何透過雙重緩衝(double-buffered)達成原子性更新的?
  • 應用程式與 compositor 之間是如何透過 UNIX Domain Socket 與 SCM_RIGHTS 實現零拷貝(zero-copy)buffer 傳遞的?

這篇文章將由上而下,從 UI toolkit(GTK)、桌面視窗協定(xdg_shell)、圖形原語(wl_surface)、底層二進位 IPC 通訊機制,一路探討到真實的 C++23 + Vulkan 實戰參考範例,為大家全面拆解 Wayland 的運作原理!

1. 頂層視角:GTK 應用程式在 Wayland 上如何運作?

在傳統 X11 架構下,視窗裝飾(標題列、關閉按鈕)通常由伺服端的 window manager 繪製,應用程式只負責在指定的 X Window 區域內畫圖。但在 Wayland 架構下,應用程式(client)的自主權與隔離性大幅提高。

flowchart TD subgraph G_Client["GTK 應用程式 (Client Process)"] GTK["GTK 4 / GTK 3
(Widgets, Layout, State)"] GSK["GSK / Cairo
(Scene Graph & 2D/3D Rendering)"] GDK["GDK Wayland Backend
(gdk/wayland)"] EGL["EGL / Mesa (GPU 加速)
wl_shm (軟體繪製)"] LIBWAYLAND["libwayland-client
(IPC Protocol)"] GTK --> GSK GSK --> GDK GDK --> EGL GDK --> LIBWAYLAND end subgraph G_IPC["Wayland Protocol (UNIX Domain Socket)"] SOCKET["$XDG_RUNTIME_DIR/wayland-0"] end subgraph G_Server["Wayland Compositor (如 Mutter, KWin)"] COMP["Compositor Core
(Windowing & Shell Management)"] RENDERER["Compositor Renderer
(OpenGL / Vulkan)"] LIBINPUT["libinput
(Input Event Handling)"] end subgraph G_Kernel["Linux Kernel"] DRM["DRM / KMS
(Display Output)"] EVD["evdev
(Keyboard, Mouse, Touch)"] end LIBWAYLAND <-->|傳遞 Buffer Handle 與狀態請求| SOCKET SOCKET <--> COMP EVD --> LIBINPUT LIBINPUT --> COMP COMP --> RENDERER RENDERER --> DRM

GDK Wayland 後端與事件整合

GTK 應用程式啟動時,GDK(GIMP Drawing Kit)會載入 Wayland 後端(gdk/wayland),並透過 libwayland-client 建立對 compositor 的 socket 連線(通常位於 $XDG_RUNTIME_DIR/wayland-0)。

GDK 會將 Wayland socket 的 File Descriptor 掛載進 GLib 的主事件迴圈(GMainContext)。當 Wayland 有事件到達時,GLib 的 Poll 機制會喚醒應用程式,將 Wayland 事件解析後轉為 GdkEvent 派發給對應的 GTK Widget。

客戶端裝飾 (Client-Side Decoration, CSD)

在 Wayland 中,compositor 預設不負責幫應用程式加上視窗標題列。因此 GTK 採用 CSD,將標題列(GtkHeaderBar)、視窗控制按鈕(最小化、最大化、關閉)以及視窗外圍陰影(drop shadow)全部納入應用程式的繪製樹中。

緩衝區繪製與提交

  1. GPU 渲染:GTK 4 透過 GSK(GTK Scene Kit)配合 Vulkan 或 OpenGL/EGL,直接在顯示卡記憶體中渲染 framebuffer。接著透過 Linux 的 DMA-BUF 機制將 GPU buffer 的記憶體描述符傳遞給 compositor。
  2. 軟體繪製 fallback:若缺乏硬體加速,GTK 透過 Cairo 在共享記憶體(memfd_create)中繪製,並透過 wl_shm 協定共享給 compositor。

幀時脈同步 (Frame Clock)

GTK 的動畫與重繪機制(gdk_frame_clock)完全依賴 compositor 驅動。當 GTK 繪製完一幀並提交後,會透過 wl_surface.frame 註冊一個回呼(callback)。

Compositor 會在當前畫面真正完成 VSync 上屏且準備好接收下一幀時,送出 wl_callback.done 事件並帶上高精度時間戳記(timestamp)。GTK 收到通知後才開始排程下一幀的計算與渲染,徹底告別不必要的 CPU/GPU 消耗與畫面撕裂。

2. 視窗語意層:xdg_shell 協定

如果你翻開 Wayland 核心協定,會發現裡面根本沒有「視窗(window)」這個概念。核心協定只提供抽象的像素畫布 wl_surface

那麼,桌面應用程式的「標題、最小化、最大化、全螢幕、右鍵選單」是由誰定義的?答案就是 xdg_shell

flowchart TD REG["wl_registry
(Wayland 全域註冊表)"] -->|綁定| WM["xdg_wm_base
(全域管理器介面)"] SURF["wl_surface
(基礎像素表面)"] -->|封裝| XSURF["xdg_surface
(桌面表面基底)"] WM -->|建立| XSURF WM -->|輔助定位| POS_HELPER["xdg_positioner"] XSURF -->|"賦予角色 (Role)"| TOP["xdg_toplevel
(一般頂層應用視窗)"] XSURF -->|"賦予角色 (Role)"| POP["xdg_popup
(彈出式選單 / Tooltip)"] POS_HELPER -.->|指定彈出規則| POP

核心介面分工

  1. xdg_wm_basexdg_shell 的工廠介面,負責建立 xdg_surface 與管理客戶端活躍度(Ping/Pong)。
  2. xdg_surface:將 wl_surface 與桌面視窗狀態連結的基底物件,負責管理視窗幾何邊界(set_window_geometry,用來剔除外圍陰影以精準計算貼齊尺寸)與狀態交握。
  3. xdg_toplevel:代表標準的桌面頂層視窗,支援設定 set_titleset_app_id(對應 .desktop 啟動檔與圖示)、set_maximizedset_fullscreen
  4. xdg_popupxdg_positioner:專為右鍵選單、下拉清單、tooltip 設計。由於 Wayland 出於安全隔離考量不向應用程式揭露螢幕全域座標,應用程式無法自行計算選單是否會超出螢幕邊界。透過 xdg_positioner,應用程式只需設定錨點與翻轉策略(如 flip_xslide_y),compositor 就會在螢幕邊界內自動計算最合適的彈出位置。

視窗縮放的雙向狀態協商 (Configure-Ack Handshake)

在 X11 時代,當使用者拖曳改變視窗大小時,常常會看到視窗內容被瞬間拉伸失真、或是邊框縮小但內容還沒跟上的殘影。xdg_shell 透過嚴謹的雙向狀態機徹底解決了這個問題:

sequenceDiagram autonumber participant Comp as Compositor (Mutter / KWin) participant Client as GTK Client Note over Comp: 使用者拉動視窗或最大化 Comp->>Client: xdg_toplevel.configure(1024, 768, [maximized]) Comp->>Client: xdg_surface.configure(serial=1234) Note over Client: 1. 根據新尺寸重新排版 UI
2. 繪製 1024x768 的新 Buffer Client->>Comp: xdg_surface.ack_configure(serial=1234) Client->>Comp: wl_surface.attach(new_buffer) Client->>Comp: wl_surface.commit() Note over Comp: Compositor 確認序號匹配
原子性(Atomic)更新畫面上屏

Compositor 送出 configure 事件時會帶上一組 serial 序號。Client 在重新繪製完成並呼叫 ack_configure(serial) 之前,compositor 會持續以舊尺寸顯示或暫停更新,絕不會呈現半完成的破裂畫面。

互動式移動與縮放 (Interactive Move / Resize)

在 Wayland 中,client 無法任意修改自己的螢幕座標。當使用者在 GTK 的標題列按下滑鼠開始拖曳時:

  1. GTK 偵測到點擊事件,呼叫 xdg_toplevel.move(seat, serial)
  2. Compositor 接管後續手勢:直接在顯示層移動整個 surface,GTK 本身完全不需要介入座標計算。

心跳監控機制 (Ping / Pong)

為了防止應用程式因無窮迴圈或耗時任務卡死導致介面失去回應,xdg_wm_base 內建了心跳機制:

  • Compositor 定期發送 xdg_wm_base.ping(serial)
  • Client 必須在主事件迴圈中立刻回應 xdg_wm_base.pong(serial)
  • 若逾時未收到回覆,compositor 便能可靠地判斷應用程式已當機,向使用者跳出「應用程式無回應」的結束對話框。

3. 圖形原語層:wl_surface 的雙重緩衝狀態機

在 Wayland 中,所有呈現在螢幕上的像素載體,最底層都是一個 wl_surface

wl_surface 最核心的精髓在於其 「待處理狀態(pending state)」「當前狀態(current state)」 的雙重緩衝設計。

flowchart TD subgraph G_Client["Client 操作請求"] REQ_OPS["attach / damage / scale / frame
(可多次累積呼叫)"] end subgraph G_State["wl_surface 狀態機"] ST_PENDING["待處理狀態 (Pending State)
暫存所有變更,對螢幕無任何影響"] ST_CURRENT["當前狀態 (Current State)
Compositor 實際拿來合成上屏的狀態"] end REQ_OPS -->|暫存變更至| ST_PENDING BTN_COMMIT["wl_surface.commit 呼叫"] -->|"原子性觸發 (Atomically Applied)"| ST_PENDING ST_PENDING -->|套用所有變更| ST_CURRENT ST_CURRENT -->|Compositor 讀取| COMP_RENDER["Compositor 畫面混成與渲染"]

關鍵 API 與優化機制

  • attach(buffer) & damage_buffer(x, y, w, h):綁定新 buffer 並宣告異動區域(dirty / damage region)。Compositor 只需重繪該局部區域,大幅節省 GPU 頻寬。
  • commit()原子性提交開關。在此之前呼叫的所有 attachdamageset_buffer_scale 都不會生效;呼叫 commit() 的瞬間,全部變更一次性生效。
  • set_opaque_region(region):告訴 compositor 表面內哪些區塊是完全不透明的。Compositor 可以直接對被遮擋的底層視窗進行遮擋剔除(occlusion culling),避免無謂的 overdraw。
  • set_input_region(region):定義點擊命中範圍,未涵蓋的區域事件將直接穿透到底層視窗。
  • enter(output) / leave(output):當視窗跨越不同螢幕時,compositor 會通知 client 當前螢幕的 HiDPI 縮放比,讓 client 能即時動態調整渲染解析度。

表面角色模型與子表面 (Subsurfaces)

一個 wl_surface 在生命週期中只能被賦予一種角色(如 xdg_toplevelxdg_popup 或滑鼠游標)。

此外,透過 wl_subsurface,應用程式可以建立多層次複合畫布:

  • 同步模式(sync mode):子表面的更新暫存,直到父表面呼叫 commit() 時一起原子性上屏。
  • 非同步模式(desync mode):子表面擁有獨立的更新週期。例如在影片播放器中,主視窗 UI 以 60 Hz 更新,而影片畫面作為子表面以獨立的 24 fps 非同步提交,解碼渲染與 UI 互不阻塞。

4. 底層通訊:Wayland 二進位 IPC 與零拷貝傳輸

Wayland 在處理行程間通訊(IPC)時,捨棄了 X11 龐大複雜的通訊協定,採用了極簡的二進位封包設計。

傳輸層:UNIX Domain Socket

Wayland 基於本地 UNIX Domain Stream Socket(AF_UNIX, SOCK_STREAM,沒有任何 TCP/IP 網路堆疊開銷。

8-Byte 訊息標頭與二進位格式 (Wire Format)

所有 Wayland 的請求(request)與事件(event)都是序列化的二進位資料流。每個訊息都以固定的 8-byte header 開始:

flowchart TD subgraph G_Packet["Wayland 二進位封包格式 (Wire Format)"] direction TB subgraph G_Header["8-Byte 固定訊息標頭 (Message Header)"] direction TB H_OBJ["Object ID
32-bit (4 Bytes) · 目標物件識別碼"] H_SIZE["Message Size
16-bit (2 Bytes)
封包總長度"] H_OPCODE["Opcode
16-bit (2 Bytes)
方法 / 事件編號"] end subgraph G_Payload["動態參數負載 (Payload Data)"] P_ARGS["Arguments (32-bit 對齊參數清單)
int32 · uint32 · fixed (24.8)
string · new_id · array"] end H_OBJ --> H_SIZE & H_OPCODE H_SIZE & H_OPCODE --> P_ARGS end classDef headerNode fill:#1e3a8a,stroke:#60a5fa,stroke-width:2px,color:#eff6ff; classDef payloadNode fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f0fdf4; class H_OBJ,H_SIZE,H_OPCODE headerNode; class P_ARGS payloadNode;
  1. Object ID(32-bit uint):目標物件識別碼(例如某個特定的 wl_surface 實例)。
  2. Opcode(16-bit uint):方法編號(0 代表介面定義的第一個方法,1 代表第二個,依此類推)。
  3. Message Size(16-bit uint):包含 header 與 payload 的封包總長度。
  4. Payload:參數資料,包含 intuintfixed(24.8 定點數)、stringarray 等,全部對齊 4-byte 邊界。

零拷貝傳輸:SCM_RIGHTS 傳遞 File Descriptor

圖形繪製最忌諱在行程間複製大塊像素資料。Wayland 的做法是絕不透過 socket 傳遞像素,而是透過 Linux Kernel 的 sendmsg() 輔助資料(ancillary data)傳遞 File Descriptor

flowchart TD subgraph G_ClientProcess["GTK Client 行程"] MEM["GPU Framebuffer / DMA-BUF
或 memfd_create 共享記憶體"] FD["Client 端 FD (如 fd=7)"] MEM --- FD end subgraph G_Kernel["Linux Kernel IPC"] SCM["sendmsg(..., SCM_RIGHTS, fd=7)
Kernel 在 Compositor FD 表建立副本"] end subgraph G_Compositor["Compositor 行程"] SFD["Server 端 FD (如 fd=12)"] SMEM["直接映射相同的實體 RAM
或 GPU 顯存紋理"] SFD --- SMEM end FD -->|sendmsg| SCM SCM -->|recvmsg| SFD
  • 共享記憶體(wl_shm:Client 透過 memfd_create() 建立匿名記憶體,透過 SCM_RIGHTS 將 FD 傳給 compositor,雙方各自呼叫 mmap() 映射同一塊實體記憶體。
  • GPU 紋理(linux-dmabuf:GTK/Mesa 透過 DMA-BUF 建立 GPU buffer 的 FD 並傳遞,compositor 直接將其作為 EGLImage/GPU Texture 匯入,實現真正的 zero-copy 渲染。

本地物件 ID 分配與非同步通訊

在傳統 RPC 中,建立物件往往需要發送請求並等待伺服器回傳新 ID。而 Wayland 採用了巧妙的本地分配機制:

  • Client 指派 ID:Client 在發送 new_id 請求(如建立 surface)時,直接自行決定下一個未使用的 32-bit ID(範圍為 0x000000010xfeffffff)並告知 server,完全無需等待伺服器回傳確認
  • 全非同步通訊:絕大多數請求都是單向發送(fire-and-forget)。若 client 需要確保伺服端已處理完前面的所有請求,只需發送一個 wl_display.sync 屏障(barrier),compositor 處理到該點時會回傳 wl_callback.done 事件。

5. 實戰範例:極簡硬體加速 Wayland + Vulkan 參考實作(hello-wayland)

為了讓大家能跳脫 GTK/Qt 等大型龐雜框架的包裝,真正看清上述所有 Wayland 核心機制的運作細節,我們實作了一個極簡且具備純硬體加速的開源參考專案:

👉 GitHub 專案倉庫https://github.com/p47t/hello-wayland

flowchart TD subgraph App["Hello Wayland (C++23)"] WAPP["WaylandApp
(PIMPL · 事件迴圈)"] VK["VulkanRenderer
(RAII · std::expected)"] end subgraph Wayland["Wayland Protocol"] REG["wl_registry"] WM["xdg_wm_base"] XSURF["xdg_surface / xdg_toplevel"] WSURF["wl_surface"] end subgraph VulkanDriver["Vulkan WSI (GPU Driver)"] VK_SURF["VkSurfaceKHR
(VK_KHR_wayland_surface)"] SWAP["VkSwapchainKHR
(VK_KHR_swapchain)"] end WAPP -->|綁定| REG REG -->|獲取| WM WM -->|建立| XSURF XSURF -->|管理| WSURF WSURF -.->|傳入 display 與 surface| VK_SURF VK_SURF -->|建立| SWAP VK -->|渲染並呈現| SWAP classDef cppNode fill:#1e3a8a,stroke:#60a5fa,stroke-width:2px,color:#eff6ff; classDef wlNode fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f0fdf4; classDef vkNode fill:#7c2d12,stroke:#fb923c,stroke-width:2px,color:#fff7ed; class WAPP,VK cppNode; class REG,WM,XSURF,WSURF wlNode; class VK_SURF,SWAP vkNode;

關鍵架構設計與實作細節

  1. 直接對接 Wayland 核心協定: 專案完全不依賴任何重量級視窗庫,直接透過 libwayland-client 與編譯期透過 wayland-scanner 產生的 xdg-shell-protocol.c/.h 建立 socket 連線並綁定 wl_compositorxdg_wm_base
  2. 嚴謹的雙向狀態協商(Configure-Ack): 在 xdg_surface_listener.configure 回呼中精準處理 compositor 指派的幾何尺寸與視窗狀態,並立即呼叫 xdg_surface_ack_configure(surface, serial) 達成無破圖協商。
  3. Vulkan 原生 WSI 與零拷貝呈現: 透過 Vulkan 的 VK_KHR_wayland_surface 擴充,將底層的 wl_displaywl_surface 直接註冊為 VkSurfaceKHR;配合 VK_KHR_swapchain,GPU 著色器渲染完畢後即可直接將顯存緩衝區交由 compositor 混成,實現真正的 zero-copy 渲染管線。
  4. 動態 Swapchain 重建: 當使用者拖曳視窗改變尺寸時,專案能自動處理 VK_ERROR_OUT_OF_DATE_KHRVK_SUBOPTIMAL_KHR,優雅地以 oldSwapchain 重新建立新尺寸的 Framebuffer 與 Render Pass,保證視窗縮放過程如絲般順滑。
  5. wl_surface.frame 幀率時脈同步: 在每一幀渲染提交時註冊 wl_surface_frame 回呼,精確配合螢幕刷新率(60/120/144 Hz)排程下一幀,達成無撕裂、零浪費的省電渲染循環。
  6. 現代 C++23 與 Cabin 建置: 全專案採用 C++23 開發,大量運用 std::expected 進行單子風格的無例外錯誤處理(Monadic error handling),並使用現代 C++ 套件管理器 Cabin 管理建置與依賴,只需簡單兩行即可編譯並執行:
# 編譯專案
cabin build

# 執行 Wayland Vulkan 應用程式
cabin run

6. X11 vs Wayland 架構對比總結

架構維度 傳統 X11 架構 現代 Wayland 架構
核心架構 集中式 X Server + 獨立 window manager + compositor Compositor 兼任顯示伺服器與視窗管理器
繪圖流程 間接轉發渲染指令或雙重 buffer 拷貝 Client 直接渲染至 GPU/SHM buffer,compositor 零拷貝合成
視窗管理 伺服端繪製邊框;全域座標公開暴露 客戶端裝飾(CSD);由 xdg_shell 進行雙向狀態協商
通訊協定 龐大狀態機與網路協定封包 極簡 8-Byte 標頭二進位串流 + SCM_RIGHTS 傳遞 FD
畫面同步 容易出現垂直撕裂(tearing)與閃爍 透過 wl_surface.frame 嚴格鎖定 VSync,保證「每幀完美」
安全隔離 任何程式可監聽全域按鍵與螢幕截圖 行程沙盒隔離,敏感操作須經 XDG Desktop Portal 授權

7. 延伸閱讀與參考資源

如果你想更深入地從 C 語言底層、自製 compositor 或自訂協定擴充角度探索 Wayland,強烈推薦閱讀開源經典著作:

  • 📖 The Wayland Book:由 Drew DeVault 撰寫的權威開源指南,系統性涵蓋了 Wayland 協定架構、libwayland 內部機制、Seat 輸入模型以及 client/server 端實作,是深入學習 Wayland 的必讀經典。
  • 💻 hello-wayland 專案倉庫:現代 C++23、Vulkan 與 Cabin 的極簡硬體加速 Wayland 桌面客戶端實作範例。

結語

從應用程式層的 GTK 視窗元件與 CSD 繪製,到 xdg_shell 嚴謹的 Configure-Ack 雙向協商,再到 wl_surface 的雙重緩衝狀態機、底層 SCM_RIGHTS 的零拷貝檔案描述符傳遞,以及實際透過 C++23 與 Vulkan 打造的 hello-wayland 範例——Wayland 透過分層明確的現代化設計,為 Linux 桌面帶來了流暢、無撕裂且具隔離安全性的圖形基礎架構。

理解這套架構,不僅能幫助我們在開發 GTK/Qt 等 GUI 應用程式時寫出效能更好、行為更標準的程式碼,更能深入體會現代作業系統在圖形混成與跨行程通訊上的設計智慧!


探索 Omarchy Linux:鍵盤驅動、平鋪優先與 AI 原生的現代化桌面架構實戰

Keyboard-Driven · Tile-Preferred · AI-Native 的現代化工作站架構
featured.svg

在 Linux 開發者的世界中,Arch Linux 憑藉其極簡原則(The Arch Way)、滾動更新(rolling release)機制以及龐大的 AUR(Arch User Repository)生態,一直是追求掌控感與最新技術者的首選。

然而,許多人在嘗試打造個人工作站時,往往發現「從零拼裝一個現代化、美觀且穩定的 Wayland 桌面環境」需要耗費數週時間挑選套件與調校設定檔,常常陷入「維護系統的時間遠多於真正寫程式的時間」的困境。

Omarchy Linux 便是為了解決這個痛點而生的現代化發行版。在深入體驗 Omarchy 之後,我發現它最吸引人的特質,可以精準歸結為三大核心支柱:

  1. ⌨️ Keyboard-Driven(全鍵盤驅動):手不離鍵盤即可掌控全域。系統內建完備的快捷鍵網、Quickshell 即時選單,並支援透過宣告式 Lua 靈活擴充個人化的按鍵流(例如我個人習慣配置的 Meh key 與語音聽寫)。
  2. 🪟 Tile-Preferred(平鋪視窗優先):基於 Hyprland 的動態平鋪佈局與 UWSM 工作階段管理,零重疊、零浪費螢幕空間,並具備毫秒級工作區調度能力。
  3. 🤖 AI-Native(AI 原生架構):非事後拼湊,而是系統層原生為 AI coding agent 設計——包含機器自省 CLI、內建 agent skills 規範、狀態列即時 AI 額度追蹤與安全自我修復機制。

本文將帶大家從 DHH 轉向 Linux 的背景出發,深入拆解 Omarchy 的底層技術架構、Quickshell 插件生態,以及如何透過 AI agent 進行深度客製。

1. 誕生背景:DHH 的 Linux Omakase 理念與 Omarchy 的起源

提到 Omarchy 的誕生,就不能不提 David Heinemeier Hansson(DHH)——Ruby on Rails 創始人兼 37signals CTO,同時也是近年推動「離開雲端(Leaving the Cloud)」與伺服器自託管運動的代表人物。

長年以來,許多開發者(包括 DHH 本人)儘管熱愛開源,但日常工作機仍多停留在 macOS,主要原因在於 macOS 提供了無可挑剔的字型渲染、精緻的 UI 美學與穩定的硬體整合;而 Linux 桌面長期以來雖然自由度極高,但往往需要使用者耗費數週時間自行挑選套件、縫合各家 dotfiles,容易陷入「配置時間遠多於開發時間」的泥淖。

從「離開雲端」到「離開 Apple」

在成功帶領 37signals 脫離 AWS 與公有雲、回歸自建機房並開源部署工具 Kamal 後,DHH 將他對「自主掌控權」的追求延伸到了個人操作系統——決定全面告別 Apple 與 macOS,踏上探索 Linux 桌面的旅程。

這趟旅程經歷了兩個重要的演進階段:

  1. 第一階段:Omakub(基於 Ubuntu) DHH 首先打造了 Omakub(取名自日式主廚料理「Omakase(お任せ,由主廚為你搭配平衡的組合)」+ Ubuntu)。他的核心理念是:「Linux 不該只有極簡拼裝一種途徑,它也可以像主廚配餐的 Omakase 一樣,提供一套由經驗豐富的工程師精挑細選、兼顧美學且開箱即用的開發環境。」
  2. 第二階段:邁向滾動發行庫 $\rightarrow$ Omarchy(基於 Arch Linux) 在實際日常使用後,DHH 與社群進一步發現,Arch Linux 的滾動更新(rolling release)、龐大活躍的 AUR(Arch User Repository)以及簡潔的底層架構,更適合現代開發者的工作需求。於是,Omarchy 應運而生——將 “Omakase” 的整合哲學融入 Arch Linux 的基礎之中。

Omarchy 並非只是一包 dotfiles,而是一套完整的現代化桌面系統:它採用了 Hyprland 動態平鋪合成器、以 Qt6/QML 打造的 Quickshell 狀態列、Btrfs 自動快照防護網,並原生融入了對現代 AI coding agent 的深度協同支援。

2. Omarchy 全域架構大圖(Big Picture)

Omarchy 採用分層解耦架構(Layered Architecture),將系統預設配置、桌面元件與使用者擴充層嚴格劃分:

flowchart TD L1["1. 使用者與 agent 擴充層
~/.config/omarchy/
自訂 hooks · 選單擴充 (JSONC)
插件克隆 · 客製色票"] L2["2. Omarchy 系統框架
/usr/share/omarchy/
統一主題引擎 (colors.toml)
更新管線 · agent skills"] L3["3. 現代化 Wayland 桌面生態
Hyprland (模組化 Lua 配置)
Quickshell (QML 狀態列) · UWSM session 管理"] L4["4. Arch Linux 基礎與底層系統
Pacman & AUR 滾動更新庫
Limine + Snapper (Btrfs 快照回滾)
PipeWire & Linux Kernel"] L1 ==>|安全配置覆寫 & 插件擴充| L2 L2 ==>|全域主題推播 & 桌面會話整合| L3 L3 ==>|構建於 Arch 滾動更新體系| L4 classDef userNode fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f0fdf4; classDef frameworkNode fill:#1e3a8a,stroke:#60a5fa,stroke-width:2px,color:#eff6ff; classDef desktopNode fill:#7c2d12,stroke:#fb923c,stroke-width:2px,color:#fff7ed; classDef archNode fill:#0f172a,stroke:#94a3b8,stroke-width:2px,color:#f8fafc; class L1 userNode; class L2 frameworkNode; class L3 desktopNode; class L4 archNode; linkStyle default stroke:#38bdf8,stroke-width:2.5px,fill:none;

3. 三大核心支柱與底層技術棧

3.1 鍵盤至上:Keyboard-Driven 的極速操控

Omarchy 的原生設計讓使用者能最大程度保持「手不離鍵盤主鍵區」:

  • 直覺的原生快捷鍵體系: Omarchy 預設圍繞 SuperAlt 鍵構建了完整且層次分明的快捷操作網(例如切換工作區、分割視窗、調整大小與多螢幕跳轉)。
  • 極簡的宣告式 Lua 綁定 API: 不同於過去修改複雜的配置檔,Omarchy 在 ~/.config/hypr/bindings.lua 中提供了 o.bindhl.unbind 等高階 API。使用者可以非常優雅地疊加個人自訂鍵位(例如自訂 Meh key 複合鍵、特殊巨集),而完全不破壞系統預設邏輯。
  • 全鍵盤搜尋與啟動器(omarchy-menu: 透過 Alt + SpaceSuper + Space 喚出 Quickshell 即時選單,支援模糊搜尋所有系統設定、主題切換、AI agent 選擇與應用程式啟動。
navigation-browser-terminal.webp clipboard-history-search.webp

3.2 平鋪優先:Tile-Preferred 與現代 Wayland 桌面

對於多視窗重度使用者與程式開發者而言,傳統重疊視窗(floating windows)需要不斷使用滑鼠拉伸與移動視窗邊界,極度分散注意力。

Omarchy 選擇了 Hyprland 作為核心合成器,並以平鋪視窗作為第一公民:

  • 動態平鋪與空間利用率:視窗開啟時自動分割排版,有效利用螢幕空間;搭配流暢的動畫與邊框樣式,兼具實用與視覺一致性。
  • UWSM 會話管理:採用 Universal Wayland Session Manager,所有桌面常駐程式(如 Fcitx5 輸入法、音訊守護)皆註冊為獨立的 systemd user units,徹底解決 Wayland 下環境變數不同步與程序崩潰難以追蹤的問題。
  • 模組化 Lua 視窗規則(windows.lua:個別應用程式(例如密碼庫、計算機)可精準宣告為自動浮動並置中,其餘開發工具(終端機、瀏覽器、Obsidian)則維持全平鋪排版。
navigation-fourway-tiling.webp

3.3 AI 原生:AI-Native 架構與 Agent 深度協同

傳統 Linux 發行版對 AI coding agent(如 Claude Code、Antigravity、Aider、Pi)極為不友善——設定檔散落在 /etc/usr~/.config,常需要互動式 sudo 輸入密碼導致 agent 阻塞,且一旦修改錯誤可能造成系統黑畫面。

Omarchy 從設計之初便將 AI 協作納入作業系統核心:

  1. 結構化自省介面(Machine-Readable CLI): AI agent 不需要靠猜測指令參數,Omarchy 提供了強大的自省 API:
    # 輸出全系統所有指令的 JSON Schema(包含群組、路由、參數與說明)
    omarchy commands --json
    
    # 免互動式 sudo 的系統健康與除錯資訊輸出(避免 agent 執行時阻塞)
    omarchy debug --no-sudo --print
  2. 內建 agent skills 規範(/usr/share/omarchy/default/agents/skills/: Omarchy 預先為 AI agent 封裝了專屬的 skill 知識庫:
    • omarchy skill:定義了安全的配置修改邊界、熱重載策略與備份復原命令。
    • diagnose-crash skill:自動擷取 journalctlcoredumpctl 與 Wayland 合成器日誌,讓 agent 能精準分析桌面當機原因並修復。
  3. 狀態列即時 AI 額度追蹤(omarchy.agents: 狀態列隨附第一方 AI 插件,點擊即可即時展開目前 Claude / OpenAI / 本地模型的 token 消耗步調、今日用量與額度分析。
  4. 隔離與插件克隆模式(plugin clone pattern): 系統預設組件位於 /usr/share/omarchy/(唯讀保護)。AI agent 在擴充狀態列元件時,可以使用 omarchy plugin clone <plugin-name>,將系統內建插件安全複製到 ~/.config/omarchy/plugins/ 下建立專屬版本,修改絕不會破壞上游核心。
  5. 快速安全回滾(Self-Healing Mechanisms): 若 AI agent 在調整配置時寫出語法錯誤,Omarchy 提供非破壞性的還原指令:
    # 自動備份當前錯誤配置並還原回預設狀態
    omarchy refresh shell
    omarchy refresh hyprland

3.4 穩健底層:Btrfs + Snapper + Limine 快照回滾

為了確保滾動發行庫(rolling release)的穩定性,Omarchy 在底層構建了自動快照保護網:

  • Btrfs 子卷隔離:根目錄(@)與家目錄(@home)分離,快照回滾不影響個人使用者資料。
  • Snapper + Libalpm Hooks:每次 pacman 寫入套件前後自動透過 libalpm hooks 捕捉 Pre/Post 快照。
  • Limine 開機選單同步:整合 limine-snapper-sync,每次更新自動在開機選單生成快照項目,遇到異常可直接從選單回滾。
snapshots-bootloader.webp

4. 深入 Quickshell 插件架構與生態

Omarchy 的狀態列(Bar)、通知中心、鎖定螢幕、OSD 與彈出面板全都是基於 Quickshell(以 Qt6/QML 實作的 Wayland shell 框架) 以插件形式掛載:

flowchart TD subgraph Host ["omarchy-shell (Quickshell 單一常駐主體)"] direction TB Services["全域共用服務 (QML)
• 統一主題色票 (colors.toml)
• PipeWire 音訊 / Network 狀態"] Registry["PluginRegistry 外掛註冊表
• inotify 監聽即時熱重載
• manifest.json 契約驗證"] end subgraph Plugins ["插件生態系統 (Plugins)"] direction LR Builtin["系統第一方插件 (/usr/share/...)
• omarchy.bar · omarchy.audio
• omarchy.agents · omarchy.network"] Custom["使用者自訂插件 (~/.config/...)
• custom.cpu-monitor (自訂)
• 第三方 Git 克隆插件"] end Services --- Registry Registry ==>|動態載入 & 生命週期託管| Builtin Registry ==>|動態載入 & 熱重載| Custom classDef hostNode fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f0f9ff; classDef subNode fill:#0f172a,stroke:#64748b,stroke-width:1px,color:#e2e8f0; classDef builtinNode fill:#1e3a8a,stroke:#60a5fa,stroke-width:2px,color:#eff6ff; classDef customNode fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f0fdf4; class Host hostNode; class Services,Registry subNode; class Builtin builtinNode; class Custom customNode; linkStyle default stroke:#38bdf8,stroke-width:2.5px,fill:none;

4.1 核心運作機制

  1. 單一常駐 Host(single shell host): 全桌面只跑一個 omarchy-shell 程序。點擊狀態列展開音訊面板、Wi-Fi 列表或日曆時,是在同一個記憶體空間內呼叫 IPC 顯示 UI,實現零冷啟動延遲
  2. inotify 即時熱重載(hot-reloading)PluginRegistry.qml 透過後台 inotifywait 監聽 ~/.config/omarchy/plugins/。當你儲存任何 .qmlmanifest.json 時,外掛會在幾十毫秒內自動重載,完全不影響工作區與執行中的應用程式。
  3. 嚴謹的 Manifest 契約 (manifest.json): 每個插件透過清單宣告其支援的型態(kinds):bar-widget(狀態列元件)、panel(彈出面板)、overlay(全螢幕遮罩)、service(背景單例服務)與 bar(替換式狀態列)。

4.2 哪裡可以找到與探索 Omarchy 插件?

  • 官方插件探索中心(Plugins Hub):造訪 https://plugins.omarchy.org/ 可以瀏覽由社群與官方維護的海量插件庫(包含各式狀態列小工具、控制面板、系統監控與主題擴充)。
  • 第一方內建插件庫:位於 /usr/share/omarchy/shell/plugins/,使用 omarchy plugin list 即可檢視(如 omarchy.agentsomarchy.tailscaleomarchy.audioomarchy.disk-speedtest 等)。
  • 插件克隆模式:使用 omarchy plugin clone <source-id> --edit 將內建插件複製到 ~/.config/omarchy/plugins/ 進行安全魔改。
  • 社群第三方 Git 插件:使用 omarchy plugin add <git-url> --enable 快速安裝並加入狀態列。

5. 實戰演練:客製與擴充 Omarchy 範例

5.1 客製化 Meh Key 體系與 Voxtype 語音聽寫切換

雖然 Omarchy 預設提供了完整的 Super 鍵操作網,但為了徹底杜絕與個別開發工具(如 IDE、終端機內部快捷鍵)的鍵位衝突,我個人在 ~/.config/hypr/bindings.lua 中引入了 Meh key(Ctrl + Alt + Shift 作為高階快捷鍵前綴。

透過 Omarchy 提供的標準 o.bind API,可以非常直覺地將系統常駐的 Voxtype 本地 Whisper 語音聽寫守護程序voxtype record toggle)綁定至 Meh + V

-- ~/.config/hypr/bindings.lua
local o = require("omarchy.bindings")

-- 個人客製化:綁定 Meh + V (Ctrl + Alt + Shift + V) 隨手切換語音錄音
o.bind("CTRL + ALT + SHIFT + V", "Toggle dictation", "voxtype record toggle")

執行 hyprctl reload 後立即生效。現在無論在哪個視窗,按下 Meh + V 就能立即開始或結束語音錄音並自動完成文字輸出,完全不影響一般的應用程式快速鍵。

5.2 實作 CPU 溫度與使用率狀態列 Widget + btop 整合(長駐串流優化)

~/.config/omarchy/plugins/custom.cpu-monitor/ 建立自訂小工具。為了避免每 2 秒透過 Timer 重複 fork 子行程造成 CPU 開銷,我們採用 單一長駐串流進程 + Quickshell 原生 SplitParser 的高效架構:

  1. manifest.json
{
  "schemaVersion": 1,
  "id": "custom.cpu-monitor",
  "name": "CPU Monitor",
  "version": "1.0.0",
  "kinds": ["bar-widget"],
  "entryPoints": { "barWidget": "CpuWidget.qml" },
  "barWidget": {
    "displayName": "CPU Monitor",
    "category": "System",
    "defaultSection": "right"
  }
}
  1. cpu_stream.sh(純 bash 常駐採集腳本,每 2 秒向 stdout 輸出單行 JSON,全生命週期僅 fork 1 次):
#!/usr/bin/env bash
# ~/.config/omarchy/plugins/custom.cpu-monitor/cpu_stream.sh

# 啟動時定位溫度感測路徑
temp_file=""
for d in /sys/class/hwmon/hwmon*; do
    if [ -f "$d/name" ] && grep -q "coretemp\|k10temp\|zenpower\|cpu_thermal" "$d/name" 2>/dev/null; then
        [ -f "$d/temp1_input" ] && temp_file="$d/temp1_input" && break
    fi
done

read -r _ u1 n1 s1 i1 io1 ir1 sir1 st1 _ < /proc/stat
prev_total=$((u1 + n1 + s1 + i1 + io1 + ir1 + sir1 + st1))
prev_idle=$((i1 + io1))

# 持續串流輸出,零額外子程序
while true; do
    sleep 2
    read -r _ u2 n2 s2 i2 io2 ir2 sir2 st2 _ < /proc/stat
    total=$((u2 + n2 + s2 + i2 + io2 + ir2 + sir2 + st2))
    idle=$((i2 + io2))

    total_diff=$((total - prev_total))
    idle_diff=$((idle - prev_idle))
    prev_total=$total
    prev_idle=$idle

    usage=$(( total_diff > 0 ? (100 * (total_diff - idle_diff)) / total_diff : 0 ))

    temp="--"
    if [ -n "$temp_file" ] && [ -r "$temp_file" ]; then
        read -r raw_temp < "$temp_file" 2>/dev/null
        temp="$(( raw_temp / 1000 ))°C"
    fi

    printf '{"usage": %d, "temp": "%s"}\n' "$usage" "$temp"
done
  1. CpuWidget.qml(使用 SplitParser 串流事件驅動,搭配 TextMetrics 固定欄位寬度防止字元跳動):
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui

BarWidget {
  id: root
  moduleName: "custom.cpu-monitor"
  property int cpuUsage: 0
  property string cpuTemp: "--"

  // 預先計算 3 位數百分比與溫度的最大字元寬度,防止數字跳動造成狀態列抖動
  TextMetrics { id: usageMetrics; font.family: Style.font.family; font.pixelSize: Style.font.body; text: "100%" }
  TextMetrics { id: tempMetrics; font.family: Style.font.family; font.pixelSize: Style.font.body; text: "100°C" }

  Process {
    id: statStream
    running: true
    command: ["bash", Quickshell.env("HOME") + "/.config/omarchy/plugins/custom.cpu-monitor/cpu_stream.sh"]
    stdout: SplitParser {
      onRead: function(line) {
        try {
          var data = JSON.parse(line)
          if (data.usage !== undefined) root.cpuUsage = data.usage
          if (data.temp !== undefined) root.cpuTemp = data.temp
        } catch (e) {}
      }
    }
  }

  Row {
    anchors.centerIn: parent
    spacing: Style.space(4)
    Text { text: ""; color: Color.foreground; anchors.verticalCenter: parent.verticalCenter }
    Text { width: usageMetrics.width; horizontalAlignment: Text.AlignRight; text: root.cpuUsage + "%"; color: Color.foreground; anchors.verticalCenter: parent.verticalCenter }
    Text { text: "·"; color: Color.foreground; opacity: 0.6; anchors.verticalCenter: parent.verticalCenter }
    Text { width: tempMetrics.width; horizontalAlignment: Text.AlignLeft; text: root.cpuTemp; color: Color.foreground; anchors.verticalCenter: parent.verticalCenter }
  }

  MouseArea {
    anchors.fill: parent
    cursorShape: Qt.PointingHandCursor
    onClicked: root.bar.run("omarchy launch or focus tui btop")
  }
}
  1. 啟用與掛載
omarchy plugin enable custom.cpu-monitor --section right

啟用後狀態列右上角即時渲染出 CPU 負載與溫度,且固定寬度不晃動,點擊即可無縫展開 btop 監控!

5.3 全域色票生成與動態編譯(AI-Native)

由 AI agent 自動生成 Cyberpunk 2077 配色 colors.toml,並透過單一指令完成全域推播:

omarchy theme set cyber-neon

Omarchy 的模板引擎會自動讀取 /usr/share/omarchy/default/themed/*.tpl,將色票即時注入 Ghostty、Alacritty、VS Code、Neovim、Obsidian 與 Quickshell 並發送熱重載訊號。

tokyo-night-preview.webp

6. 總結

特性維度 傳統 DIY Arch Linux Omarchy Linux
操作哲學 依賴滑鼠或分散的快速鍵 Keyboard-Driven:直覺原生快捷鍵、宣告式 Lua 擴充(如 Meh key)與 Voxtype
視窗佈局 需手動調整重疊視窗 Tile-Preferred:Hyprland 動態平鋪 + UWSM systemd 工作階段隔離
AI 整合 無原生支援,指令易因 sudo 阻塞 AI-Native:結構化自省 CLI、agent skills、狀態列 token 追蹤
狀態列與 UI Waybar (GTK) 或 Polybar Quickshell (Qt6/QML):單一常駐 host、毫秒級熱重載
外觀一致性 手動設定 10+ 個獨立設定檔 colors.toml 單一來源 + 跨應用模板編譯管線
更新防護 需自行設置 Btrfs 快照 Limine + Snapper + omarchy-update 預檢與開機選單快照回滾

Omarchy 並非要取代 Arch Linux 的極簡靈魂,而是以 Keyboard-DrivenTile-PreferredAI-Native 為核心,透過高度模組化且優雅的架構,打造出兼具流暢操控力、視覺一致性與 AI 深度協作的現代化開發者工作站。


深入解構非同步核心:C++20/23 協程與 Rust Async/Future 的設計哲學與架構差異

從 Push-driven 與 Pull-driven 模型、記憶體佈局、取消語義、自我引用位址到 io_uring / epoll I/O 架構適應矛盾的全面對比
featured.svg

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

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

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

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


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

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

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

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

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

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

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

    bool await_ready() const noexcept { return false; }

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

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

對稱轉移(Symmetric Transfer)的威力

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

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

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

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

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

sequenceDiagram autonumber actor Client as 📱 Downstream Client participant EL as 🔄 Linux epoll / EventLoop participant Parent as 📦 Server Coroutine (handle_request) participant Child as 📦 Client Coroutine (stream_request) participant Upstream as 🦙 Upstream LLM (Ollama/vLLM) Note over EL,Parent: 【Push 1: 連線事件就緒,直接精確喚醒伺服器協程】 Client->>EL: POST /v1/chat/completions (stream: true) EL->>Parent: h_server.resume() (O(1) 直接跳轉至暫停點) activate Parent Parent->>Parent: picohttpparser 解析請求 & std::expected 路由比對 Note over Parent,Child: 【對稱轉移:父協程切換至子協程,零堆疊增長】 Parent->>Child: co_await stream_request()
(設定 continuation_ = h_server,回傳 h_child 尾跳躍) deactivate Parent activate Child Child->>Upstream: 轉發 HTTP POST 請求至上游 LLM Child->>EL: co_await AsyncReadable{upstream_fd}
(註冊 upstream_fd & h_child,暫停出讓控制權) deactivate Child Note over EL,Child: 【Push 2: 上游 Token 就緒,EventLoop 直接推入子協程 Frame】 Upstream-->>EL: 傳送 SSE Token Chunk (Linux Kernel epoll_wait 喚醒) EL->>Child: h_child.resume() (精確直達 stream_request 中斷點,無需自頂向下 re-poll) activate Child Child->>Client: 即時 Pass-Through 轉發 SSE Chunk Note over Child,Parent: 【Push 3: 串流結束,final_suspend 對稱轉移交回父協程】 Upstream-->>Child: SSE [DONE] (上游連線關閉 / 完成) Child->>Parent: final_suspend() 尾呼叫跳轉 (return continuation_) deactivate Child activate Parent Parent->>Client: 結束 HTTP 串流回應 deactivate Parent

時序關鍵解構:

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

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

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

pub trait Future {
    type Output;

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

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

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

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

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

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

Waker 與 Reactor 喚醒鏈

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

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

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

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

sequenceDiagram autonumber actor Client as 📱 Downstream Client participant Reactor as 📡 Tokio Mio Reactor (epoll) participant Exec as ⚙️ Tokio Executor (Run Queue) participant Parent as 🦀 Root Future (handle_request) participant Child as 🦀 Leaf Future (stream_request) participant Upstream as 🦙 Upstream LLM (Ollama/vLLM) Note over Reactor,Parent: 【Pull 1: 連線就緒 ➜ 排入佇列 ➜ 自頂向下發起首次 Poll】 Client->>Reactor: POST /v1/chat/completions (stream: true) Reactor->>Exec: waker.wake() ➜ 將 Task 推入 Run Queue Exec->>Parent: 1. Executor 呼叫 Root Future::poll(cx) activate Parent Parent->>Parent: 解析請求 & 路由比對 Parent->>Child: 2. 遞迴呼叫 stream_request.poll(cx) activate Child Child->>Upstream: 發送 HTTP POST 請求至上游 LLM Child->>Reactor: 3. poll_read() 未就緒 ➜ 註冊 cx.waker() 至 Reactor Child-->>Parent: 4. 回傳 Poll::Pending (原路沿呼叫鏈向上返回) deactivate Child Parent-->>Exec: 5. 回傳 Poll::Pending (Task 讓出 Worker 執行緒) deactivate Parent Note over Reactor,Child: 【Pull 2: 上游 Token 就緒 ➜ 再次從根節點遍歷重評估 O(D)】 Upstream-->>Reactor: 傳送 SSE Token Chunk (epoll_wait 喚醒) Reactor->>Exec: waker.wake() ➜ Task 重新放回 Run Queue Exec->>Parent: 1. Executor 再次呼叫 Root Future::poll(cx) activate Parent Parent->>Child: 2. 狀態機轉發 ➜ 遞迴呼叫 stream_request.poll(cx) activate Child Child->>Upstream: 3. poll_read() 成功讀取 Token Chunk (Poll::Ready) Child->>Client: 即時轉發 SSE Chunk deactivate Child Parent-->>Exec: 4. 再次返回 Poll::Pending (等待下一個 Chunk) deactivate Parent Note over Reactor,Parent: 【Pull 3: 串流結束 ➜ 向上回傳 Poll::Ready(()) 完成任務】 Upstream-->>Reactor: SSE [DONE] / EOF Reactor->>Exec: waker.wake() Exec->>Parent: 1. Executor 呼叫 Root Future::poll(cx) activate Parent Parent->>Child: 2. 遞迴呼叫 stream_request.poll(cx) activate Child Child-->>Parent: 3. 串流完畢,回傳 Poll::Ready(()) deactivate Child Parent->>Client: 結束 HTTP 回應 Parent-->>Exec: 4. 頂層回傳 Poll::Ready(()) (Task 完成並銷毀) deactivate Parent

時序關鍵解構:

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

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

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

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

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

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

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

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

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

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

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

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

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

然而在實務上:

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

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

編譯期確定大小的保證

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

Rust 的取消機制相當直接:直接 Drop 該 Future

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

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

致命暗坑:取消安全性

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

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

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

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


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

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

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

6.1 epoll 與 Rust Pull 模型的天然契合

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

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

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

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

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

Rust 的妥協與代價

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

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

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

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

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

    bool await_ready() const noexcept { return false; }

    void await_suspend(std::coroutine_handle<> h) noexcept {
        io_uring_sqe* sqe = io_uring_get_sqe(ring);
        io_uring_prep_read(sqe, fd, buf, nbytes, 0);
        // 直接將協程 handle 位址作為 user_data
        io_uring_sqe_set_data(sqe, h.address());
        io_uring_submit(ring);
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

推薦選擇 Rust Async 的場景:

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

10. 結語

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

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

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


11. 延伸閱讀與經典文獻

經典設計理論

C++ 協程與標準演進

Rust 非同步與安全性契約

作業系統 I/O 架構


使用 C++23 與協程打造極簡高效的 OpenAI 相容 AI Gateway

深入剖析 Stackless 協程、Linux epoll 非同步架構、GDB 協程生命週期呼叫堆疊與 SSE 串流實戰
featured.svg

在當今生成式 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?

在構想這個閘道時,我們設定了幾個核心設計目標:

  1. 輕量設計與零框架相依(Zero-Framework Overhead):不引入 Boost.Asio 等重量級網路庫,而是直接基於 Linux 原生 epoll 與 non-blocking socket 打造專屬的非同步協程執行期(runtime),將二進位檔體積與執行期記憶體控制在數 MB 以內。
  2. 直觀清晰的協程代碼(Async/Await Syntax):透過 C++20/C++23 的 co_awaitTask<T>,讓非同步 I/O 與網路串流轉發像同步程式碼一樣線性流暢,徹底告別傳統 callback hell。
  3. 充分利用 C++23 語言與標準庫新特性:廣泛使用 std::expected 與 monadic 操作進行零成本錯誤處理、以 std::string_view::contains 簡化字串比對,並以 std::unreachable() 消除無效分支、輔助編譯器最佳化。
  4. 無縫相容 OpenAI API 生態:完整支援 /v1/chat/completions/v1/completions/v1/embeddings/v1/models/healthz,無論是官方 OpenAI Python/Node SDK 或是 curl 都能直接隨插即用。
  5. 低延遲 SSE 串流 Pass-Through:針對大語言模型(LLM)的 token 串流輸出(stream: true),實現零應用層堆積分配的即時 Pass-Through 轉發,並原生支援新一代推理模型(Reasoning Models)的思考鏈串流。
  6. 現代化建置體驗:使用如 Rust Cargo 般簡潔好用的 Cabin (cabin.toml) 管理依賴(nlohmann_jsonspdlogpicohttpparsercatch2),告別繁瑣的 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_awaitco_yieldco_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++ 協程基礎設施主要由以下四個元件相互協同運作:

  1. promise_type:協程內部的狀態控制器與結果接收者。它決定協程啟動時是否立即暫停(initial_suspend)、結束時如何清理(final_suspend)、如何捕獲返回值(return_value / return_void)與處理未捕獲異常(unhandled_exception)。
  2. std::coroutine_handle<P>:一個輕量級、型別安全的裸指標,指向 Coroutine Frame。可用來執行 resume()destroy()、檢查 done(),以及透過 handle.promise() 存取關聯的 promise 物件。
  3. Coroutine Frame:編譯器在背後自動生成的資料結構,存放狀態機索引、函式參數、局部變數與 promise 實例。
  4. 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++ 協程架構的標準範式,背後有三大關鍵考量:

  1. RAII 資源生命週期管理promise_type 存活於 Coroutine Frame 內部,它無法安全管理外部呼叫者的擁有權。Task<T> 是一個 純移動 (move-only) 的外部 handle 物件。當 Task<T> 超出作用域或被解構時,它的解構子可以明確且安全地呼叫 handle_.destroy() 釋放 Coroutine Frame,防止懸空指標與記憶體洩漏。
  2. 公開 API 與內部狀態隔離:呼叫者只需要關心 co_await taskrelease()valid() 等公開介面,不需要看到內部的 continuation 鏈結指標(continuation_)、異常指標(exception_)或底層 variant 結果。
  3. 零拷貝傳值與單次消費意圖:透過將 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

當一個協程函式被呼叫時:

  1. 編譯器在堆積上分配 Coroutine Frame,並構造 TaskPromise<T>
  2. 呼叫 promise.get_return_object(),內部利用 std::coroutine_handle<TaskPromise<T>>::from_promise(*this) 取得指向當前 Frame 的 handle,封裝成 Task<T> 並立即回傳給呼叫端。
  3. 由於 initial_suspend() 回傳 std::suspend_always,協程會先停在起始處,直到呼叫端執行 co_await task 時才啟動執行。

2.5 實戰透視:透過 GDB 追蹤協程完整生命週期與呼叫堆疊(Call Stack)

在傳統同步程式碼中,函式的調用與返回完全遵循 CPU 堆疊指標(Stack Pointer, ESP/RSP)的線性 push/pop。然而,在 C++20/23 無堆疊協程中,協程的暫停與恢復本質上是堆積狀態機(Heap-allocated State Machine)的切換。當我們在 GDB 等除錯器中觀察時,會看到非常不同於傳統函式的符號與呼叫堆疊結構。

1. 編譯器符號拆解:Ramp Function vs. Actor Clone

當 GCC 或 Clang 編譯包含 co_awaitco_yieldco_return 的協程函式(例如 HttpServer::handle_connection(int))時,編譯器前端(CoroSplit Pass)會將該函式裂解為數個不同的實體符號:

flowchart TD Source["📄 原始協程原始碼
HttpServer::handle_connection(int client_fd)"] subgraph Compiler["⚙️ GCC / Clang 編譯期拆解 (CoroSplit Pass)"] direction TB Ramp["🚀 Ramp 函數 (銜接入口)
HttpServer::handle_connection(int)
• 呼叫 operator new 分配 Coroutine Frame
• 複製引數 client_fd 至 Frame
• 構造 promise 物件並呼叫 get_return_object()
• 執行 initial_suspend() 並回傳 Task<void>"] Actor["🎭 Actor 函數 (實際狀態機本體)
handle_connection(Frame*) [clone .actor]
• 包含 switch(frame->__suspend_index)
• 存放所有跨暫停點的局部變數 (如 8KB buffer)
• 每次 handle.resume() 時由此處跳轉至中斷點續行"] Destroy["🗑️ Destroy 函數 (解構清理)
handle_connection(Frame*) [clone .destroy]
• 當 Task 解構呼叫 handle.destroy() 時觸發
• 解構 Frame 內局部變數並呼叫 operator delete"] end Source --> Compiler Ramp -.->|建立並指向| Actor Ramp -.->|綁定銷毀邏輯| Destroy classDef srcNode fill:#e0f2fe,stroke:#0284c7,stroke-width:2px,color:#0c4a6e; classDef compNode fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#0f172a; class Source srcNode; class Ramp,Actor,Destroy compNode;
  • Ramp 函數(handle_connection(int):對外保留原始函式簽名。呼叫端呼叫此函式時,它只負責在 Heap 上分配 Coroutine Frame、初始化 Promise、調用 initial_suspend(),並回傳 Task<void> 外部控制物件。
  • Actor 函數(handle_connection [clone .actor]:真正的狀態機本體,引數為指向 Coroutine Frame 的內部指標。內部維護暫停點索引(Suspend Index),每次 handle.resume() 被呼叫時,便透過此函式直接跳轉至上次中斷點。
  • Destroy 函數(handle_connection [clone .destroy]:當協程未執行完畢即被解構(例如 Task<T> 超出作用域觸發 handle_.destroy())時,負責釋放 Frame 內資源與記憶體。

2. GDB 自動化無感除錯技巧(Non-intrusive Tracing)

非同步伺服器(如 AI Gateway)高度依賴 Linux Non-blocking socket 與 epoll 事件循環。若使用傳統互動式 GDB 在中斷點停住手動輸入命令,往往會導致客戶端 TCP 連線逾時(Timeout)、epoll 逾時觸發或連線被重設。

為了解決這個問題,我們可以撰寫一個自動化的 GDB 腳本(trace_coro.gdb),利用 commands 指令搭配 continue 與 batch 模式,在不阻塞網路連線與事件循環的前提下,全速即時抓取所有關鍵階段的 Call Stack:

# trace_coro.gdb
set pagination off
set print thread-events off

# 1. 捕捉 Ramp 函數調用 (Frame 建立階段)
break aigw::http::HttpServer::handle_connection(int)
commands 1
  silent
  printf "\n=======================================================\n"
  printf " [STAGE 1] Ramp Function Called -> Allocate Frame & Task\n"
  printf "=======================================================\n"
  backtrace 4
  continue
end

# 2. 捕捉 Actor 狀態機恢復 (Initial Resume 與後續 I/O 喚醒)
break aigw::http::HttpServer::handle_connection [clone .actor]
commands 2
  silent
  printf "\n=======================================================\n"
  printf " [STAGE 2 / 4] Actor Resume -> Inside State Machine\n"
  printf "=======================================================\n"
  backtrace 4
  continue
end

# 3. 捕捉 Awaiter 暫停與 epoll 註冊
break aigw::coro::EventLoop::register_read
commands 3
  silent
  printf "\n=======================================================\n"
  printf " [STAGE 3] Suspend Point -> Register epoll EPOLLIN\n"
  printf "=======================================================\n"
  backtrace 4
  continue
end

# 4. 捕捉下游業務邏輯與對稱轉移回應
break aigw::http::StreamWriter::write_raw
commands 4
  silent
  printf "\n=======================================================\n"
  printf " [STAGE 5] Symmetric Transfer -> Handler Response Write\n"
  printf "=======================================================\n"
  backtrace 5
  continue
end

run

透過指令批次啟動追蹤:

gdb -batch -x trace_coro.gdb --args ./build/dev/packages/ai-gateway/ai-gateway

在另一個終端機使用 curl http://localhost:8080/healthz 發送請求,GDB 即可精確無漏地印出協程完整生命週期的堆疊演變!

3. 實測 5 大階段完整 Call Stack 堆疊剖析

以下是搭配 AI Gateway 真實執行環境所擷取的 5 大關鍵階段 Call Stack 與底層運作剖析:

【階段 1:Ramp 呼叫與 Frame 配置(TCP Accept ➜ Spawn)】

當 Linux 核心通知有新連線抵達,HttpServer::start 的 while 迴圈呼叫 srv->handle_connection(client_fd)

#0  aigw::http::HttpServer::handle_connection(this=0x7fffffffdfa0, client_fd=5) at src/http/server.cc:79
#1  0x00005555555627ab in aigw::http::HttpServer::start(this=0x7fffffffdfa0, loop=...)::<lambda()>::operator() at src/http/server.cc:229
#2  0x0000555555562a14 in aigw::http::HttpServer::start(this=0x7fffffffdfa0, loop=...) at src/http/server.cc:234
#3  0x0000555555559f20 in aigw::GatewayService::start(this=0x7fffffffdf50, loop=...) at src/gateway.cc:27
#4  0x0000555555559132 in main() at src/main.cc:88
  • 底層運作:此時進入的是 Ramp 函數。編譯器在 Heap 上調用 operator new 分配 Coroutine Frame,將參數 client_fd=5 存入 Frame,並構造 TaskPromise<void>。隨後呼叫 initial_suspend(),由於回傳 std::suspend_always,協程在此刻處於初始暫停態。Ramp 函數立即回傳 Task<void> 物件,由呼叫端透過 loop.spawn(task.release()) 將其底層 coroutine_handle 放進 EventLoop 的 ready_queue_
【階段 2:首次恢復(Initial Resume ➜ 進入 Actor 狀態機)】

主迴圈 EventLoop::run() 啟動,從 ready_queue_ 提取剛剛放入的 handle 並調用 h.resume()

#0  aigw::http::HttpServer::handle_connection(int) [clone .actor](_coro_frame_ptr=0x555555593820) at src/http/server.cc:79
#1  0x000055555555d4c1 in std::coroutine_handle<void>::resume(this=0x7fffffffdc40) at /usr/include/c++/13/coroutine:137
#2  0x0000555555560189 in aigw::coro::EventLoop::run(this=0x7fffffffdf00) at src/coroutine/event_loop.cc:112
#3  0x0000555555559145 in main() at src/main.cc:90
  • 底層運作:堆疊頂端此時已不再是普通的 handle_connection,而是編譯器生成的狀態機符號 [clone .actor],並傳入了 Frame 的指標(0x555555593820)。協程程式碼正式開始執行,於 Frame 內部構造局部變數 std::vector<char> buffer(8192),並進入外層 HTTP 連線循環。
【階段 3:暫停點 1(co_await async_read ➜ 讓出執行緒)】

協程執行至 co_await coro::async_read(client_fd, ...),發現 socket 尚未有資料可讀,觸發 Awaiter 暫停流程:

#0  aigw::coro::EventLoop::register_read(this=0x7fffffffdf00, fd=5, handle=...) at src/coroutine/event_loop.cc:78
#1  0x0000555555561a32 in aigw::coro::AsyncReadable::await_suspend(this=0x7fffffffd9a0, handle=...) at include/aigw/coroutine/io_awaiter.hpp:24
#2  0x0000555555563102 in aigw::http::HttpServer::handle_connection(int) [clone .actor](_coro_frame_ptr=0x555555593820) at src/http/server.cc:146
#3  0x000055555555d4c1 in std::coroutine_handle<void>::resume(this=0x7fffffffdc40) at /usr/include/c++/13/coroutine:137
#4  0x0000555555560189 in aigw::coro::EventLoop::run(this=0x7fffffffdf00) at src/coroutine/event_loop.cc:112
  • 底層運作AsyncReadable::await_suspend 將當前協程的 handlefd=5 註冊至 Linux epollEPOLLIN 事件),並回傳 noop_coroutine()。協程內部狀態與已讀取長度(total_read=0)完整保留在 Frame 中,整個呼叫堆疊向外回退(unwind)至 EventLoop::run(),進入 epoll_wait 阻塞等待網路事件,完全不佔用任何 CPU。
【階段 4:恢復點 1(I/O 就緒與精確復甦)】

當客戶端執行 curl 送出 HTTP Request 封包,Linux 核心喚醒 epoll_wait,EventLoop 取得關聯的 handle 並呼叫 h.resume()

#0  aigw::http::HttpServer::handle_connection(int) [clone .actor](_coro_frame_ptr=0x555555593820) at src/http/server.cc:150
#1  0x000055555555d4c1 in std::coroutine_handle<void>::resume(this=0x7fffffffdc40) at /usr/include/c++/13/coroutine:137
#2  0x0000555555560241 in aigw::coro::EventLoop::run(this=0x7fffffffdf00) at src/coroutine/event_loop.cc:135
#3  0x0000555555559145 in main() at src/main.cc:90
  • 底層運作EventLoop::run 再次呼叫 h.resume(),執行權 $O(1)$ 精確直達中斷點 server.cc:150total_read += static_cast<size_t>(n);)。協程從 non-blocking socket 中一口氣讀入 84 bytes 的請求資料,並調用 HttpParser::parse_request 零拷貝解析 HTTP 標頭,成功匹配到 GET /healthz
【階段 5:暫停點 2 與對稱轉移(Symmetric Transfer ➜ 回應下發)】

解析完成後,協程調用 co_await handler_(req, writer),進入下游業務邏輯:

#0  aigw::http::StreamWriter::write_raw(this=0x7fffffffd810, data="HTTP/1.1 200 OK\r\n...") at src/http/server.cc:54
#1  0x000055555555b204 in aigw::GatewayService::handle_healthz(this=0x7fffffffdf50, req=..., writer=...) [clone .actor] at src/gateway.cc:89
#2  0x000055555555af10 in aigw::GatewayService::handle_request(this=0x7fffffffdf50, req=..., writer=...) [clone .actor] at src/gateway.cc:55
#3  0x0000555555563588 in aigw::http::HttpServer::handle_connection(int) [clone .actor](_coro_frame_ptr=0x555555593820) at src/http/server.cc:163
#4  0x000055555555d4c1 in std::coroutine_handle<void>::resume(this=0x7fffffffdc40) at /usr/include/c++/13/coroutine:137
#5  0x0000555555560241 in aigw::coro::EventLoop::run(this=0x7fffffffdf00) at src/coroutine/event_loop.cc:135
  • 底層運作
    1. handle_connection 透過 co_await 喚醒 GatewayService::handle_request,並將自身 handle 設為 continuation_
    2. 請求被分派至 handle_healthz,建構 JSON 回應並呼叫 writer.write_raw()
    3. write_raw 內部發起 co_await coro::async_write 將 HTTP 回應寫入客戶端 socket。
    4. 當回應寫入完畢、子協程執行至 final_suspend() 時,透過 Symmetric Transfer(對稱轉移) 尾跳躍直接切換回 handle_connection,完全無需經過額外的堆疊累積或繁瑣的 EventLoop 重排程。

4. Compiler Explorer (Godbolt) 互動式彙編與狀態機分析

為了讓讀者能夠更直觀地親手驗證與探索編譯器對 C++23 協程的底層 lowering 細節,我們在 Compiler Explorer 上搭建了一個精簡可獨立編譯的互動式分析環境:

👉 Compiler Explorer 互動分析環境:https://godbolt.org/z/fd4azqMvj

在該環境中,你可以即時切換與對比主流編譯器的後端代碼生成:

  • GCC 14.1:清楚觀察編譯器將協程函式拆解為 Ramp 函數、[clone .actor] 狀態機跳轉函式與 [clone .destroy] 記憶體清理函式。
  • Clang 19.1:觀察 LLVM 的 coro.splitCoroElide pass 如何處理 Coroutine Frame 配置與狀態切換。
  • 對稱轉移尾呼叫(Tail Call Optimization):驗證 await_suspend 回傳 std::coroutine_handle<> 時,編譯器如何生成一條無額外堆疊開銷的 jmp 指令(Tail Jump),將控制權直接交給下一個協程。
  • Coroutine Frame 佈局:檢視 Frame 中函式指標、Promise 物件、跨暫停點局部變數與 Suspend Index 的記憶體排布。

3. AI Gateway 架構設計與 C++23 現代化實作

有了堅固的協程基礎,接下來我們看看 AI Gateway 如何構建高性能的 HTTP 轉發服務,並深度結合 C++23 的語言特性。

3.1 自行實作 Linux epoll EventLoop 與非同步 Awaiter

我們在 include/aigw/coroutine/event_loop.hpp 中實作了單執行緒非阻塞的 EventLoop,結合 Linux epolleventfd 喚醒機制,並維護一個以 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() 回傳 EAGAINEWOULDBLOCK,協程會透過 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 中設計了雙重回調機制:

  1. on_header:一旦上游回傳 HTTP 200 與 Content-Type: text/event-stream,立即向用戶端下發 SSE 回應標頭(Transfer-Encoding: chunkedCache-Control: no-cache)。
  2. 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 升級帶來了幾大核心優勢:

  1. 富型別錯誤狀態(Rich Typed Errors):閘道層可根據 RoutingError 精確映射 HTTP 狀態碼(例如 NoMatchingRoute 映射為 404 Not Found,而 PoolNotFound/PoolEmpty 映射為 500 Internal Error),且全程零例外開銷。
  2. C++23 Monadic 鏈式操作:支援使用 .transform().and_then() 進行優雅的函數式鏈結:
    // 優雅提取 Pool 名稱,無需多層巢狀 if-else
    auto pool_name = router.route("llama3.3").transform([](const RouteDecision& d) {
        return d.pool->name();
    });
  3. C++23 std::string_view::contains:告別冗長易錯的 str.find("chunked") != std::string_view::npos,改用簡潔直觀的 te->contains("chunked")ct->contains("text/event-stream")
  4. 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 buildcabin 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 包含負數或尾隨無效字元,解析不嚴格可能造成非預期行為。

修復

  • 限制客戶端與伺服端緩衝區最大上限為 16MBMAX_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),直到連線逾時中斷。

原因剖析

  1. 上游推論後端(如 Ollama)是以 HTTP/1.1 Chunked Transfer Encoding 回傳 SSE 串流資料,每個資料區塊由長度標頭與內容組成,並以長度為 0 的終止區塊(0\r\n\r\n)標記傳輸結束。
  2. 由於我們的閘道採用零拷貝的 raw chunk 直接轉發,若在轉發 downstream HTTP Header 時將 Transfer-Encoding: chunked 標頭過濾或遺漏,客戶端的 HTTP 解碼器(httpx)便無法得知這是 chunked 資料流。
  3. 結果 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.reasoningdelta.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++ 開發體驗與開源生態複用
featured.svg

隨著 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 的幾個核心架構特色:

  1. 內建 Git worktree 原生隔離:herdr 提供了原生的 worktree 整合(如 herdr worktree create --branch <name>)。當你要讓 agent 處理一個 issue 或進行重構時,herdr 會在獨立的 Git worktree 中建立專屬 workspace。這讓多個 agent 能在各自獨立的目錄分支下並行編譯與測試,完全不會產生 Git 鎖定或分支衝突。
  2. 多 agent 高並行分派(agent-first orchestration):開發者習慣為不同任務建立獨立 workspace,例如同時跑 feat/issue-2feat/issue-10bugfix/authdocs/apirefactor/db
  3. 程式化與 agent-to-agent 自動化開區:herdr 提供本地 socket API 與 CLI。負責統籌的主 agent(lead agent)或自動化腳本能透過程式化指令(herdr workspace create / herdr worktree create)動態建立工作區、派發任務,並監聽 agent 的即時工作狀態。
  4. 常駐背景守護(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

在開發這個專案的過程中,有兩個體驗特別深刻:

  1. Cabin 建置系統帶來如 Rust Cargo 般的現代開發體驗:告別寫了幾十年依舊繁瑣且容易踩坑的 CMakeLists.txt,僅靠一份簡潔的聲明式 cabin.toml,就能自動解析 ports 套件依賴、管理子模組,並以 cabin buildcabin test 快速編譯與測試。
  2. 現代 C++23 與高品質開源生態的深度整合:結合 std::expectedstd::rangesstd::lexicographical_compare_three_waystd::format,並複用社群頂級函式庫(CLI11nlohmann_jsonFTXUICatch2 v3),在維持高效能與零額外抽象開銷的同時,寫出具備高型別安全、高可讀性與完整測試覆蓋的現代系統程式。

1.3 五大核心需求與系統架構

在打造這款外掛時,我設定了幾個核心需求:

  1. 自然字母數字排序(natural alphanumeric sort):數字區塊需視為整數比較,確保 workspace-2 排在 workspace-10 之前;同時支援不區分大小寫的初級比較與嚴格大小寫的平手裁決,且必須能處理任意長度的大數值而不發生整數溢位。
  2. 多維度排序策略
    • natural:自然數字排序(預設)。
    • label / alpha:純字典字母排序。
    • status:依照 agent 工作狀態優先權排序(working > blocked > done > idle > unknown)。
    • repo:依據 Git 倉庫根目錄分組,並整理旗下各 worktree 分支。
    • path:依據工作目錄(CWD)層級排序。
    • panes / tabs:依據終端分割窗格或分頁數量排序(快速找出最活躍的工作區)。
    • reverse:反轉現有工作區順序。
  3. 最小移動次數計算(minimal move reordering):herdr 的 RPC 提供 workspace.move(id, insert_index) 介面。我們不能暴力重置所有工作區,而必須透過置換模擬演算法計算出最少的移動步驟,減少畫面閃爍與 IPC 負擔。
  4. 宣告式互動 TUI(live preview terminal UI):利用 FTXUI 打造即時互動終端介面,支援快捷鍵 [1-8] 切換策略、[r] 反轉、[f] 置頂當前焦點工作區,並以 ANSI 顏色徽章和位置偏移指示器(如 +2, -1, 0)提供即時重排預覽。
  5. 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 建置系統 — 如 Rust Cargo 般的 C++ 開發體驗

長期以來,C++ 專案的建置系統與依賴管理一直是開發者心中的痛。CMake 雖然功能強大,但其語法晦澀、歷史包袱沉重,要引入外部相依套件通常得在 FetchContentfind_package、vcpkg、Conan 或手動編譯之間痛苦掙扎,動輒數十行的樣板程式碼更讓人心力交瘁。

在這個專案中,我全面採用了新一代 C++ 套件管理與建置系統:Cabincabinpkg)。

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 的關鍵優勢

  1. 宣告式 ports 生態(port = true: 如 nlohmann_jsonCLI11catch2,只需標註 port = true 與版本範圍語意(如 ^3.12.0),Cabin 會自動從官方 ports 倉庫解析、下載、快取並編譯對應版本,完全不需手動配置 CMake 或下載標頭檔。
  2. 多目標清晰隔離: 將主執行檔(executable)、測試套件(test)與專屬壓測目標(bench-natural-sort)分開宣告,dev-dependencies(如 Catch2)僅會在編譯測試目標時被拉取與鏈結,避免污染最終的發布二進位檔案。
  3. 無縫整合本地子模組: 對於需要深度客製化或特定分支的函式庫(例如終端圖形庫 ftxui),可以直接使用 path = "third_party/ftxui" 引入,Cabin 會自動處理包含目錄與原始碼建置。
  4. 標準化的命令列工作流
    • 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.hppsrc/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.ccsort_workspaces 核心流程中,我們運用了 std::ranges::stable_sortstd::ranges::reversestd::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

在純字典字母排序中,我們希望達成:

  1. 主要比較:不區分大小寫(case-insensitive),例如 'a''A' 視為相同。
  2. 平手仲裁(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 字典序作為唯一平手仲裁
}

這段程式碼將原本需要寫十幾行雙迴圈、大小寫轉換與指標推進的繁瑣邏輯,濃縮成兼具編譯器最佳化與數學嚴謹性的三向比較表達式。

3.4 std::format 型別安全字串格式化

告別易引發記憶體安全問題的 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_viewstd::span 的生命週期管理

在整個外掛的排序管線中,工作區標籤(label)、目錄路徑(CWD)與工作區 ID 需要頻繁進行字串比對與切片。為了徹底消除短命 std::string 的堆積記憶體配置(heap allocation):

  1. std::string_view:在字串自然比對器 natural_compare(std::string_view lhs, std::string_view rhs) 中,直接操作字串指標與長度,不產生任何記憶體複製。
  2. 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 實現 WorkspaceWorktreeInfo 的自動雙向序列化,處理 herdr socket 回傳的複雜樹狀快照。
CLI11 命令列解析與子命令架構 支援 sortlistinteractivehook 四大子命令,提供豐富的參數校驗(如 CLI::IsMember 檢查合法排序策略)與內建色彩 help 格式化。
FTXUI 終端互動式 UI 元件 採用 Functional Reactive 模式構建全螢幕 TUI,包含 radio menu、checkbox、table、即時按鍵監聽器與 ANSI 彩色渲染。
Catch2 v3 單元測試與微基準評測 提供強大的 TEST_CASESECTION 階層測試與 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]

  1. $i=0$:目標是 w5,當前 w5 位於索引 4。執行 move(w5, 0),狀態變為 [w5, w1, w2, w3, w4]
  2. $i=1$:目標是 w3,當前 w3 位於索引 3。執行 move(w3, 1),狀態變為 [w5, w3, w1, w2, w4]
  3. $i=2$:目標是 w1,當前 w1 已經位於索引 2,無需移動。
  4. $i=3$:目標是 w4,當前 w4 位於索引 4。執行 move(w4, 3),狀態變為 [w5, w3, w1, w4, w2]
  5. $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 listherdr 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
  1. 左側策略選單:支援即時選取 8 種排序策略,並提供反轉與焦點置頂 checkbox。
  2. 右側即時預覽表格:每次變更選項,表格會即時計算重排後的結果,並在最右側以綠色 +2 或紅色 -1 標註每個工作區相對於原本位置的位置位移量(delta)
  3. 直覺快捷鍵:按下數字鍵 [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)與高輸送量,我們設計了全方位的雙軌基準測試體系

  1. Catch2 v3 微基準測試(microbenchmark):整合於單元測試套件中,提供隔離的奈秒級微基準量測。
  2. 自研 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 自動化整合

專案將基準測試包裝為兩個靈活的入口:

  1. 專屬評測二進位檔cabin run --release --bin bench-natural-sort -- --category all(支援 --markdown--json--csv)。
  2. 主程式子命令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),包含:

  1. 自然排序測試:驗證遞移律(Transitivity: $a < b \land b < c \implies a < c$)、前導零仲裁、符號與分支路徑、以及超過 30 位的超長數值比對。
  2. 策略排序測試:驗證 8 種策略的升降序行為、多窗格與多分頁排序、Git worktree 歸屬排序。
  3. 邊界情況測試:空工作區列表、單一工作區、標籤為空時的 ID 回退機制、重複標籤時的穩定性。
  4. 置換模擬測試:驗證 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++ 的開發體驗有了全新的體認:

  1. C++ 不再等於「繁瑣的 CMake」:Cabin 證明了 C++ 也能擁有如同 Rust Cargo 般愉悅的相依套件管理與建置體驗。聲明式 TOML 讓專案設定清晰明瞭,新手與老手都能在幾秒鐘內輕鬆上手。
  2. C++23 讓系統程式更加安全且優雅std::expected 終結了錯誤碼與例外之爭;std::rangesstd::lexicographical_compare_three_way 讓演算法更加精練;std::string_viewstd::span 則在維持高效能的同時避免了不必要的記憶體配置開銷。
  3. 強大且成熟的開源生態:從 nlohmann_json 的優雅序列化,到 CLI11 的健全命令列解析,再到 FTXUI 的終端互動體驗,現代 C++ 社群的基礎設施已非常健全。

如果你也在使用 herdr 管理你的日常開發與 AI agent 工作區,歡迎試用並將專案 clone 下來體驗: