Lock-free 資料結構實戰:從 Treiber Stack、ABA 問題到安全記憶體回收

深入解構 CAS 狀態轉移、Tagged Pointer、Hazard Pointer、EBR 與 RCU 的架構權衡與實作細節
featured.svg

在上一篇 《C++ 與 Rust 記憶體模型:從 Memory Ordering、硬體快取到 Safe Publication》 中,我們建立了對記憶體順序、編譯器重排、Store Buffer 與快取一致性的完整認知。我們明白了單一原子操作的成功,並不自動等同於周邊資料已經同步可見;唯有透過 Acquire-Release 配對,才能在多核心之間拉起堅固的因果同步橋樑。

掌握了記憶體模型這張入場券後,我們終於可以直面高效能並行程式設計的聖杯——無鎖資料結構(Lock-free Data Structures)

許多人剛接觸無鎖程式設計時,常有一種美麗的幻想:「只要把所有 std::mutex 拿掉,改用 CAS(Compare-And-Swap)迴圈,程式碼就會奇蹟般地變快且沒有鎖衝突。」

然而,現實往往十分殘酷。當你拿掉互斥鎖的保護後,除了必須自行處理狀態移轉與記憶體順序外,還會立刻遭遇並行運算領域的兩大深水區:

  1. 拓撲移轉的幽靈——ABA 問題:指標位址相同,真的代表鏈結結構沒有改變過嗎?
  2. 記憶體生命週期的深淵——安全記憶體回收(Safe Memory Reclamation, SMR):當一個節點被某個執行緒從資料結構中拔除時,其他並行的讀者執行緒可能正剛讀出指標準備解引用(dereference)。如果此時直接 delete 或釋放記憶體,就會引發致命的 Use-After-Free 記憶體損毀。

本文將以經典的 Treiber Stack 為基準,透過現代 C++20 與 Rust 的雙語言實作,深入解構 CAS 原語、狀態重試機制,並全面剖析 Tagged Pointer、Hazard Pointer、Epoch-Based Reclamation (EBR) 與 RCU 四大安全記憶體回收架構的設計權衡。

從 Memory Ordering 到 Lock-Free:思維範式轉移

在傳統的並行架構中,互斥鎖(Mutex)扮演著全能守護者的角色。當一個執行緒進入 mutex.lock()mutex.unlock() 包夾的臨界區(Critical Section)時,Mutex 同時為我們解決了三件事:

flowchart TD subgraph MutexRole["Mutex 提供的三重保護"] M1["<div style='width:380px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>1. 互斥性 (Mutual Exclusion)</b><br/>• 同一時間僅允許單一執行緒進入臨界區</div>"] M2["<div style='width:380px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>2. 記憶體同步 (Memory Ordering)</b><br/>• unlock Release + lock Acquire 自動建立同步</div>"] M3["<div style='width:380px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>3. 生命週期安全 (Lifetime Safety)</b><br/>• 臨界區內刪除節點,絕無其他執行緒能同時讀取</div>"] M1 --> M2 --> M3 end subgraph LockFreeRole["Lock-Free 必須獨立解決的三大課題"] L1["<div style='width:380px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>1. 狀態移轉 (State Transition)</b><br/>• 改由硬體原子 CAS 原語樂觀競爭處理</div>"] L2["<div style='width:380px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>2. 記憶體順序 (Memory Ordering)</b><br/>• 自行精確配置 Acquire / Release 語意</div>"] L3["<div style='width:380px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>3. 生命週期安全 (Lifetime Safety)</b><br/>• 必須引進專門的 SMR 回收協定保護節點</div>"] L1 --> L2 --> L3 end MutexRole -->|"思維範式轉移"| LockFreeRole style MutexRole fill:#16161e,stroke:#414868,stroke-width:1.5px,color:#7aa2f7; style LockFreeRole fill:#16161e,stroke:#7dcfff,stroke-width:1.5px,color:#7dcfff; classDef mBox fill:#24283b,stroke:#414868,stroke-width:1.5px,color:#c0caf5; classDef lBox fill:#24283b,stroke:#7dcfff,stroke-width:1.5px,color:#7dcfff; class M1,M2,M3 mBox; class L1,L2,L3 lBox;

當我們決定移除 Mutex 時,上述三項保護全部煙消雲散。我們必須:

  1. CAS(Compare-And-Swap) 取代臨界區互斥,讓多個執行緒樂觀競爭狀態轉移。
  2. Acquire / Release / Relaxed 精確標註每次原子讀寫,確保資料初始化與發布具備因果先後。
  3. 引進 安全記憶體回收(SMR) 機制,接手原先由鎖保護的物件生命週期。

既然 Mutex 能如此滴水不漏地提供三重防護,為什麼並行系統工程師依然對「擺脫互斥鎖」如此執著?這就必須從 Mutex 在微觀硬體與宏觀作業系統排程中所背負的隱形成本談起。

互斥鎖的真實代價:從輕量原子到核心態深淵

許多人直覺認為「鎖就只是一道門,排隊通過即可」。但在現代多核心架構與搶佔式作業系統下,Mutex 的運作遠比想像中複雜。現代主流作業系統的 Mutex(如 Linux 的 Futex 或 Windows 的 SRWLock)均採用「快慢雙路徑」設計:

flowchart TD Req["<div style='width:260px; padding:4px 8px; text-align:center;'><b>執行緒請求加鎖</b><br/><code>mutex.lock()</code></div>"] FastCheck{"Fast-path 比對<br/>使用者態原子 CAS"} InCS["<div style='width:260px; padding:4px 8px; text-align:left;'><b>進入臨界區 (Critical Section)</b><br/>• ⚡ 耗時約 10–25 ns<br/>• 無核心介入、無切換</div>"] SpinCheck{"自旋重試<br/>Spin-wait"} SlowCall["<div style='width:260px; padding:4px 8px; text-align:left;'><b>墜入 Slow-path 核心態</b><br/>• 呼叫系統呼叫 <code>sys_futex(WAIT)</code><br/>• 執行緒掛起並移入等待佇列</div>"] CtxSwitch["<div style='width:260px; padding:4px 8px; text-align:left;'><b>上下文切換 (Context Switch)</b><br/>• 💥 耗時 1,000–5,000 ns<br/>• CPU 調度其他執行緒執行</div>"] Wakeup["<div style='width:260px; padding:4px 8px; text-align:left;'><b>持有者釋放與喚醒</b><br/>• 呼叫 <code>sys_futex(WAKE)</code><br/>• ❄️ 冷快取、TLB Miss、快取行彈跳</div>"] Req --> FastCheck FastCheck -->|無競爭| InCS FastCheck -->|發生競爭| SpinCheck SpinCheck -->|自旋獲鎖成功| InCS SpinCheck -->|自旋超時失敗| SlowCall SlowCall --> CtxSwitch CtxSwitch --> Wakeup Wakeup --> InCS classDef proc fill:#24283b,stroke:#7aa2f7,stroke-width:1.5px,color:#c0caf5; classDef branch fill:#24283b,stroke:#bb9af7,stroke-width:1.5px,color:#bb9af7; classDef ok fill:#1f2335,stroke:#9ece6a,stroke-width:1.5px,color:#9ece6a; classDef warn fill:#24283b,stroke:#e0af68,stroke-width:1.5px,color:#e0af68; classDef danger fill:#1f2335,stroke:#f7768e,stroke-width:1.5px,color:#f7768e; class Req proc; class FastCheck,SpinCheck branch; class InCS ok; class SlowCall warn; class CtxSwitch,Wakeup danger;

這套混合架構雖然極大化了無競爭情境的效能,但只要並發度提高並引發持續競爭,Mutex 的連鎖代價便會逐一浮現:

上下文切換與核心態調度開銷

在無競爭的快樂路徑(Happy Path)上,mutex.lock() 僅是一次純使用者態的原子操作,耗時通常在 10 到 25 奈秒之間。然而,一旦多個執行緒同時爭搶鎖,自旋未果的執行緒就必須發起系統呼叫(如 Linux 的 SYS_futex),將自己掛起並交出 CPU。

一次完整的上下文切換(Context Switch)涉及暫存器狀態保存、進入核心態排程器佇列、切換虛擬記憶體頁表與上下文環境。這段延遲直接暴增至 1,000 到 5,000 奈秒(數量級約在 1 到 5 微秒,視架構與負載而定),比一次純記憶體操作慢了整整兩到三個數量級。

快取污染與快取行彈跳

當一個被掛起的執行緒在數微秒後被排程器重新喚醒時,極有可能被分派到另一個閒置的 CPU 核心上執行。這會帶來嚴重的快取懲罰:

  • 該執行緒原本在 L1/L2 快取中快取的熱點資料早已失效(Cold Cache Misses),甚至需要重新走 TLB 查表。
  • 多個核心爭奪同一個 Mutex 內部狀態變數時,每次加鎖與解鎖的原子寫入都會觸發快取一致性協定(如 MESI/MOESI)在多核心互連架構上引發大量無效化訊息。這會造成嚴重的快取行彈跳(Cacheline Bouncing),使該快取行的互連流量飽和,甚至拖慢共享同一快取階層的其他無關工作。

護送效應與擴展性懸崖

當系統面臨高度並發時,鎖的等待佇列會形成惡性循環的「護送效應」(Convoy Effect)。由於每個執行緒被喚醒時都要承擔額外的核心調度延遲,即使臨界區內的運算只有短短 20 奈秒,整個系統的推進節奏也會被微秒級的喚醒排程完全綁架。

此時系統不僅面臨阿姆達爾定律(Amdahl’s Law)所定義的循序上限(臨界區限制了理論最大加速比),更會觸發嚴重的負向擴展(Negative Scaling):增加更多的 CPU 核心非但無法提升吞吐量,反而因為更激烈的鎖競爭、快取行彈跳風暴與微秒級排程調度延遲放大,導致整體效能呈懸崖式暴跌。

優先權反轉

在具備執行緒優先權的即時或多工作業系統中,Mutex 存在著名的結構性缺陷——優先權反轉(Priority Inversion):

  • 低優先權執行緒獲取了鎖,進入臨界區。
  • 隨後,中優先權執行緒搶佔了 CPU(中優先權工作不需要該鎖,但優先級高於低優先權)。
  • 此時高優先權執行緒被喚醒,需要獲取同一個鎖,但由於低優先權執行緒被中優先權卡住而無法執行,高優先權執行緒被迫無限期等待。

1997 年美國 NASA 的火星探測器「火星拓荒者號」(Mars Pathfinder)便曾因為資訊匯流排 Mutex 發生優先權反轉,導致高優先權的資料收集任務嚴重逾時,觸發系統看門狗(Watchdog)不斷重新開機。

不可組合性與死鎖風險

鎖缺乏代數上的組合性(Locks do not compose)。兩個單獨看來無懈可擊、內部各自用 Mutex 保護的執行緒安全函式,在交叉呼叫時若未能嚴格遵循完全一致的加鎖順序,就會在多執行緒交錯下瞬間觸發死鎖(Deadlock)。

為什麼需要無鎖:不可妥協的場景與系統保證

正因為 Mutex 存在上述硬體與排程層面的代價,在特定工程領域中,我們並非單純為了「追求理論效能」,而是面對硬性約束時「不得不採用無鎖」。無鎖資料結構的核心價值在於提供兩大不可替代的保證:系統級進度保證低且可預測的長尾延遲

系統級進度保證:杜絕命運綁定

基於互斥鎖的系統存在著嚴重的命運綁定(Fate Sharing):一旦持有鎖的執行緒因為任何非預期事件(被作業系統強制暫停、遭遇分頁置換 Page Fault、觸發例外、甚至被外部訊號直接殺死),所有正在等待該鎖的其他執行緒都將全數卡死或永久阻塞

無鎖演算法消除了「鎖的所有權」概念。沒有任何一個執行緒具備獨佔資源的特權;執行緒之間只透過硬體原語競逐狀態轉移。即使某個執行緒在執行途中被作業系統隨機凍結,其他執行緒依然能各憑本事持續推進。

消除長尾延遲

在金融高頻交易(HFT)、即時競價、分散式儲存引擎與資料庫 WAL(Write-Ahead Log)的關鍵路徑上,系統衡量標準不是「平均延遲(P50)有多低」,而是「99.9% 甚至 99.99% 的長尾延遲(P99 / P99.9)有多穩定」。

Mutex 偶發的鎖競爭與 Context Switch 會在微秒層級造成嚴重的延遲抖動(Jitter)。Lock-Free 演算法將操作完全收斂在純使用者態與 CPU 執行單元內,徹底拔除了核心態排程介入的可能性,提供了極高確定性的延遲表現。

嚴格禁止睡眠的極限環境

在許多系統底層場景中,呼叫任何可能讓執行緒陷入睡眠的 Mutex 操作,在語法或規範上是被絕對嚴格禁止的

  • 中斷處理常式(ISR)與訊號處理函式(Signal Handlers):硬體中斷與 Linux 訊號發生在特殊的非同步上下文,該上下文根本沒有獨立的排程實體可供掛起,且必須保證非同步信號安全(Async-Signal-Safe)。一旦在此處嘗試加鎖並陷入睡眠,將導致系統崩潰或瞬間死鎖。
  • 即時音訊處理(Real-time Audio DSP):在 CoreAudio、JACK 或 ASIO 等音訊框架中,處理回呼函式必須在數微秒至數百微秒的嚴格時限內將音訊緩衝區填滿。若因 Mutex 競爭觸發 Context Switch,哪怕只延誤了 10 微秒,都會引發音訊緩衝區欠載(Buffer Underrun),在使用者耳機中產生刺耳的破音或爆音。
  • 非同步協程執行緒池(Async Runtime Worker Threads):在 Tokio、Go Runtime 等 M:N 協程排程體系中,少數幾個工作執行緒負責輪番執行成千上萬個非同步協程(Coroutines / Tasks)。如果某個協程的工作函式呼叫了傳統的 Blocking Mutex 並陷入核心態睡眠,將導致該 OS 執行緒上排隊的所有其他協程一併陷入飢餓與癱瘓。

進度保證等級:Lock-Free、Wait-Free 與 Obstruction-Free

在學術與系統規格中,「無鎖」並非單純指「程式碼中沒有搜尋到 mutex 關鍵字」,而是對系統並行推進能力的嚴格數學承諾:

  • Obstruction-Free(無障礙):最弱的保證。只要一個執行緒在執行時其他執行緒全部暫停,該執行緒保證能在有限步驟內完成操作。若多個執行緒持續活鎖競爭,則不保證進展。
  • Lock-Free(無鎖):保證整個系統在巨觀上持續有進度(System-wide Progress)。即使作業系統在任何時刻隨機將某些執行緒掛起(Suspend)或降低優先權,剩餘的執行緒中至少有一個一定能持續完成操作。但個別執行緒可能會遭遇飢餓(Starvation)。
  • Wait-Free(無等待):最強的進度保證。每一個執行緒都保證能在有限的步驟內(Bounded Steps)完成操作,徹底杜絕了個別執行緒的飢餓現象。通常需要搭配昂貴的協同幫忙(Helping Scheme)機制。

一般的並行佇列與堆疊多半落在 Lock-Free 等級。

核心原子原語:Compare-And-Swap (CAS) 的運作機制

Lock-Free 資料結構的心臟是 CAS 操作。在 C++ 中體現為 atomic::compare_exchange_weakcompare_exchange_strong;在 Rust 中則為 AtomicPtr::compare_exchange_weakcompare_exchange

CAS 操作將「比較目前值是否符合預期」與「寫入新數值」合併為語言層級不可分割的原子讀改寫(Read-Modify-Write, RMW)操作(在底層硬體上可能對應單一指令如 x86 CMPXCHG、或 ARM/RISC-V 的 LL/SC 指令對):

flowchart TD CAS_Start["<div style='width:290px; padding:4px 8px 8px 8px; line-height:1.4;'><b>CAS 原語呼叫</b><br/><code>CAS(target, expected, desired)</code></div>"] Check{"目標數值比對<br/>*target == expected ?"} DoStore["<div style='width:260px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>原子替換成功 (Success)</b><br/>• 寫入新值:<code>*target = desired</code><br/>• 狀態回傳:<code>true</code></div>"] DoUpdate["<div style='width:260px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>比對失敗 (Failure)</b><br/>• 更新預期:<code>expected = *target</code><br/>• 不修改 target,回傳 <code>false</code></div>"] CAS_Start --> Check Check -->|比對成功| DoStore Check -->|比對失敗| DoUpdate classDef proc fill:#24283b,stroke:#7aa2f7,stroke-width:1.5px,color:#c0caf5; classDef branch fill:#24283b,stroke:#bb9af7,stroke-width:1.5px,color:#bb9af7; classDef ok fill:#1f2335,stroke:#9ece6a,stroke-width:1.5px,color:#9ece6a; classDef fail fill:#1f2335,stroke:#f7768e,stroke-width:1.5px,color:#f7768e; class CAS_Start proc; class Check branch; class DoStore ok; class DoUpdate fail;

如果目標記憶體目前的值等於 expected,硬體就將其替換為 desired 並回傳成功;如果不等於,硬體放棄寫入,並自動將目前記憶體的最新值寫回 expected 變數中,回傳失敗。

為什麼 CAS 需要兩個 Memory Ordering 參數?

檢視 C++ 與 Rust 的 CAS 函式簽名,你會發現它們都要求傳入兩個 ordering:

// C++
bool compare_exchange_weak(T& expected, T desired,
                           std::memory_order success,
                           std::memory_order failure);
// Rust
pub fn compare_exchange_weak(
    &self,
    current: *mut T,
    new: *mut T,
    success: Ordering,
    failure: Ordering
) -> Result<*mut T, *mut T>;

為什麼不能只用一個 ordering?

  • 成功時(Success):執行了實質的讀改寫(RMW),我們正在發布新的狀態給其他執行緒,因此可以使用 ReleaseAcquireAcqRelSeqCst
  • 失敗時(Failure):沒有發生任何寫入操作!失敗的 CAS 本質上是一次純讀取的載入(將目標位址的最新值寫回 expected)。因為沒有寫入,所以在語意上失敗 ordering 不能要求 ReleaseAcqRel 效果;同時在 C++ 規範中,失敗 ordering 不得嚴於成功 ordering(例如成功為 Relaxed 時,失敗不得為 Acquire);而在 Rust 中若傳入不合法的失敗 ordering 亦會在執行期引發 panic。

weakstrong 的本質差異

  • compare_exchange_strong:保證只有在當前值不等於 expected 時才會回傳 false
  • compare_exchange_weak:允許在當前值明明等於 expected 的情況下,依然因為硬體因素(例如 ARM/RISC-V 的 LL/SC 快取行被踢出、中斷或 context switch)而發生虛假失敗(Spurious Failure)。

最佳實踐原則:在絕大多數 Lock-free 演算法中,CAS 本身就被包裹在一個重試迴圈裡。此時通常建議優先考慮 compare_exchange_weak。在弱記憶體架構(如 ARM64)上,weak 能直接映射到最精簡的單對 LL/SC(ldaxr/stlxr)指令,免去 strong 為了防止硬體虛假失敗所額外生成的內部重試迴圈包裝(但在 x86 等強記憶體架構上,兩者皆編譯為 lock cmpxchg,效能表現相同)。

經典實戰:Treiber Stack 雙語言基準實作(不含 SMR)

Treiber Stack(由 R. Kent Treiber 於 1986 年提出)是無鎖領域中最基礎、最優雅的資料結構。它本質上是一個單向鏈結串列,所有操作都僅集中在單一原子指標——head 上:

flowchart LR subgraph StackTopo["Treiber Stack 拓撲狀態 (單向鏈結結構)"] Head["<div style='width:120px; padding:4px 6px 8px 6px; line-height:1.3;'><b>head 指標</b><br/>原子指標 (CAS)</div>"] NodeA["<div style='width:105px; padding:4px 6px 8px 6px; line-height:1.3;'><b>Node A</b><br/>val: 10<br/>next ➔</div>"] NodeB["<div style='width:105px; padding:4px 6px 8px 6px; line-height:1.3;'><b>Node B</b><br/>val: 20<br/>next ➔</div>"] NodeC["<div style='width:105px; padding:4px 6px 8px 6px; line-height:1.3;'><b>Node C</b><br/>val: 30<br/>next ➔</div>"] NullNode["<div style='width:85px; padding:4px 6px 8px 6px; line-height:1.3;'><b>nullptr</b><br/>堆疊底部</div>"] Head --> NodeA NodeA --> NodeB NodeB --> NodeC NodeC --> NullNode end style StackTopo fill:#16161e,stroke:#414868,stroke-width:1.5px,color:#7aa2f7; classDef headBox fill:#24283b,stroke:#7dcfff,stroke-width:2px,color:#7dcfff; classDef nodeBox fill:#1f2335,stroke:#bb9af7,stroke-width:1.5px,color:#c0caf5; classDef nullBox fill:#16161e,stroke:#565f89,stroke-width:1px,color:#565f89; class Head headBox; class NodeA,NodeB,NodeC nodeBox; class NullNode nullBox;

⚠️ 重要實作前提:本節程式碼為不含 SMR(安全記憶體回收)的教學基準實作,旨在精確解構 CAS 狀態轉移與 Acquire/Release 配對。它的正確性嚴格建立在三個限制邊界假設上:

  1. 節點不得就地釋放(不得中途呼叫 delete 或歸還給記憶體配置器)。
  2. 節點不得重複 push,且呼叫端不得在其他執行緒可能持有指標時修改節點欄位。
  3. 節點生命週期維持至所有執行緒完全 join 離場後,由擁有者統一釋放(杜絕並行執行時的 Use-After-Free)。 在未引入後續章節探討的 SMR 安全回收協定前,切勿將此裸指標實作直接移植至生產環境!

C++20 實作

#include <atomic>
#include <cassert>
#include <iostream>
#include <thread>
#include <vector>

template <typename T>
class TreiberStack {
public:
    struct Node {
        T data;
        Node* next{nullptr};
        explicit Node(T val) : data(std::move(val)) {}
    };

private:
    // 注意:在主流 64 位元架構上 std::atomic<Node*>::is_always_lock_free 為 true。
    // 在特殊嵌入式或非原生字長環境下,需透過 is_lock_free() 確認硬體無鎖保證。
    std::atomic<Node*> head_{nullptr};

public:
    TreiberStack() = default;
    ~TreiberStack() = default;

    // 禁止複製與搬移以確保原子指標位址固定
    TreiberStack(const TreiberStack&) = delete;
    TreiberStack& operator=(const TreiberStack&) = delete;

    void push(Node* new_node) {
        // 步驟 1:使用 relaxed load 取得目前的 head
        // 理由:我們此處只是讀取指標位址賦予 new_node->next,
        // 尚未 dereference 舊節點,不需要任何跨執行緒同步。
        Node* old_head = head_.load(std::memory_order_relaxed);

        // 步驟 2:CAS 迴圈
        // 成功的 CAS 使用 release:確保 new_node 的 data 與 next 初始化完成後才發布。
        // 失敗的 CAS 使用 relaxed:若失敗,old_head 會被自動更新,直接進入下一輪。
        do {
            new_node->next = old_head;
        } while (!head_.compare_exchange_weak(
            old_head, new_node,
            std::memory_order_release,
            std::memory_order_relaxed));
    }

    Node* pop() {
        // 步驟 1:使用 acquire load 取得目前的 head
        // 理由:我們接下來必須安全讀取 old_head->next,因此必須與當初 push 該節點的 release store 同步!
        Node* old_head = head_.load(std::memory_order_acquire);

        while (old_head != nullptr) {
            // 讀取下一個節點(此時依賴前面 load 或上一次失敗 CAS 的 acquire 同步)
            Node* next_node = old_head->next;

            // 步驟 2:CAS 嘗試將 head 移向 next_node
            // 成功:使用 acquire 保持防禦性同步。
            // 失敗:關鍵!失敗 ordering 必須也是 acquire!
            // 理由:若 CAS 失敗,old_head 會被更新為另一個執行緒剛 push 上去的新節點;
            // 下一輪迴圈會立刻執行 old_head->next,此處的 acquire 才能與該執行緒 push 的 release 建立因果同步!
            if (head_.compare_exchange_weak(
                    old_head, next_node,
                    std::memory_order_acquire,
                    std::memory_order_acquire)) {
                return old_head;
            }
        }
        return nullptr; // stack 為空
    }
};

int main() {
    TreiberStack<int> stack;
    typename TreiberStack<int>::Node n1(10);
    typename TreiberStack<int>::Node n2(20);

    // 多執行緒並行 Push
    std::thread t1([&] { stack.push(&n1); });
    std::thread t2([&] { stack.push(&n2); });
    t1.join();
    t2.join();

    // 多執行緒並行 Pop
    typename TreiberStack<int>::Node* res1 = nullptr;
    typename TreiberStack<int>::Node* res2 = nullptr;

    std::thread t3([&] { res1 = stack.pop(); });
    std::thread t4([&] { res2 = stack.pop(); });
    t3.join();
    t4.join();

    assert(res1 != nullptr && res2 != nullptr);
    assert(res1 != res2);
    assert(res1->data + res2->data == 30);
    assert(stack.pop() == nullptr);

    std::cout << "Treiber Stack operations completed successfully.\n";
}

Rust 實作

在 Rust 中,裸指標的解引用屬於 unsafe 操作。Rust 的型別系統迫使我們明確寫出哪些假設是由程式員擔保的:

use std::sync::atomic::{AtomicPtr, Ordering};
use std::sync::Arc;
use std::thread;

pub struct Node<T> {
    pub data: T,
    pub next: *mut Node<T>,
}

impl<T> Node<T> {
    pub fn new(data: T) -> Self {
        Self {
            data,
            next: std::ptr::null_mut(),
        }
    }
}

pub struct TreiberStack<T> {
    head: AtomicPtr<Node<T>>,
}

// 實作 Send 與 Sync:這是作者對型別安全不變量的承諾
// 只要 T: Send,節點所有權在 pop 時即可安全轉移跨執行緒存取;
// 由於內部 head 為 AtomicPtr,亦滿足多執行緒共用參照的安全性。
unsafe impl<T: Send> Send for TreiberStack<T> {}
unsafe impl<T: Send> Sync for TreiberStack<T> {}

impl<T> TreiberStack<T> {
    pub fn new() -> Self {
        Self {
            head: AtomicPtr::new(std::ptr::null_mut()),
        }
    }

    pub fn push(&self, new_node: *mut Node<T>) {
        assert!(!new_node.is_null());
        let mut old_head = self.head.load(Ordering::Relaxed);
        loop {
            // 安全保證:呼叫者保證在此刻獨占 new_node
            unsafe {
                (*new_node).next = old_head;
            }

            // 成功 Release 發布新節點;失敗 Relaxed 載入最新 head 重試
            match self.head.compare_exchange_weak(
                old_head,
                new_node,
                Ordering::Release,
                Ordering::Relaxed,
            ) {
                Ok(_) => break,
                Err(actual) => old_head = actual,
            }
        }
    }

    pub fn pop(&self) -> *mut Node<T> {
        let mut old_head = self.head.load(Ordering::Acquire);
        while !old_head.is_null() {
            // 安全保證:基準範例保證節點在全部執行緒離場前不會被 free
            let next_node = unsafe { (*old_head).next };

            // 成功 Acquire 獲取;失敗 Acquire 確保下輪解引用 actual 時具備同步保護
            match self.head.compare_exchange_weak(
                old_head,
                next_node,
                Ordering::Acquire,
                Ordering::Acquire,
            ) {
                Ok(_) => return old_head,
                Err(actual) => old_head = actual,
            }
        }
        std::ptr::null_mut()
    }
}

// 輔助型別:封裝裸指標以符合 Send 要求,安全傳遞進 thread::spawn
struct SendPtr<T>(*mut Node<T>);
unsafe impl<T: Send> Send for SendPtr<T> {}
impl<T> SendPtr<T> {
    fn into_inner(self) -> *mut Node<T> {
        self.0
    }
}

fn main() {
    let stack = Arc::new(TreiberStack::new());
    let mut node1 = Box::new(Node::new(10));
    let mut node2 = Box::new(Node::new(20));

    let n1_ptr = SendPtr(&mut *node1 as *mut Node<i32>);
    let n2_ptr = SendPtr(&mut *node2 as *mut Node<i32>);

    let s1 = Arc::clone(&stack);
    let t1 = thread::spawn(move || s1.push(n1_ptr.into_inner()));
    let s2 = Arc::clone(&stack);
    let t2 = thread::spawn(move || s2.push(n2_ptr.into_inner()));
    t1.join().unwrap();
    t2.join().unwrap();

    let s3 = Arc::clone(&stack);
    let t3 = thread::spawn(move || SendPtr(s3.pop()));
    let s4 = Arc::clone(&stack);
    let t4 = thread::spawn(move || SendPtr(s4.pop()));

    let r1 = t3.join().unwrap().into_inner();
    let r2 = t4.join().unwrap().into_inner();

    assert!(!r1.is_null() && !r2.is_null());
    assert_ne!(r1, r2);

    let sum = unsafe { (*r1).data + (*r2).data };
    assert_eq!(sum, 30);
    assert!(stack.pop().is_null());

    println!("Rust TreiberStack executed safely.");
}

線性化點(Linearization Point)剖析

在並行驗證理論中,一個無鎖操作必須存在一個確切的原子瞬間,使得該操作在該時刻「邏輯生效」,這被稱為線性化點

  • push 的線性化點:CAS 成功將 head 指向 new_node 的那一瞬間。
  • pop 的線性化點(非空):CAS 成功將 head 指向 next_node 的那一瞬間。
  • pop 的線性化點(為空):當 head.load(Acquire) 讀到 nullptr 的那一瞬間。

致命幽靈:ABA 問題深度剖析

現在,讓我們放寬前面基準實作的假設:「允許節點被重複使用或歸還配置器」。災難立刻降臨。

這就是並行計算中最著名的陷阱——ABA 問題

ABA 問題如何發生?

假設目前的 Stack 拓撲為 head -> [A] -> [B] -> [C]

此時有兩個執行緒 T1 與 T2 正在執行 pop()

sequenceDiagram autonumber participant T1 as 執行緒 1 (T1) participant Stack as 共享狀態 (head) participant T2 as 執行緒 2 (T2) Note over Stack: head 指向 A<br/>A.next = B, B.next = C T1->>Stack: 讀取 head = A T1->>T1: 讀取 next = A.next (值為 B) Note over T1: T1 被作業系統 Context Switch 掛起!<br/>(暫停在 CAS 執行前) T2->>Stack: 執行 pop() 成功取出 A T2->>Stack: 執行 pop() 成功取出 B Note over Stack: 此時 head 指向 C T2->>T2: 將節點 A 重新利用或修改 T2->>Stack: 執行 push(A) (A.next 設為 C) Note over Stack: 目前 head 重新指向 A!<br/>但拓撲已變成 A -> C Note over T1: T1 被喚醒,準備執行 CAS! T1->>Stack: CAS(head, expected=A, desired=B) Note over Stack,T1: 檢查 head 目前值為 A,與 expected 相等!<br/>CAS 宣告成功!將 head 設為 B! Note over Stack: 災難降臨:<br/>head 被指向了已被釋放或移走的 B!<br/>節點 C 永久遺失,引發記憶體損毀!

拆解 ABA 的本質

當 T1 喚醒並嘗試執行 CAS 時,它僅僅比對了 head == A。 在 T1 的眼裡:「head 的記憶體位址依然是 A,代表沒有人動過這個 stack!」 但實際上,整個 Stack 經歷了 A → B → A 的劇烈變動:

  1. B 早已被 T2 取走並可能已被 delete 釋放。
  2. 節點 A 雖然位址回到頂端,但它的 next 已經從原先的 B 變成了 C。
  3. T1 盲目地將 head 覆寫為暫存的 B,直接導致:
    • 資料遺失:節點 C 脫離了鏈結串列,發生記憶體洩漏。
    • 記憶體損毀(Use-After-Free):head 指向了一個已經被 T2 釋放的無效位址 B,下一個呼叫 pop() 的執行緒解引用 head->next 時將立刻崩潰(Crash / Segment Fault)!

ABA 的核心教訓是:指標位址相等,絕不代表資料結構的內部拓撲與歷史狀態未曾改變

四大安全記憶體回收機制(Safe Memory Reclamation)

為了解決 ABA 問題以及多執行緒並行釋放節點時的 Use-After-Free,學界與工業界發展出了四種主流機制。在深入細節前,我們必須先釐清一條核心界線:Tagged Pointer 與後三種 SMR 機制的防護目標有著本質區別

  • Tagged Pointer:本質是「狀態變更偵測協定」。它透過版本號偵測指標位址相同時的拓撲演變,但計數器仍有溢位循環(Wraparound)的理論可能,且它完全無法防護節點的解引用生命週期
  • Hazard Pointer / EBR / RCU:屬於真正的「安全記憶體回收(SMR)協定」。它們的主要目標是確保「只要有任何讀者可能解引用某個節點,該節點的物理記憶體就絕不會被釋放」。在工程實踐中,這直接消除了最常見的 ABA 根源(即節點被釋放後遭記憶體配置器原地重用);但若演算法本身存在非記憶體重用引起的語意 ABA,仍需搭配版本號處理。
flowchart TD SMR["<div style='width:520px; padding:6px 12px 10px 12px; line-height:1.4;'><b>安全記憶體回收與防護機制 (Safe Memory Reclamation, SMR)</b><br/>解決無鎖資料結構中的 ABA 陷阱與 Use-After-Free 記憶體安全核心課題</div>"] subgraph Solutions["四大主流回收演算法架構"] direction LR subgraph GroupA["狀態檢測與讀者看板"] direction TB M1["<div style='width:270px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>1. Tagged Pointer</b><br/>• 單調遞增版本標記 (DWCAS / 壓縮指標)<br/>• 攔截 ABA,但無法防護生命週期</div>"] M2["<div style='width:270px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>2. Hazard Pointer (C++26)</b><br/>• 讀者公開登記目前存取節點<br/>• 精確保護單一節點,回收邊界明確</div>"] M1 --> M2 end subgraph GroupB["世代標記與寬限期回收"] direction TB M3["<div style='width:270px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>3. Epoch-Based (EBR)</b><br/>• 全域 Epoch 推進 + 三代分桶批次回收<br/>• 讀者近乎零代價,Rust 生態首選</div>"] M4["<div style='width:270px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>4. Read-Copy-Update (RCU)</b><br/>• 寫入者複製副本,寬限期延遲釋放<br/>• 讀者近乎零屏障,Linux 經典</div>"] M3 --> M4 end GroupA -.- GroupB end SMR --> Solutions style Solutions fill:#16161e,stroke:#414868,stroke-width:1.5px,color:#7aa2f7; style GroupA fill:#1f2335,stroke:#bb9af7,stroke-width:1px,color:#bb9af7; style GroupB fill:#1f2335,stroke:#7dcfff,stroke-width:1px,color:#7dcfff; classDef root fill:#24283b,stroke:#7dcfff,stroke-width:2px,color:#7dcfff; classDef cardA fill:#24283b,stroke:#bb9af7,stroke-width:1.5px,color:#c0caf5; classDef cardB fill:#24283b,stroke:#7dcfff,stroke-width:1.5px,color:#c0caf5; class SMR root; class M1,M2 cardA; class M3,M4 cardB;

Tagged / Versioned Pointer(代數標記指標)

最直接的直覺是:既然只看指標會被騙,那我們在指標旁邊加上一個單調遞增的版本號(Tag / Counter)!

每次對 head 進行修改時,不僅更新指標,還將版本號加 1:

狀態演變:
(指標 A, 版本 1) ➜ (指標 B, 版本 2) ➜ (指標 C, 版本 3) ➜ (指標 A, 版本 4)

當 T1 甦醒嘗試執行 CAS 時:

  • T1 預期的狀態是 (A, 1)
  • 目前 head 的實際狀態是 (A, 4)
  • CAS 判定兩者不相等,失敗!ABA 被成功攔截。

實作方式與限制

  1. 雙倍寬度 CAS(DWCAS):在 64-bit 系統上,指標佔 64-bit,計數器佔 64-bit,總共需要 128-bit 的原子 CAS(x86-64 的 CMPXCHG16B 或 AArch64 的 CASP)。需注意在部分平臺上 128-bit 原子操作可能非硬體原生無鎖(需透過 is_lock_free() 驗證),且在高頻競爭下版本計數器仍存在循環繞回(Wraparound)風險。
  2. 指標壓縮(Pointer Packing):在 64-bit 虛擬位址空間中,指標僅使用規範位址(Canonical Address)的低位元。在四級分頁(48-bit 虛擬位址)下,使用者空間位址的高 16 位元可被借用作為版本標記;若在五級分頁(57-bit 虛擬位址)架構下,則僅剩最高 7 位元可用。重大相容性限制:嵌入標記後的指標並非合法位址,解引用前必須先透過 bitmask 遮罩清除標記;且在 AArch64 架構上,硬體功能如頂部字節忽略(TBI)、記憶體標記擴展(MTE)與指標認證(PAC)皆會使用指標高位元,任意壓縮指標會破壞跨平台可攜性。
  3. 重大盲點:Tagged Pointer 能偵測狀態改變,但完全無法保護記憶體生命週期!如果在 T1 讀取 old_head->next 的瞬間,節點 A 的記憶體已經被 T2 歸還給作業系統(munmap),T1 的讀取操作依然會直接觸發硬體分頁錯誤(Page Fault)崩潰!

Hazard Pointer(風險指標)

由 Maged Michael 於 2004 年提出(論文發表於 2002 年),已正式納入 C++26 標準庫std::hazard_pointer)。

Hazard Pointer 的設計哲學是:讀者在存取某個節點前,先在全域公開的看板(Hazard Pointer Array)上宣告:「我正在閱讀指標 P,誰都不准釋放它!」

flowchart TD HP_1["<div style='width:280px; padding:4px 8px 8px 8px; line-height:1.4;'><b>1. 讀取指標</b><br/>讀取 <code>head</code> 取得當前節點指標 <code>P</code></div>"] HP_2["<div style='width:280px; padding:4px 8px 8px 8px; line-height:1.4;'><b>2. 登記看板 (Publish)</b><br/>將 <code>P</code> 寫入當前執行緒的 Hazard Slot</div>"] HP_3{"二次驗證 (Recheck)<br/>head == P ?"} HP_Retry["<div style='width:220px; padding:4px 8px 8px 8px; line-height:1.4;'><b>重試迴圈</b><br/>• 清除 Hazard Slot 標記<br/>• 重新讀取 <code>head</code> 重試</div>"] HP_4["<div style='width:280px; padding:4px 8px 8px 8px; text-align:left; line-height:1.4;'><b>4. 安全存取節點 (Safe Access)</b><br/>• 其他執行緒保證不會釋放 <code>P</code><br/>• 安全解引用 <code>P->next</code> 進行運算</div>"] HP_5["<div style='width:280px; padding:4px 8px 8px 8px; line-height:1.4;'><b>5. 解除保護 (Release)</b><br/>操作完成,清除 Hazard Slot 登記</div>"] HP_1 --> HP_2 HP_2 --> HP_3 HP_3 -->|被搶先修改| HP_Retry HP_Retry -.->|重新讀取| HP_1 HP_3 -->|驗證通過| HP_4 HP_4 --> HP_5 classDef proc fill:#24283b,stroke:#7aa2f7,stroke-width:1.5px,color:#c0caf5; classDef branch fill:#24283b,stroke:#bb9af7,stroke-width:1.5px,color:#bb9af7; classDef retry fill:#1f2335,stroke:#f7768e,stroke-width:1.5px,color:#f7768e; classDef ok fill:#1f2335,stroke:#9ece6a,stroke-width:1.5px,color:#9ece6a; class HP_1,HP_2,HP_5 proc; class HP_3 branch; class HP_Retry retry; class HP_4 ok;

回收流程(Retire)

當某個執行緒成功將節點 P 從 Stack 中 pop 出來時,它不能立刻釋放 P。而是將 P 放入該執行緒私有的「待回收清單(Retired List)」。 當 Retired List 累積到一定閾值時,執行緒發動垃圾回收:

  1. 掃描系統中所有執行緒目前登記的 Hazard Pointer 看板。
  2. 如果節點 P 出現在任何一個看板上,說明仍有讀者正在讀取它,保留 P
  3. 如果沒有任何看板引用 P,說明所有讀者都已經知曉 P 已被移除,安全釋放 Pdeletefree)。
  • 優點:記憶體回收上限有嚴格保證,不會因個別執行緒暫停而導致記憶體無限制膨脹;對單一節點保護精確。
  • 缺點:讀者存取節點時需寫入並重新驗證當前執行緒的 Hazard Slot,且回收端(Reclaimer)掃描所有執行緒看板時需負擔全域記憶體屏障開銷;此外走訪長鏈結串列時需要動態維護多個 Hazard Slot。

Epoch-Based Reclamation (EBR)

Epoch-Based Reclamation 是目前高效能無鎖資料結構(特別是 Rust 生態系,如標竿庫 crossbeam-epoch)最廣泛採用的方案。

EBR 不去逐一追蹤每一個節點,而是將整個系統的時間切分成 世代(Epoch,例如 0, 1, 2):

flowchart TD subgraph GlobalEpoch["全域狀態 (Global System State)"] GE["<div style='width:450px; padding:4px 8px 8px 8px; line-height:1.4;'><b>全域 Epoch 計數器 (Global Epoch: E)</b><br/>• 全域單調推進 (0 ➔ 1 ➔ 2 ➔ 0 循環)<br/>• 當所有活躍執行緒皆離開舊世代時,全域推進到下一代</div>"] end subgraph ThreadWork["讀者執行緒存取生命週期 (Reader Lifecycle)"] direction LR Pin["<div style='width:180px; padding:4px 6px 8px 6px; line-height:1.3;'><b>進入臨界區:pin()</b><br/>• 登記本地為世代 E<br/>• 宣告自身活躍中</div>"] Read["<div style='width:180px; padding:4px 6px 8px 6px; line-height:1.3;'><b>走訪無鎖資料結構</b><br/>• 安全解引用節點<br/>• 零額外硬體屏障</div>"] Unpin["<div style='width:180px; padding:4px 6px 8px 6px; line-height:1.3;'><b>離開臨界區:unpin()</b><br/>• 清除活躍登記<br/>• 允許世代推進</div>"] Pin -->|"存取"| Read Read -->|"完成"| Unpin end subgraph RetireQueue["待回收佇列:三代分桶防護 (Three-Epoch Buckets)"] direction LR B2["<div style='width:180px; padding:4px 6px 8px 6px; line-height:1.3;'><b>當前世代 (Epoch E)</b><br/>• 收集被 pop 移除節點<br/>• 尚有當代讀者存取中</div>"] B1["<div style='width:180px; padding:4px 6px 8px 6px; line-height:1.3;'><b>上一世代 (Epoch E-1)</b><br/>• 等待舊世代讀者離場<br/>• 暫緩釋放</div>"] B0["<div style='width:180px; padding:4px 6px 8px 6px; line-height:1.3;'><b>安全世代 (Epoch E-2)</b><br/>• 絕無讀者持有指標<br/>• <b>★ 安全批次釋放</b></div>"] B2 -->|"推進"| B1 B1 -->|"推進"| B0 end GlobalEpoch -->|"標記世代"| ThreadWork ThreadWork -->|"節點退役入桶"| RetireQueue style GlobalEpoch fill:#16161e,stroke:#7dcfff,stroke-width:1.5px,color:#7dcfff; style ThreadWork fill:#16161e,stroke:#bb9af7,stroke-width:1.5px,color:#bb9af7; style RetireQueue fill:#16161e,stroke:#9ece6a,stroke-width:1.5px,color:#9ece6a; classDef glob fill:#24283b,stroke:#7dcfff,stroke-width:1.5px,color:#c0caf5; classDef work fill:#24283b,stroke:#bb9af7,stroke-width:1.5px,color:#c0caf5; classDef ret fill:#24283b,stroke:#9ece6a,stroke-width:1.5px,color:#c0caf5; class GE glob; class Pin,Read,Unpin work; class B0,B1,B2 ret;

EBR 的黃金準則

  1. 任何執行緒在存取無鎖結構前,必須先呼叫 guard = epoch::pin()。這會將當前執行緒標記為活躍,並綁定在當前全域 Epoch。
  2. 只要執行緒處於 pin() 狀態,它所看見的所有節點都保證不會被釋放
  3. 被移除的節點會被標記退役(Retire)並丟入當前 Epoch 的垃圾箱。
  4. 推進世代:當系統發現所有曾活躍於前一世代的執行緒皆已離場(不再 pin 在較舊世代)時,全域 Epoch 即可安全推進至下一代。
  5. 安全回收:處於 (E - 2) 世代的垃圾,保證沒有任何存活的讀者能看見,可以安全批次釋放
  • 優點:在 pin() 期間,讀取任何節點完全不需要任何原子寫入或硬體屏障,效能幾乎等同於讀取一般指標,非常適合走訪大規模樹狀結構或跳躍表(SkipList)。
  • 缺點(致命傷):若有任何一個執行緒呼叫了 pin() 後發生長久停頓(例如執行耗時計算、被作業系統搶佔或發生 I/O 阻塞),全域 Epoch 將無法推進。這會導致垃圾回收被無限期延宕,系統中累積的退役節點記憶體無界膨脹,最終引發 OOM(Out of Memory)!值得澄清的是,這只會卡住記憶體回收流程,其他執行緒在資料結構本身的 push/pop 運作依然能持續推進。

Read-Copy-Update (RCU)

RCU 是 Linux 核心中支撐百萬級網路轉發與檔案系統路由的核心機制,亦有使用者空間實作(Userspace RCU, liburcu)。此外,C++26 草案亦已正式納入 <rcu> 標頭檔支援。

RCU 特別針對 讀極多、寫極少(Read-Mostly)的資料結構設計:

  • Reader:透過 rcu_read_lock() 進入臨界區,不執行任何鎖定、不修改任何共享計數器,直接讀取指標。讀取開銷近乎為零。
  • Writer:不能就地修改資料。必須先複製一份舊資料副本,在副本上完成修改,接著透過原子指標替換(Release store)將入口切換到新版本。
  • 寬限期(Grace Period)與非阻塞回呼:切換入口後,舊版本資料不能立刻釋放。Writer 可呼叫 synchronize_rcu() 阻塞等待,直到切換前就已進入臨界區的所有舊讀者全部執行完畢離場;或呼叫非阻塞的 call_rcu() 註冊回呼函式,待寬限期結束後由背景機制非同步安全銷毀舊版本資料。

四大記憶體回收機制架構對比

機制 保護粒度 讀取端開銷 記憶體上限保證 對執行緒長久停頓(Stall)敏感度 典型應用場景
Tagged Pointer 單一指標 零額外讀取負擔(需 DWCAS) 無(無法解決釋放引發的崩潰) 不敏感 僅防範拓撲 ABA,需搭配其他回收方案
Hazard Pointer 個別節點 中等(每次解引用需寫入全域狀態並下屏障) 嚴格保證(垃圾量有明確上限) 極佳(單一執行緒暫停僅卡住少數節點) 節點數量少、執行緒可能隨機掛起或有即時性要求之系統
Epoch-Based (EBR) 整段操作臨界區 極低(僅進入/離開時標記,走訪零負擔) 弱(取決於最慢的執行緒) 極高(單一執行緒停頓會拖垮全域回收) 高效能記憶體快取、跨執行緒並行 Map/SkipList(如 crossbeam
RCU 整個資料版本 近乎為零(普通指標存取) 弱(寬限期內需維持雙版本) 高(需等待寬限期排空) 路由表、設定檔更新、讀極多寫極少之系統服務

全部改成 SeqCst,也救不了生命週期

並行開發者常犯的一個危險認知是:「既然 memory ordering 這麼複雜,我乾脆把全專案的原子操作全部改成 std::memory_order_seq_cstOrdering::SeqCst,這樣不就萬無一失了嗎?」

讓我們用一個最殘酷的時序交錯來擊碎這個幻想:

假設 head 指標全域採用 SeqCst,不使用任何 SMR 回收防護:

執行緒 1 (Reader)                     執行緒 2 (Reclaimer)
------------------------------------------------------------
Node* p = head.load(SeqCst);
// 此時 p 存放節點 A 的記憶體位址
                                      head.compare_exchange_strong(p, p->next, SeqCst);
                                      // 成功將 A 從鏈結中移除!
                                      delete p; // 釋放節點 A 的記憶體!

int val = p->data; // USE-AFTER-FREE 災難!
// 記憶體已被釋放,甚至已被作業系統收回,直接觸發 Segment Fault!

在這個時序中:

  • 執行緒 1 的 load 是完全合法的 SeqCst
  • 執行緒 2 的 CAS 與 delete 也是完全合法的 SeqCst
  • 整個過程中沒有任何違反記憶體順序的情形發生

然而,程式依然崩潰了

這是因為:Memory Ordering 管的是變數寫入與觀察的可見性因果;而 SMR 管的是底層記憶體區塊實體是否依然存活合法! 最強的記憶體順序也無法穿越空間,阻止作業系統將已經 free 掉的記憶體分頁標記為無效。

Lock-Free 程式碼審查三大檢驗清單

在審查任何無鎖演算法或資料結構時,請務必按照以下三大獨立維度逐一檢驗:

  1. 狀態轉移正確性(State Transition & CAS):
    • CAS 的條件是否足以代表系統真實狀態?
    • 是否存在指標重複配置造成的 ABA 偽成功?(是否需要 Tagged Pointer 或版本號?)
    • 失敗重試路徑是否會發生死迴圈或活鎖?
  2. 可見性同步保證(Memory Ordering):
    • 新節點在寫入原子變數對外發布前,內部資料是否使用 Release 確立因果?
    • 讀者在解引用指標前,是否使用 Acquire 與發布者建立 synchronizes-with
    • CAS 的失敗 ordering 是否正確排除了 Release,並在需要重試解引用時維持了 Acquire
  3. 記憶體生命週期安全(Safe Memory Reclamation):
    • 當指標被載入到執行緒暫存器後,到真正存取完畢期間,該記憶體區塊是否受到 HP 或 EBR 的保護?
    • 節點退役(Retire)後,是否有嚴格的寬限期或計數比對機制,確認所有並行讀者全數離場才執行物理釋放?

AI 在無鎖程式設計中的角色:輔助撰寫與形式驗證

隨著大型語言模型(LLM)與 AI 輔助程式設計工具(如 Claude Code)的普及,許多開發者開始嘗試讓 AI 撰寫或最佳化無鎖資料結構。在一般業務邏輯中,AI 能大幅提升產能;但在並行程式設計——尤其是弱記憶體模型與硬體原子原語的世界裡,AI 究竟是強大夥伴,還是潛伏的定時炸彈?

我們應該如何正確運用 AI 來輔助無鎖程式設計與程式碼驗證?

為什麼不能盲目信任 AI 寫出的 Lock-Free 程式碼?

無鎖程式設計本質上是極端交錯狀態下的離散數學。而現今的 LLM 是基於機率分佈預測下一個 token 的模型,擅長提取常見模式,但在面對非直覺的底層硬體特性時,極易產生隱蔽盲區:

  • 幻覺與過度簡化(The Naive Loop Illusion):當你要求 AI「用 C++ 寫一個無鎖 Stack」時,它幾乎千篇一律會給出教科書式的裸指標 CAS 迴圈。這段程式碼在單執行緒或低並發測試時完全正常,但 AI 往往對 ABA 問題隻字不提,更完全忽略了節點被 delete 後並行讀者引發的 Use-After-Free 崩潰。
  • 記憶體順序的隨機性:在要求 AI 最佳化效能時,AI 經常在沒有精確因果依據的情況下,將 SeqCst 降級為 Relaxed,遺漏關鍵的 Acquire 讀取屏障或 Release 發布屏障;或者在 CAS 失敗分支上錯誤地配置了未定義的記憶體順序。
  • 生命週期管理的缺失:實作一個健全的 Hazard Pointer 或 EBR 機制需要極度嚴謹的批次狀態機(包含執行緒註冊、看板掃描、世代三代推進等)。AI 往往只能生成空有函式骨架的虛構實作,在真實高並發壓力下瞬間被記憶體洩漏或懸置指標擊垮。

未經嚴格驗證的 AI 無鎖程式碼,最危險之處在於它看起來無比正確——編譯毫無警告,單元測試跑過一萬次也全數通過,卻在生產環境的 ARM64 伺服器連續運行數天後偶然觸發非法記憶體存取。

AI 的真實價值:作為對抗性審查員(Adversarial Reviewer)

既然不能讓 AI 閉眼裸寫核心無鎖邏輯,AI 的真正威力該如何發揮?答案是翻轉角色:不要讓 AI 當「架構師」,而是讓它擔任「對抗性質疑者」(Red Teaming Reviewer)。

人類工程師在審查自己撰寫的並行程式碼時,極容易陷入思維定勢(Confirmation Bias),預設執行緒會照著自己構想的理想順序前進。而 AI 沒有這種心理負擔,只要給予正確的約束,它能高效扮演挑錯的黑客角色。

提示詞設計策略:逼問邊界交錯

在請 AI 審查人類撰寫的無鎖程式碼時,避免使用「這段程式碼有沒有問題?」這種寬泛提問,而應當給予明確的底層架構約束:

針對弱記憶體模型的審查 Prompt 範例
「請扮演資深並行系統核心工程師。審查以下 C++20 無鎖佇列的 pop() 實作。請特別假設運行於 ARM64 架構(具備 Store Buffer 與弱記憶體順序):

  1. 請檢查 head.load 與後續存取內部欄位之間,是否存在任何指令重排(Reordering)可能讀取到未初始化的資料?
  2. 請構造一個精確的雙執行緒交錯時序(Interleaving trace),證明在特定的 CAS 失敗重試路徑上,是否可能發生 ABA 或存取已被釋放的記憶體?
  3. 列出所有你認為可以進一步降級或必須升級的 std::memory_order,並嚴格給出因果依據。」

在這種對抗性約束下,AI 能極其迅速地指出人類肉眼容易漏看的分支細節,例如「CAS 失敗時 expected 更新與下一次重試讀取之間的屏障漏洞」。

終極閉環:AI 結合符號模型檢查(Formal Verification)

無論是人類專家還是 AI 審查,本質上都依賴經驗推演,無法窮舉龐大的並行交錯空間。要在工程上獲得真正的數學級確定性,必須將 AI 融入形式化驗證工具鏈(Formal Verification Toolchain):

flowchart TD subgraph HumanPhase["人類工程師核心主導"] H1["<div style='width:460px; padding:4px 8px 8px 8px; line-height:1.4;'><b>架構設計與狀態機定義</b><br/>• 定義無鎖拓撲、CAS 不變量與 SMR 回收策略</div>"] end subgraph AIPhase["AI 協同分析與測試生成 (LLM Augmented)"] direction LR A1["<div style='width:225px; padding:4px 6px 8px 6px; text-align:left; line-height:1.3;'><b>對抗性代碼審查</b><br/>• 弱記憶體重排推演<br/>• 構造極限 ABA 交錯</div>"] A2["<div style='width:225px; padding:4px 6px 8px 6px; text-align:left; line-height:1.3;'><b>驗證套件生成</b><br/>• 自動產出 Loom 測試<br/>• 轉譯 TLA+ / GenMC 規格</div>"] A1 -.- A2 end subgraph VerifyPhase["確定性形式驗證 (Formal Verification)"] direction LR V1["<div style='width:225px; padding:4px 6px 8px 6px; text-align:left; line-height:1.3;'><b>Loom / GenMC 窮舉</b><br/>• 探索所有並行排程狀態<br/>• 捕捉百萬分之一時序 Bug</div>"] V2["<div style='width:225px; padding:4px 6px 8px 6px; text-align:left; line-height:1.3;'><b>TSan 動態競爭偵測</b><br/>• 記憶體存取插樁分析<br/>• 攔截底層 Data Race</div>"] V1 -.- V2 end HumanPhase --> AIPhase AIPhase --> VerifyPhase VerifyPhase --> Result{"形式驗證結果?"} Result -->|通過驗證| Deploy["<div style='width:250px; padding:4px 8px 8px 8px; line-height:1.4;'><b>★ 具備數學保證的生產級實作</b><br/>通過全狀態空間探索與壓力測試</div>"] Result -->|發現反例| Fix["<div style='width:250px; padding:4px 8px 8px 8px; line-height:1.4;'><b>反例日誌分析 (Trace Analysis)</b><br/>AI 解讀報錯時序,輔助定位修補</div>"] style HumanPhase fill:#16161e,stroke:#7dcfff,stroke-width:1.5px,color:#7dcfff; style AIPhase fill:#16161e,stroke:#bb9af7,stroke-width:1.5px,color:#bb9af7; style VerifyPhase fill:#16161e,stroke:#414868,stroke-width:1.5px,color:#7aa2f7; classDef human fill:#24283b,stroke:#7dcfff,stroke-width:1.5px,color:#c0caf5; classDef ai fill:#24283b,stroke:#bb9af7,stroke-width:1.5px,color:#c0caf5; classDef verify fill:#24283b,stroke:#7aa2f7,stroke-width:1.5px,color:#c0caf5; classDef branch fill:#24283b,stroke:#bb9af7,stroke-width:1.5px,color:#bb9af7; classDef ok fill:#1f2335,stroke:#9ece6a,stroke-width:1.5px,color:#9ece6a; classDef fail fill:#1f2335,stroke:#f7768e,stroke-width:1.5px,color:#f7768e; class H1 human; class A1,A2 ai; class V1,V2 verify; class Result branch; class Deploy ok; class Fix fail;

讓 AI 生成 Loom 與 GenMC 形式測試 Harness

撰寫形式化測試套件(如 Rust 的 loom 或 C++ 的 GenMC)相當繁瑣,需將所有原生型別替換為模型檢查器的特定原語。這恰好是 AI 的絕佳施展場域:

  • Prompting 任務:「將以下這段生產級 Treiber Stack 的 Rust 實作,改寫為 loom 模型測試案例。使用 loom::sync::atomicloom::thread,設定 2 個執行緒同時進行並行 push 與 pop,驗證是否滿足 LIFO 屬性與記憶體無外洩。」
  • AI 能在數秒內生成規範的驗證環境,讓模型檢查器窮舉成千上萬種合法執行緒排程。

讓 AI 解讀反例追蹤日誌(Counterexample Trace)

當模型檢查器發現錯誤時,輸出的反例日誌往往龐大無比,列出數十個排程步驟與記憶體存取歷史。人類工程師需要花費數小時追蹤究竟是哪一個執行緒的哪一行指令觸發了狀態不一致。

此時將反例日誌餵給 AI:

「這是在執行 GenMC 模型檢查時回報的失敗日誌。請追蹤 Execution Graph,指出在第幾個 step 時發生了哪兩個記憶體存取的因果斷裂?是哪一行程式碼的 memory ordering 不足以建立 synchronizes-with?」

AI 具備強大的符號模式匹配能力,能精準從數百行交錯記錄中萃取出關鍵的因果漏洞,並給予精準的修復建議。

透過「人類定架構 ➔ AI 擬推演並編寫測試模型 ➔ 符號檢查器嚴密證明 ➔ AI 解讀反例日誌」的閉環體系,我們才能真正將 AI 的敏捷性與形式驗證的數學嚴謹性結合,打造出堅不可摧的生產級無鎖系統。

結語:何時該用 Lock-Free?

無鎖資料結構擁有極致的吞吐潛力與避免優先權反轉(Priority Inversion)的優雅特質,但它的實作代價無比高昂。每一行看似平凡的指標操作背後,都牽動著 CPU 管線排空、快取一致性廣播、ABA 防護與記憶體延後回收的複雜協同。

在工程實踐中,我們應當抱持審慎客觀的態度:

  • 95% 的業務場景:優先使用現代作業系統高度最佳化的標準鎖(如基於 Futex 的 std::mutex 或 Rust 的 parking_lot::Mutex)。現代互斥鎖在無競爭情況下僅是一次輕量的原子 CAS,開銷僅數十奈秒,且心智負擔極低。
  • 高頻交易、核心驅動與底層基礎設施:在極度要求低延遲、不可容忍鎖定阻塞(如即時音訊處理、高並發網路事件循環 Reactor)的關鍵路徑上,投入精力設計並驗證 Lock-Free 結構。
  • 站在巨人的肩膀上:若需要使用無鎖結構,盡量選用經過工業級形式化驗證(如 TLA+)與龐大測試套件(如 ThreadSanitizer、Loom)錘鍊的成熟庫(如 Rust 的 crossbeam,C++ 的 Folly 或 Intel TBB),切忌在未經深思熟慮前自行在生產環境手寫無鎖記憶體回收器。

透過這兩篇文章的梳理,我們從底層硬體快取與記憶體模型的微觀世界,一路跨越至無鎖拓撲與安全記憶體回收的宏觀架構。並行程式設計雖然充滿挑戰,但只要掌握了因果順序與生命週期的雙重視角,看似詭譎多變的多執行緒世界,終將呈現出清晰嚴謹的工程之美。


C++ 與 Rust 記憶體模型:從 Memory Ordering、硬體快取到 Safe Publication

深入理解 Relaxed、Acquire/Release、SeqCst 在編譯器與硬體層級的運作機制與同步保證
featured.svg

在單一執行緒(thread)裡,先執行 X = 10,接著執行 Y = 20,直覺上我們會認定「第一件事做完,才做第二件事」。但當另一個執行緒讀到 Y == 20 時,我們能不能百分之百相信 X 此時也已經是 10?

答案是否定的。

要回答這個問題,必須深入認識 記憶體模型(Memory Model):它是一份語言規格與硬體架構之間的法律契約,嚴格規範了多個執行緒存取共享記憶體時,哪些讀取結果是合法的,以及各執行緒之間如何建立可信的同步關係。

無論是 C++(自 C++11 起確立,並在 C++20 持續演進的 <atomic>)還是 Rust(標準庫中的 std::sync::atomiccore::sync::atomic::Ordering),兩者背後採用的是同一套源自 C++11 的記憶體模型哲學。如果沒有搞懂這套規則,直接跳去寫 CAS(Compare-And-Swap)或無鎖資料結構,往往會把「原子數值更新成功」與「周邊資料已被安全觀察」混為一談,埋下極難除錯的並行競爭缺陷。

本文將從計算機硬體管線與語言標準出發,全方位拆解 C++ 與 Rust 的記憶體順序(Memory Ordering)機制。

記憶體模型本質:寫入順序不等於觀察順序

在現代多核心系統中,高階語言的程式碼順序(Program Order)絕不等於實際硬體上各核心觀察到的時間順序。

編譯器為了極大化運算效率,會進行指令重排(Instruction Reordering)、暫存器提升(Register Promotion)或死碼消除;CPU 為了避免等待緩慢的記憶體匯流排,會透過亂序執行(Out-of-Order Execution)、暫存寫入緩衝區(Store Buffer)與推測性讀取(Speculative Load)來填滿運算管線。這一切在單一執行緒中都受到 as-if 原則 保護——只要單執行緒的最終執行結果與原始順序一致,任何重排都是合法的。

但當另一個執行緒從另一顆核心觀察這塊記憶體時,as-if 的保護罩就瞬間失效了。

誰動了我的程式碼?從高階軟體到晶片硬體的重排層級

當我們在原始碼中寫下兩行看似簡單的賦值操作時,這兩行指令在真正轉化為電晶體狀態改變的漫長路途中,必須穿透多個軟硬體抽象層。

為了榨乾現代處理器的每一分運算潛力,從編譯器最佳化到矽晶片電路,各層級都被賦予了重排與快取的自由:

flowchart TD subgraph Layer1["高階軟體與編譯器層 (Compiler Layer)"] direction LR L1_Code["<div style='width:280px; text-align:left; line-height:1.4;'><b>高階語言原始碼 (Source Code)</b><br/>• 循序語意:C++ / Rust Program Order<br/>• 單執行緒 as-if 原則保障結果正確<br/>• 尚未考慮多核心記憶體可見性問題</div>"] L1_Opt["<div style='width:280px; text-align:left; line-height:1.4;'><b>編譯器最佳化 (Optimization)</b><br/>• 指令調度 (Scheduling):隱藏管線延遲<br/>• 暫存器提升 (Promotion):延遲寫回記憶體<br/>• 冗餘消除 (DSE / CSE):刪除中間存取</div>"] L1_Code -->|"編譯與轉換"| L1_Opt end subgraph Layer2["處理器執行核心 (CPU Core & Execution Pipeline)"] direction LR L2_Core["<div style='width:280px; text-align:left; line-height:1.4;'><b>超純量管線與執行引擎 (Pipeline)</b><br/>• 指令解碼為微操作 (μops) 平行派發<br/>• 分支預測與推測讀取 (Speculative Load)<br/>• 保留站 (Reservation Station) 動態調度</div>"] L2_ROB["<div style='width:280px; text-align:left; line-height:1.4;'><b>亂序執行與引退機制 (ROB)</b><br/>• 運算元資料就緒即脫離循序執行<br/>• 重排序緩衝區 (ROB):維護暫存器假象<br/>• 指令循序引退 (In-order Retirement)</div>"] L2_Core -->|"微操作引退"| L2_ROB end subgraph Layer3["核心記憶體介面 (Memory Subsystem Interface)"] direction LR L3_SB["<div style='width:280px; text-align:left; line-height:1.4;'><b>寫入緩衝區 (Store Buffer)</b><br/>• 吸收寫入延遲,非同步背景排空 (Drain)<br/>• 支援寫入合併 (Store Merging)<br/>• <b>★ 物理根源:誘發 Store-Load 亂序</b></div>"] L3_IQ["<div style='width:280px; text-align:left; line-height:1.4;'><b>無效化佇列 (Invalidate Queue)</b><br/>• 暫存跨核心失效請求,延遲確認 (ACK)<br/>• 避免 CPU 等待快取行使無效廣播<br/>• 弱架構下引發過期快取資料讀取</div>"] L3_SB <--->|"記憶體介面協調"| L3_IQ end subgraph Layer4["快取階層與多核心互連 (Cache Hierarchy & Interconnect)"] direction LR L4_Cache["<div style='width:280px; text-align:left; line-height:1.4;'><b>多層級快取階層 (Cache Hierarchy)</b><br/>• 本地 L1 / L2 快取與共享 L3 快取<br/>• 快取未命中 (Miss) 產生巨大延遲落差<br/>• 造成各變數寫入發布時程產生偏斜</div>"] L4_Bus["<div style='width:280px; text-align:left; line-height:1.4;'><b>快取一致性互連網路 (Interconnect)</b><br/>• MESI / MOESI 協定維持快取行狀態<br/>• <b>局限:僅保證單一位址,不保證跨位址順序</b><br/>• 晶片互連網路 (Ring/Mesh) 傳播延遲差</div>"] L4_Cache <--->|"快取行狀態維護"| L4_Bus end Layer1 -->|"發射機器碼"| Layer2 Layer2 -->|"寫入指令提交"| Layer3 Layer3 -->|"非同步排空寫入 / 快取失效協調"| Layer4 style Layer1 fill:#1a1e2e,stroke:#3b4261,stroke-width:1.5px,color:#7aa2f7; style Layer2 fill:#1a1e2e,stroke:#bb9af7,stroke-width:1.5px,color:#bb9af7; style Layer3 fill:#1a1e2e,stroke:#ff9e64,stroke-width:1.5px,color:#ff9e64; style Layer4 fill:#1a1e2e,stroke:#2ac3de,stroke-width:1.5px,color:#2ac3de; classDef sw fill:#24283b,stroke:#7aa2f7,stroke-width:1.5px,color:#c0caf5; classDef core fill:#24283b,stroke:#bb9af7,stroke-width:1.5px,color:#c0caf5; classDef mem fill:#24283b,stroke:#ff9e64,stroke-width:1.5px,color:#ff9e64; classDef hw fill:#24283b,stroke:#7dcfff,stroke-width:1.5px,color:#7dcfff; class L1_Code,L1_Opt sw; class L2_Core,L2_ROB core; class L3_SB,L3_IQ mem; class L4_Cache,L4_Bus hw;

以下由上至下(從軟體層深入至硬體晶片)逐一解析各層級如何「篡改」我們的指令順序:

高階語言與編譯器最佳化層(Compiler Optimization)

編譯器是第一道重排關卡。在不違反單執行緒 as-if 原則的前提下,現代靜態編譯器(如 Clang、GCC、Rustc)會進行極為激進的重整:

  • 指令調度(Instruction Scheduling):為了隱藏記憶體存取延遲或填補管線空泡(Bubble),編譯器會將沒有資料相依性(Data Dependency)的無關指令互相調換位置。
  • 暫存器提升(Register Promotion):將頻繁讀寫的變數直接鎖定在 CPU 暫存器中,跳過記憶體讀寫。這意味著一個核心對變數的修改可能長久停留在暫存器內,根本沒有寫出到記憶體中,使得其他核心毫無察覺。
  • 冗餘存取消除(Dead Store / Common Subexpression Elimination):若編譯器判定某個變數在短時間內被重複寫入,它甚至可能直接抹除中間的寫入動作(例如將連續的 flag = 1; flag = 2; 直接最佳化為 flag = 2;)。

處理器管線與亂序執行(Out-of-Order Execution)

穿過編譯器產生的機器碼後,指令進入 CPU 核心管線。在現代超純量處理器中,硬體並不會死板地一行行依序執行指令:

  • 推測執行與分支預測(Speculative Execution):當處理器遇到條件分支時,不會乾等條件計算完成,而是依據歷史紀錄猜測分支走向,並提前載入後續指令所需的資料(Speculative Load)。若預測成功,程式效率翻倍;但在多核心視角下,這意味著「後面的讀取操作」在實體時間軸上提早於「前面的條件判斷」發生。
  • 動態調度(Dynamic Scheduling / Reservation Stations):CPU 前端將複雜指令拆解為微操作($\mu\text{ops}$)後分派給多個平行的執行單元(ALU、Load、Store)。只要某個運算單元的資料就緒,就能立刻插隊執行,不受原始指令先後束縛。
  • 重排序緩衝區(Reorder Buffer, ROB):為了維持單核心的確定性,CPU 透過 ROB 機制讓所有指令在暫存器更新與例外處理時維持循序引退(In-order Retirement)。但請注意:ROB 僅負責處理器內部的暫存器假象,記憶體的真實寫入在引退後才交由記憶體子系統接手!

寫入緩衝區與記憶體介面(Store Buffer & Memory Interface)

當寫入指令從 CPU 核心引退後,它並不會直接寫入快取,而是被丟入核心內部的 Store Buffer(寫入緩衝區)

  • 為什麼需要 Store Buffer?:CPU 核心運算頻率極高(3~5 GHz),而快取存取需要數個週期。如果每執行一次寫入都要等待快取確認所有權,處理器管線將發生嚴重停頓。因此 CPU 將寫入丟入 Store Buffer 後即視為完成,由 Store Buffer 在背景非同步將資料排空(Drain)至 L1 快取。
  • Store-Load 亂序的物理溫床:當核心執行「寫入 A、讀取 B」時,寫入 A 尚滯留在 Store Buffer 中,而讀取 B 卻已經直接從快取中取出了舊值。從外部觀察者看來,這就像讀取 B 跑到了寫入 A 之前!這正是 x86-64 唯一允許的硬體亂序現象。

快取階層與多核心互連網路(Cache Hierarchy & Interconnect Fabric)

最後,資料離開 Store Buffer 進入快取階層:

  • 快取命中延遲不均(Cache Latency Disparity):若變數 X 發生快取未命中(Cache Miss,需向外部 L3 或主記憶體索取),而變數 Y 剛好命中 L1 快取,弱記憶體模型架構(如 ARM64)的硬體可能優先將 Y 寫入並向外發布,使得原本先寫入的 X 反而在時間上落後。
  • 快取一致性協定的局限(MESI Coherence Limit):MESI / MOESI 協定只負責維護「單一快取行(單一記憶體位址)」在各核心快取狀態的唯一性。它完全不保證多個不同記憶體位址之間的傳播因果。不同位址的快取無效化訊息在晶片互連網路(Ring Bus 或 Mesh)中傳播時,可能因為路由擁塞而產生時間差。

軟體記憶體模型(Memory Model)的契約角色

正因為從高階軟體到硬體電路存在如此多層級的重排自由,如果沒有一套統一規範,跨平台並行程式設計將寸步難行。

這就是 語言層級記憶體模型(Software Memory Model) 誕生的根本目的: 它在混亂的底層現實與純粹的高階語意之間築起一道契約牆。開發者透過標註 RelaxedAcquireReleaseSeqCst,精確指示編譯器在適當的位置插入 編譯器屏障(Compiler Barrier) 抑制指令重排,並發射對應架構的 硬體記憶體屏障指令(Memory Fence) 強迫管線與 Store Buffer 排空,以最小的效能代價換取跨核心因果的一致性。

原子性(Atomicity)與記憶體順序(Memory Ordering)

在深入討論前,必須嚴格釐清這兩個經常被混淆的概念:

  • Atomicity(原子性):指的是對單一變數的存取操作不可分割。在讀取或寫入時,不會讀到其他執行緒只寫了一半的破碎資料(Tearing)。例如 64-bit 整數寫入在 32-bit 架構上若未做原子防護,可能會被拆成兩個 32-bit 指令,導致讀者拿到上半部舊值與下半部新值的混亂狀態。
  • Memory Ordering(記憶體順序):指的是不同記憶體存取操作之間的可見性順序與同步保證。當你看到某個旗標被設定為 true 時,能否安全相信在該旗標設定之前所準備的所有資料(包括非原子的一般變數)都已經在你的核心中完整可見?

使用原子型別(C++ 的 std::atomic<T> 或 Rust 的 AtomicI32AtomicBool 等)保證了操作的不可分割性;而你在每次操作時所指定的 memory_order,則決定了它要對周邊其他記憶體存取施加多強的順序約束。

四大核心因果關係

C++ 與 Rust 的記憶體模型使用數學上的偏序關係(Partial Order)來定義並行操作的合法性。其中最關鍵的四個概念如下:

flowchart TD subgraph ThreadA["執行緒 A"] A1["一般變數寫入 data = 42"] A2["Release Store ready = true"] A1 -- "sequenced-before" --> A2 end subgraph ThreadB["執行緒 B"] B1["Acquire Load ready == true"] B2["一般變數讀取 assert(data == 42)"] B1 -- "sequenced-before" --> B2 end A2 -- "synchronizes-with<br/>(reads-from 成功配對)" --> B1 A1 -. "happens-before (推導成立)" .-> B2 classDef proc fill:#1f2335,stroke:#414868,stroke-width:1.5px,color:#c0caf5; classDef sync fill:#24283b,stroke:#bb9af7,stroke-width:2px,color:#bb9af7; classDef safe fill:#24283b,stroke:#9ece6a,stroke-width:2px,color:#9ece6a; class A1,B2 proc; class A2 sync; class B1 safe;
  1. sequenced-before(循序先於):同一執行緒內部的程式執行順序。在 Thread A 裡,程式碼上一行 sequenced-before 下一行。單靠此關係無法跨越執行緒邊界。
  2. reads-from(讀取自):某個執行緒的 load 操作讀取到了另一個執行緒的 store 操作所寫入的值。
  3. synchronizes-with(同步於):跨執行緒的關鍵橋樑。當一個帶有 Release 語意的 store 被另一個帶有 Acquire 語意的 load 成功讀到(reads-from 成立)時,這兩個操作之間就建立了 synchronizes-with 關係。
  4. happens-before(先發生於):由 sequenced-before 與 synchronizes-with 組合傳遞而成的全域偏序關係。如果操作 A happens-before 操作 B,則 A 對記憶體所做的所有修改,對於 B 都是保證可見的。

最核心的原則是:單純「我讀到了你寫入的數值(reads-from)」,並不代表「你在寫入該數值前所做的所有事情,都已經同步給我(happens-before)」。 必須使用正確的 memory ordering 才能建立起跨執行緒的同步橋樑。

思考實驗:Relaxed 為什麼可能讀到 0, 20?

為了驗證我們的直覺盲點,來看這個經典的訊息傳遞(Message Passing)問題。

假設全域有兩個整數變數 XY,初始值皆為 0。兩個執行緒同時執行,且全部採用限制最弱的 relaxed 順序:

Thread A             Thread B
X = 10 (relaxed)     y = Y  (relaxed)
Y = 20 (relaxed)     x = X  (relaxed)

執行緒 B 先讀取 Y 存入暫存變數 y,接著讀取 X 存入暫存變數 x

在 C++ 與 Rust 的記憶體模型規範下,程式結束時 (x, y) 可能的數值有哪些?

  • A. (0, 0):Thread B 在 Thread A 尚未寫入任何值前就讀完了。
  • B. (10, 0):Thread B 讀取 Y 時 Thread A 尚未寫入 Y;讀取 X 時 Thread A 已經寫入 X
  • C. (10, 20):Thread B 成功讀取到 Thread A 的所有最新寫入。
  • D. (0, 20):Thread B 明明已經看到了後寫入的 Y = 20,卻讀到了先寫入的 X 初始值 0!

答案是:A、B、C、D 全部都是標準規範所允許的合法結果!

最令人難以接受的是選項 D:既然 Thread B 已經看到了後寫入的 Y = 20,為什麼竟然還能讀到舊的 X = 0

C++ 與 Rust 程式碼驗證

我們來用現代 C++20 與 Rust 分別寫出這段邏輯:

C++20 實作

#include <atomic>
#include <iostream>
#include <thread>

int main() {
    std::atomic<int> X{0};
    std::atomic<int> Y{0};
    int x = -1;
    int y = -1;

    std::thread t1([&] {
        X.store(10, std::memory_order_relaxed);
        Y.store(20, std::memory_order_relaxed);
    });

    std::thread t2([&] {
        y = Y.load(std::memory_order_relaxed);
        x = X.load(std::memory_order_relaxed);
    });

    t1.join();
    t2.join();

    std::cout << "Result: x = " << x << ", y = " << y << '\n';
}

Rust 實作

use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use std::thread;

struct SharedData {
    x: AtomicI32,
    y: AtomicI32,
}

fn main() {
    let data = Arc::new(SharedData {
        x: AtomicI32::new(0),
        y: AtomicI32::new(0),
    });

    let d1 = Arc::clone(&data);
    let t1 = thread::spawn(move || {
        d1.x.store(10, Ordering::Relaxed);
        d1.y.store(20, Ordering::Relaxed);
    });

    let d2 = Arc::clone(&data);
    let t2 = thread::spawn(move || {
        let y = d2.y.load(Ordering::Relaxed);
        let x = d2.x.load(Ordering::Relaxed);
        (x, y)
    });

    t1.join().unwrap();
    let (x, y) = t2.join().unwrap();

    println!("Result: x = {}, y = {}", x, y);
}

這兩段程式在語言規格中是完全等價的。Relaxed 順序只給予兩個保證:

  1. 單一操作的原子性:不會讀到撕裂的半個整數。
  2. 單一變數的修改順序(Modification Order)一致:對於同一個變數 X,所有執行緒看到的修改順序是一致的(不可能看到 X 先變成 10 又無緣無故變回 0)。

但是,Relaxed 完全不提供跨變數之間的順序保證,也不建立跨執行緒的同步關係。

當 Thread B 讀取到 Y == 20 時,這僅僅是一次孤立的 reads-from,並沒有 synchronizes-with 伴隨產生。因此,Thread A 對 X 的寫入與 Thread B 對 X 的讀取之間不存在 happens-before 關係。Thread B 讀到 0 是完全合法的。

如果你在常見的 x86-64 桌上型電腦上重複執行這段程式一百萬次,可能永遠都不會看到 (0, 20) 出現。這是因為 x86-64 硬體架構具備極強的記憶體順序保證(TSO,Total Store Order),硬體本身不允許 store-store 或 load-load 亂序。但在 ARM64(AArch64,如 Apple Silicon、多數智慧型手機與現代雲端伺服器)等弱記憶體架構上,或是在激進的最佳化編譯器面前,(0, 20) 是隨時可能發生的真實情況。

在未定義同步保證的程式碼上測試一百萬次不報錯,絕不等於程式是正確的。

硬體真實樣貌:編譯器、Store Buffer 與快取一致性

語言層級的 Memory Model 定義了「什麼是合法的」,而編譯器與 CPU 硬體則負責「如何兌現(或打破)這些約定」。

了解底層硬體運作,能幫助我們建立直觀的物理心智模型:

flowchart TD subgraph CPUCore0["CPU 核心 0 (Writer)"] ALU0["執行管線 (ALU)"] SB0["Store Buffer (暫存未寫入快取之資料)"] ALU0 -- "寫入" --> SB0 end subgraph CPUCore1["CPU 核心 1 (Reader)"] ALU1["執行管線 (ALU)"] LQ1["Load Queue / Invalidate Queue"] LQ1 -- "推測讀取" --> ALU1 end subgraph MemorySubsystem["快取子系統 (Cache Subsystem)"] L1_0["L1 / L2 快取 (Core 0)"] L1_1["L1 / L2 快取 (Core 1)"] Interconnect["快取一致性匯流排 (MESI/MOESI Protocol)"] L1_0 <--> Interconnect L1_1 <--> Interconnect end SB0 -- "非同步排空 (Drain)" --> L1_0 Interconnect -- "使無效 (Invalidate)" --> LQ1 classDef core fill:#1f2335,stroke:#414868,stroke-width:1px,color:#c0caf5; classDef buffer fill:#24283b,stroke:#ff9e64,stroke-width:1.5px,color:#ff9e64; classDef bus fill:#16161e,stroke:#7dcfff,stroke-width:1.5px,color:#7dcfff; class ALU0,ALU1 core; class SB0,LQ1 buffer; class L1_0,L1_1,Interconnect bus;

Store Buffer(寫入緩衝區)

CPU 核心的時脈在數 GHz,而存取快取(Cache)需要數個週期,存取主記憶體(RAM)甚至需要上百個週期。如果 CPU 每做一次寫入都要等快取行(Cache Line)取得所有權並確認寫入完成,執行管線將頻繁停頓(Stall)。

因此,現代 CPU 核心在管線與快取之間加入了一個硬體佇列——Store Buffer。 當核心執行 store 指令時,它將位址與數值直接丟進 Store Buffer,隨即宣告指令完成,管線繼續執行下一條指令。Store Buffer 會在背景非同步地將資料排空(Drain)至 L1 快取。

這就產生了時間差:本核心已經在邏輯上完成了寫入,但該數值根本還沒進入快取系統,其他核心完全看不見。

在前面的例子中(此場景特別容易發生在弱記憶體架構如 ARM64 上):

  1. 核心 0 執行 X = 10,丟入 Store Buffer。
  2. 核心 0 接著執行 Y = 20,也丟入 Store Buffer。
  3. 如果 Y 所在的快取行原本就處於核心 0 的 Modified 狀態,而 X 所在的快取行發生了快取未命中(Cache Miss),弱架構硬體可能優先將 Y = 20 排入快取並廣播給其他核心,而 X = 10 仍被滯留在 Store Buffer 裡!
  4. 結果核心 1 的快取先收到了 Y = 20,卻在讀取 X 時拿到舊值 0。

快取一致性協定(Cache Coherence)不能拯救跨變數順序

常有人誤以為:「我們有 MESI / MOESI 快取一致性協定,快取不是隨時保持一致嗎?為什麼還會亂序?」

這個理解偏差在於:快取一致性協定僅僅保證「單一記憶體位址(單一快取行)」在所有核心之間的狀態轉換是一致的。 它能保證對於同一個變數 X,所有核心最終會看到相同的修改歷史(即 Modification Order),但它完全不負責協調多個不同記憶體位址(XY)之間的寫入先後順序!

跨變數的先後順序,必須依賴記憶體屏障(Memory Barrier)或專屬的原子指令來強制約束硬體管線與 Store Buffer。

強記憶體模型 vs 弱記憶體模型

硬體架構在記憶體順序的嚴格程度上分為兩大陣營:

硬體架構 記憶體模型類別 硬體可能發生的重排 特性與編譯產物
x86-64 / AMD64 強模型(TSO, Total Store Order) 唯一允許的是 Store-Load 亂序(本核心寫入尚未離開 Store Buffer,隨後的讀取先行完成) 不會發生 Store-Store 或 Load-Load 亂序。普通的 Acquire Load 與 Release Store 編譯後都是一般 MOV 指令,零額外硬體代價!
AArch64 (ARM64) / RISC-V 弱模型(Weakly-Ordered) 所有組合皆可亂序:Store-Store、Load-Load、Load-Store、Store-Load 擁有極致的管線吞吐量。需要明確的指令如 STLR(Store-Release)、LDAR(Load-Acquire)或 DMB(Data Memory Barrier)來確保順序。

組合語言對照:Release Store 與 Acquire Load

看編譯器產生的真實組合語言能讓我們對代價一目了然:

x86-64 組合語言
# std::memory_order_release / Ordering::Release
mov DWORD PTR [rdi], 20       # 普通的 MOV 就天生具備 Release 效果(硬體禁止 Store-Store 重排)

# std::memory_order_acquire / Ordering::Acquire
mov eax, DWORD PTR [rsi]       # 普通的 MOV 就天生具備 Acquire 效果(硬體禁止 Load-Load 重排)

# std::memory_order_seq_cst (Store)
# 方式 1 (GCC / Clang 常見):xchg 存取記憶體時硬體自帶隱含 LOCK 前綴,具全域屏障效果
xchg DWORD PTR [rdi], eax

# 方式 2 (MSVC 常見):一般 mov 寫入,隨後接 mfence 強迫清空 Store Buffer
mov DWORD PTR [rdi], eax
mfence                        # 或使用 lock or DWORD PTR [rsp], 0 作為屏障
AArch64 (ARM64) 組合語言
# std::memory_order_release / Ordering::Release
stlr w0, [x1]                 # STLR (Store-Release Register):排空 Store Buffer 中先前的寫入

# std::memory_order_acquire / Ordering::Acquire
ldar w0, [x1]                 # LDAR (Load-Acquire Register):阻止後續讀取被推測執行重排到此指令前
 Note

為什麼 xchg 沒有顯式寫出 lock 前綴?
依據 Intel x86-64 架構規範(Intel SDM),當 XCHG 指令的運算元涉及記憶體時,CPU 硬體會自動視為自帶隱含的 LOCK 前綴(Implicit LOCK),因此組譯時不需寫出 lock xchg。它具有全屏障(Full Barrier)效果,會強迫清空本核心的 Store Buffer,防止 x86 唯一允許發生的硬體重排(Store-Load 亂序),達成全域順序一致(SeqCst)的保證。

重要觀念:Release 不是把快取直接寫回主記憶體(RAM),Acquire 也不是清空快取再去讀 RAM! 所有的讀寫依然發生在高速快取階層中。Release/Acquire 指令的作用是約束核心內部編譯器重排、Store Buffer 排空時程與推測讀取管線,確保各核心快取在交換 MESI 訊息時能夠維持因果先後。

Acquire 與 Release:訊息傳遞與 Safe Publication

既然 Relaxed 無法傳遞順序保證,那麼正確的跨執行緒資料發布方式是什麼?

這就是並行程式設計中最核心、最經典的模式:Acquire-Release 同步(Safe Publication)

語意規範

  • Release Store(發布寫入): 在當前執行緒中,所有排在該 Release Store 之前的記憶體存取(不論是一般變數還是原子變數),都絕對不能被編譯器或 CPU 重排到該 Release Store 之後。 這就像在寫入點築起一道向下的單向屏障,確保「所有先前的資料準備工作,在旗標發布的那一刻都已就緒」。
  • Acquire Load(獲取讀取): 在當前執行緒中,所有排在該 Acquire Load 之後的記憶體存取,都絕對不能被編譯器或 CPU 重排到該 Acquire Load 之前。 這是一道向上的單向屏障,確保「在成功看到發布旗標之前,絕不提前讀取後續依賴的資料內容」。

建立同步橋樑

當執行緒 B 的 Acquire Load 成功讀取到執行緒 A 的 Release Store 所寫入的值時(reads-from),兩者之間就正式建立了 synchronizes-with 關係!

透過因果鏈條的串聯:

$$\text{A 的資料準備} \xrightarrow{\text{sequenced-before}} \text{A 的 Release Store} \xrightarrow{\text{synchronizes-with}} \text{B 的 Acquire Load} \xrightarrow{\text{sequenced-before}} \text{B 讀取資料}$$

happens-before 關係正式成立! B 保證能讀到 A 在 Release Store 之前所做的所有修改。

雙語言實作:安全發布指標(Safe Publication)

在實務上,我們經常需要初始化一個龐大的結構,然後將指標發布給多個工作執行緒。如果沒有正確的 memory ordering,其他執行緒可能會讀到尚未初始化完成的垃圾資料。

C++20 實作

#include <atomic>
#include <cassert>
#include <iostream>
#include <thread>

struct Payload {
    int id;
    int data[1024];
};

std::atomic<Payload*> g_payload{nullptr};

void producer() {
    auto* p = new Payload();
    p->id = 42;
    for (int i = 0; i < 1024; ++i) {
        p->data[i] = i * 2;
    }

    // Release store: 確保 payload 內容初始化完成後,指標才對外可見
    g_payload.store(p, std::memory_order_release);
}

void consumer() {
    Payload* p = nullptr;
    // 輪詢等待指標發布
    while ((p = g_payload.load(std::memory_order_acquire)) == nullptr) {
        std::this_thread::yield();
    }

    // Acquire load 成功與 producer 的 release store 同步
    // 此處保證讀到的 payload 欄位絕對是初始化後的值!
    assert(p->id == 42);
    assert(p->data[100] == 200);
    std::cout << "Consumer successfully read payload: id = " << p->id << '\n';
}

int main() {
    std::thread t2(consumer);
    std::thread t1(producer);
    t1.join();
    t2.join();
    delete g_payload.load(std::memory_order_relaxed);
}

Rust 實作

在 Rust 中,我們可以使用標準庫的 AtomicPtr 或高階封裝來表達相同的 Safe Publication 語意:

use std::sync::atomic::{AtomicPtr, Ordering};
use std::sync::Arc;
use std::thread;

struct Payload {
    id: i32,
    data: Vec<i32>,
}

struct Channel {
    ptr: AtomicPtr<Payload>,
}

fn main() {
    let channel = Arc::new(Channel {
        ptr: AtomicPtr::new(std::ptr::null_mut()),
    });

    let producer_ch = Arc::clone(&channel);
    let t1 = thread::spawn(move || {
        let payload = Box::new(Payload {
            id: 42,
            data: (0..1024).map(|i| i * 2).collect(),
        });

        // 將 Box 轉換為裸指標,放棄自動釋放所有權
        let raw = Box::into_raw(payload);

        // Ordering::Release: 確保前面的欄位寫入全部就緒,才發布指標
        producer_ch.ptr.store(raw, Ordering::Release);
    });

    let consumer_ch = Arc::clone(&channel);
    let t2 = thread::spawn(move || {
        let mut raw;
        // 輪詢等待發布
        loop {
            // Ordering::Acquire: 與 Release store 建立同步
            raw = consumer_ch.ptr.load(Ordering::Acquire);
            if !raw.is_null() {
                break;
            }
            thread::yield_now();
        }

        // 安全解引用:Acquire 保證 payload 記憶體內容完整可見
        unsafe {
            let payload = &*raw;
            assert_eq!(payload.id, 42);
            assert_eq!(payload.data[100], 200);
            println!("Consumer successfully read payload: id = {}", payload.id);

            // 回收記憶體,轉回 Box 自動 drop
            let _ = Box::from_raw(raw);
        }
    });

    t1.join().unwrap();
    t2.join().unwrap();
}

注意這個重要細節:Payload 的內部欄位完全不需要是原子型別! 一般變數的寫入之所以能在沒有 data race 的情況下被安全讀取,正是因為 Release 與 Acquire 在兩端築起了因果同步的堤防。

Release Sequence(發布序列)

C++ 與 Rust 規格中還有一個極具威力的規則:Release Sequence。 如果執行緒 A 執行了一個 Release Store,隨後其他執行緒對同一個原子變數執行了一連串的讀改寫操作(Read-Modify-Write, RMW,如 fetch_add 或 CAS),即使這些中間的 RMW 操作使用的是 relaxed 順序,只要最後某個執行緒 C 使用 Acquire Load 讀到了這個序列中的任何一個值,執行緒 C 依然保證能與執行緒 A 的原始 Release Store 建立同步!

這項特性是後續構建無鎖佇列與無鎖堆疊(如 Treiber Stack)的重要基石。

AcqRel 與 SeqCst:全域總序的代價與適用時機

除了單向的 Acquire 與 Release,標準庫還提供了 AcqRel 與最強大的 SeqCst

AcqRel(Acquire-Release 組合語意)

std::memory_order_acq_rel(Rust 中為 Ordering::AcqRel)專門用於 讀改寫操作(RMW),如 fetch_addfetch_subcompare_exchange

  • 讀取部分 具備 Acquire 語意:接收先前其他執行緒發布的資料。
  • 寫入部分 具備 Release 語意:將本次更新連同先前的資料變更發布給後續的執行緒。

最典型的範例是參考計數(Reference Counting),例如 C++ 的 std::shared_ptr 或 Rust 的 Arc

  1. 計數增加(Clone / Retain):使用 Relaxed 即可,因為增加引用只是為了防止物件被提前釋放,不需要同步資料。
  2. 計數減少(Drop / Release):使用 AcqRelRelease 扣減。
  3. 最後一個持有者析構(Free):在計數扣減至 0 時,必須執行一次 Acquire(或在釋放路徑上設置 Acquire fence),以確保所有先前持有該物件的執行緒所做的寫入操作,在此刻的析構函式執行前都已完全同步可見!

SeqCst(順序一致性,Sequentially Consistent)

std::memory_order_seq_cst(Rust 中為 Ordering::SeqCst)是兩大語言的預設原子順序。如果你呼叫 atomic.store(val)atomic.load() 而不傳入 ordering 參數,編譯器預設就會套用 seq_cst

SeqCst 提供了什麼保證?

SeqCst 除了包含 Acquire 與 Release 的全部單向屏障效果之外,還附加了一個最強硬的保證: 系統中所有標記為 SeqCst 的原子操作,都遵循一個單一的、全域一致的執行總序(Single Total Order)。所有核心看到的這一組操作順序是完全相同的!

為什麼 Acquire-Release 不夠用?經典的 Dekker 演算法矛盾

很多人會問:既然 Acquire 與 Release 已經能完成訊息傳遞,為什麼還需要 SeqCst

來看這個互斥鎖演算法的核心原型(Dekker’s Algorithm 的旗標判定):

初始值:flag1 = false, flag2 = false

Thread 1                  Thread 2
flag1.store(true, rel);   flag2.store(true, rel);
if (!flag2.load(acq)) {   if (!flag1.load(acq)) {
    // 進入臨界區             // 進入臨界區
}                         }

如果兩個執行緒全部使用 Acquire / Release:

  • Thread 1 的 Release 阻止前面的存取往下移,Acquire 阻止後面的存取往上移。
  • 但 Release 與 Acquire 並不阻止這兩行程式碼彼此交錯!
  • 在底層硬體上,Thread 1 的 store 還滯留在 Store Buffer 裡時,隨後的 load 已經先去讀取 flag2(此時仍為 false)。
  • 同一時間,Thread 2 也在 Store Buffer 滯留 flag2,並讀取到 flag1 == false
  • 結果:Thread 1 與 Thread 2 同時認為對方尚未請求進入臨界區,雙雙進入臨界區,互斥鎖徹底崩潰!

這就是 Store-Load 亂序。要解決這個問題,必須要求兩個執行緒對「寫入自己的旗標」與「讀取對方的旗標」建立一個所有人公認的全域先後順序。只有 SeqCst 能強制發出全域記憶體屏障(x86 的 MFENCEXCHG,ARM 的重型記憶體屏障),確保 Store Buffer 立即排空,保證絕不可能兩邊同時讀到對方的初始值。

SeqCst 的代價與迷思

  1. 效能代價高昂:在 x86-64 上,普通 Acquire/Release 是零額外成本的 MOV,而 SeqCst 寫入必須加上帶有匯流排鎖定鎖延遲的指令;在 ARM64 上,SeqCst 需要更嚴苛的管線排空。
  2. SeqCst 不能合成複合交易SeqCst 只能保證原子變數本身的操作順序,絕對不會把兩行獨立的原子操作自動打包成一個不可分割的交易(Transaction)
  3. SeqCst 救不了記憶體生命週期:即使你把所有指標讀取與 CAS 全部改成 SeqCst,如果另一個執行緒在你讀出指標與解引用(Dereference)的短暫空隙中把該記憶體 free 掉了,依然會引發嚴重的 Use-After-Free 記憶體損毀。

Rust 型別系統與 Memory Model 的深度融合

Rust 在語言層級上實踐記憶體模型時,最令人驚豔的特點在於其所有權(Ownership)與型別系統如何與記憶體模型渾然天成地結合

SendSync 的編譯期防線

在 C++ 中,如果你不小心把一個普通的 int 跨執行緒讀寫,編譯器不會給予任何警告,程式直接陷入未定義行為(Undefined Behavior, UB)的大坑。

而在 Rust 中:

  • T: Send 代表型別 T 的所有權可以安全轉移到另一個執行緒。
  • T: Sync 代表型別 T 的不可變引用 &T 可以安全地被多個執行緒同時存取。
  • 標準庫規定:當且僅當 T: Sync 時,&T 才是 Send

普通的純量型別(如 i32)雖然也是 Sync(不可變引用 &i32 可在多執行緒間安全共享讀取),但它無法在共享引用的情況下直接修改內容(Rust 禁止透過 &i32 進行 Shared Mutation)。

原子型別(如 AtomicBoolAtomicPtr<T>)則透過內部可變性(Interior Mutability,底層奠基於 UnsafeCell<T>),對編譯器做出執行緒安全保證,因此它們被標準庫實作了 Sync。這意味著: 你可以在多個執行緒同時持有 &AtomicT 的情況下並行修改它,而 Rust 在編譯期就從型別層面全面杜絕了非原子變數引發的普通 Data Race!

獨立記憶體屏障:fencecompiler_fence

除了將 ordering 附加在各個原子存取操作上,Rust 與 C++ 都支援獨立的記憶體屏障函式:

use std::sync::atomic::{fence, compiler_fence, Ordering};

// 硬體級別的記憶體屏障:約束編譯器與 CPU 管線
fence(Ordering::Release);
fence(Ordering::Acquire);

// 純編譯器級別的屏障:阻止編譯器指令重排,不產生任何硬體 CPU 屏障指令
compiler_fence(Ordering::Release);

何時該用 fence

當一個執行緒需要大量寫入普通資料,但只有在特定條件下才需要與其他執行緒同步時,使用單一的 fence(Ordering::Release) 往往比對每一個元素都做原子操作更加高效。例如環形緩衝區(Ring Buffer)在批次寫入多筆資料後,僅在結尾發出一道 Release fence,隨後更新游標。

C++ 與 Rust Memory Ordering 語義對照

兩大系統級語言在記憶體模型的命名與語意上完全對齊:

C++20 (std::memory_order_*) Rust (std::sync::atomic::Ordering) 適用操作類別 核心保證與約束
memory_order_relaxed Ordering::Relaxed Load, Store, RMW 僅保證原子性與單一變數 Modification Order;無跨執行緒同步
memory_order_consume (Rust 未提供) Load 相依性順序(Data dependency);C++ 規格目前暫停建議使用,多被編譯器視為 Acquire
memory_order_acquire Ordering::Acquire Load, RMW 單向屏障:後續存取不能重排至此操作前;配對 Release 建立同步
memory_order_release Ordering::Release Store, RMW 單向屏障:先前存取不能重排至此操作後;配對 Acquire 建立同步
memory_order_acq_rel Ordering::AcqRel RMW 雙向屏障:兼具 Acquire 讀取與 Release 寫入語義
memory_order_seq_cst Ordering::SeqCst Load, Store, RMW 在 AcqRel 基礎上建立全域單一操作總序;代價最高

實戰決策樹:如何挑選最適當的 Ordering?

面對五花八門的 ordering,開發者常常陷入兩難:全用 SeqCst 怕效能低下,全用 Relaxed 又怕程式崩潰。

以下梳理出並行程式設計中最實用的決策心智模型:

flowchart TD Start["需要對共享變數進行原子操作"] --> Q1{"這項操作是否需要傳遞<br/>其他周邊資料的有效性?"} Q1 -- "否 (純獨立狀態或統計)" --> S_Relaxed["使用 Relaxed<br/>(例如:全域計數器、Metrics 採樣、終止旗標宣告)"] Q1 -- "是 (需要訊息傳遞 / Safe Publication)" --> Q2{"操作的性質是什麼?"} Q2 -- "單純讀取 (Load)" --> S_Acquire["使用 Acquire<br/>(接收發布者準備好的資料)"] Q2 -- "單純寫入 (Store)" --> S_Release["使用 Release<br/>(確保資料已寫入完成才發布旗標/指標)"] Q2 -- "讀改寫複合操作 (RMW / CAS)" --> Q3{"該操作需要單向發布、<br/>接收還是雙向同步?"} Q3 -- "只接收" --> S_RMW_Acq["使用 Acquire (如 CAS 失敗重試分支)"] Q3 -- "只發布" --> S_RMW_Rel["使用 Release (如 CAS 成功發布新頂端)"] Q3 -- "同時接收與發布" --> S_AcqRel["使用 AcqRel (如引用計數扣減、雙向狀態機移轉)"] Q1 -- "需要排除 Store-Load 盲區<br/>(如 Dekker 雙向旗標判定)" --> S_SeqCst["使用 SeqCst<br/>(強制全域單一操作總序)"] classDef dec fill:#24283b,stroke:#bb9af7,stroke-width:2px,color:#c0caf5; classDef opt fill:#1f2335,stroke:#7dcfff,stroke-width:1.5px,color:#7dcfff; classDef strong fill:#2a1e2d,stroke:#f7768e,stroke-width:1.5px,color:#f7768e; class Q1,Q2,Q3 dec; class S_Relaxed,S_Acquire,S_Release,S_RMW_Acq,S_RMW_Rel,S_AcqRel opt; class S_SeqCst strong;

總結與下一步:通往 Lock-free 的必經之路

理解 Memory Ordering,是現代系統級工程師從「只會加鎖保護」邁向「高效能並行架構」的最關鍵轉折點:

  1. 不要仰賴直覺推理時間線:在弱記憶體模型與現代編譯器面前,牆上時鐘的先後不代表因果先後,唯有數學上的 happens-before 鏈條才能給予可信保證。
  2. 區分狀態更新與資料發布:單次 atomic 的成功換值只代表該數值本身不撕裂,要讓讀者安全看見其關聯資料,必須藉由 Release-Acquire 配對建立 synchronizes-with
  3. 理解硬體代價:在 x86 上 Acquire/Release 幾乎免費,但在 ARM64 等平台上每一次強硬的順序約束都對應著實體硬體屏障指令。

然而,當我們真正邁向 無鎖資料結構(Lock-free Data Structures) 時,單靠 Memory Ordering 還遠遠不夠。 當我們拿掉 Mutex,利用 CAS(Compare-And-Swap)進行高並發鏈結串列或堆疊的操作時,立刻就會遭遇並行領域的經典噩夢——ABA 問題;而當多個執行緒同時彈出節點時,我們更會面臨「這個節點到底何時才能被安全 delete 或釋放?」的巨大挑戰。

在下一篇文章中,我們將以經典的 Treiber Stack 為切入點,深入探討 CAS 的狀態轉移機制,並全方位剖析 Tagged Pointer、Hazard Pointer、Epoch-Based Reclamation (EBR) 與 RCU 四大安全記憶體回收機制的架構權衡與實作細節。


深入剖析 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 的運作原理!

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

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

flowchart TD subgraph G_Client["GTK 應用程式 (Client Process)"] GTK["GTK 4 / GTK 3<br/>(Widgets, Layout, State)"] GSK["GSK / Cairo<br/>(Scene Graph & 2D/3D Rendering)"] GDK["GDK Wayland Backend<br/>(gdk/wayland)"] EGL["EGL / Mesa (GPU 加速)<br/>wl_shm (軟體繪製)"] LIBWAYLAND["libwayland-client<br/>(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<br/>(Windowing & Shell Management)"] RENDERER["Compositor Renderer<br/>(OpenGL / Vulkan)"] LIBINPUT["libinput<br/>(Input Event Handling)"] end subgraph G_Kernel["Linux Kernel"] DRM["DRM / KMS<br/>(Display Output)"] EVD["evdev<br/>(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 消耗與畫面撕裂。

視窗語意層:xdg_shell 協定

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

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

flowchart TD REG["wl_registry<br/>(Wayland 全域註冊表)"] -->|綁定| WM["xdg_wm_base<br/>(全域管理器介面)"] SURF["wl_surface<br/>(基礎像素表面)"] -->|封裝| XSURF["xdg_surface<br/>(桌面表面基底)"] WM -->|建立| XSURF WM -->|輔助定位| POS_HELPER["xdg_positioner"] XSURF -->|"賦予角色 (Role)"| TOP["xdg_toplevel<br/>(一般頂層應用視窗)"] XSURF -->|"賦予角色 (Role)"| POP["xdg_popup<br/>(彈出式選單 / 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<br/>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 確認序號匹配<br/>原子性(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 便能可靠地判斷應用程式已當機,向使用者跳出「應用程式無回應」的結束對話框。

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

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

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

flowchart TD subgraph G_Client["Client 操作請求"] REQ_OPS["attach / damage / scale / frame<br/>(可多次累積呼叫)"] end subgraph G_State["wl_surface 狀態機"] ST_PENDING["待處理狀態 (Pending State)<br/>暫存所有變更,對螢幕無任何影響"] ST_CURRENT["當前狀態 (Current State)<br/>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 互不阻塞。

底層通訊: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["<b>Object ID</b><br/>32-bit (4 Bytes) · 目標物件識別碼"] H_SIZE["<b>Message Size</b><br/>16-bit (2 Bytes)<br/>封包總長度"] H_OPCODE["<b>Opcode</b><br/>16-bit (2 Bytes)<br/>方法 / 事件編號"] end subgraph G_Payload["動態參數負載 (Payload Data)"] P_ARGS["<b>Arguments (32-bit 對齊參數清單)</b><br/>int32 · uint32 · fixed (24.8)<br/>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<br/>或 memfd_create 共享記憶體"] FD["Client 端 FD (如 fd=7)"] MEM --- FD end subgraph G_Kernel["Linux Kernel IPC"] SCM["sendmsg(..., SCM_RIGHTS, fd=7)<br/>Kernel 在 Compositor FD 表建立副本"] end subgraph G_Compositor["Compositor 行程"] SFD["Server 端 FD (如 fd=12)"] SMEM["直接映射相同的實體 RAM<br/>或 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 事件。

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

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

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

flowchart TD subgraph App["Hello Wayland (C++23)"] WAPP["WaylandApp<br/>(PIMPL · 事件迴圈)"] VK["VulkanRenderer<br/>(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<br/>(VK_KHR_wayland_surface)"] SWAP["VkSwapchainKHR<br/>(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

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 授權

延伸閱讀與參考資源

如果你想更深入地從 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 進行深度客製。

誕生背景: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 的深度協同支援。

Omarchy 全域架構大圖(Big Picture)

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

flowchart TD L1["<b>1. 使用者與 agent 擴充層</b><br/><code>~/.config/omarchy/</code><br/>自訂 hooks · 選單擴充 (JSONC)<br/>插件克隆 · 客製色票"] L2["<b>2. Omarchy 系統框架</b><br/><code>/usr/share/omarchy/</code><br/>統一主題引擎 (colors.toml)<br/>更新管線 · agent skills"] L3["<b>3. 現代化 Wayland 桌面生態</b><br/>Hyprland (模組化 Lua 配置)<br/>Quickshell (QML 狀態列) · UWSM session 管理"] L4["<b>4. Arch Linux 基礎與底層系統</b><br/>Pacman &amp; AUR 滾動更新庫<br/>Limine + Snapper (Btrfs 快照回滾)<br/>PipeWire &amp; Linux Kernel"] L1 ==>|安全配置覆寫 &amp; 插件擴充| L2 L2 ==>|全域主題推播 &amp; 桌面會話整合| 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;

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

鍵盤至上: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

平鋪優先: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

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

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

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

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

深入 Quickshell 插件架構與生態

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

flowchart TD subgraph Host ["omarchy-shell (Quickshell 單一常駐主體)"] direction TB Services["全域共用服務 (QML)<br/>• 統一主題色票 (colors.toml)<br/>• PipeWire 音訊 / Network 狀態"] Registry["PluginRegistry 外掛註冊表<br/>• inotify 監聽即時熱重載<br/>• manifest.json 契約驗證"] end subgraph Plugins ["插件生態系統 (Plugins)"] direction LR Builtin["<b>系統第一方插件</b> (<code>/usr/share/...</code>)<br/>• omarchy.bar · omarchy.audio<br/>• omarchy.agents · omarchy.network"] Custom["<b>使用者自訂插件</b> (<code>~/.config/...</code>)<br/>• custom.cpu-monitor (自訂)<br/>• 第三方 Git 克隆插件"] end Services --- Registry Registry ==>|動態載入 &amp; 生命週期託管| Builtin Registry ==>|動態載入 &amp; 熱重載| 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;

核心運作機制

  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(替換式狀態列)。

哪裡可以找到與探索 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 快速安裝並加入狀態列。

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

客製化 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 就能立即開始或結束語音錄音並自動完成文字輸出,完全不影響一般的應用程式快速鍵。

實作 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 監控!

全域色票生成與動態編譯(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

總結

特性維度 傳統 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 的底層架構、記憶體佈局、執行調度模型與哲學取捨截然相反

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

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

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

flowchart TB subgraph CPP["⚡ C++20/23 Coroutines (Push-Driven / Resumption)"] direction TB CP_Suspend["1. co_await awaitable<br/>協程暫停,儲存狀態至 Frame"] CP_Register["2. await_suspend(handle)<br/>將 coroutine_handle 註冊給 EventLoop / Driver"] CP_Push["3. Event Ready ➜ handle.resume() / 對稱轉移<br/><b>精確直接喚醒</b>:直接跳轉至中斷點續行"] CP_Suspend --> CP_Register --> CP_Push end subgraph Rust["🦀 Rust Async / Future (Pull-Driven / Polling)"] direction TB RS_Poll["1. Executor 呼叫頂層 Future::poll(cx)<br/>狀態機由上而下遞迴評估"] RS_Pending["2. 未就緒 ➜ 回傳 Poll::Pending<br/>底層 Leaf Future 將 Waker 註冊至 Reactor (epoll)"] RS_Pull["3. Event Ready ➜ waker.wake()<br/><b>重新拉取</b>: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)」出最新進度。

核心執行模型深度剖析

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["📦 <b>子協程 Frame</b><br/>final_suspend()"] -->|await_suspend 回傳 parent_h| TailJump["⚡ <b>Symmetric Transfer</b><br/>Tail Call / JMP 暫存器切換"] TailJump --> Parent["🎯 <b>父協程 Frame</b><br/>直接恢復 (零多餘堆疊開銷)"] 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()<br/>(設定 continuation_ = h_server,回傳 h_child 尾跳躍) deactivate Parent activate Child Child->>Upstream: 轉發 HTTP POST 請求至上游 LLM Child->>EL: co_await AsyncReadable{upstream_fd}<br/>(註冊 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 重新排程。

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 指標鏈。

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

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

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

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

flowchart LR subgraph CP_Mem["⚡ C++ Coroutine Frame (動態配置)"] direction TB CF1["🏷️ 函式指標 (Resume / Destroy Fn)"] CF2["🎯 promise_type 實例 (結果、Continuation 指標)"] CF3["💾 捕獲之函式參數與跨暫停點區域變數"] CF4["🔢 狀態機暫停點索引 (Suspend Index)"] CF_Note["📌 <b>預設配置於 Heap (operator new)</b><br/>可透過 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["📌 <b>編譯期確定大小 (Zero Heap 保證)</b><br/>大小由最大 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;

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["🏷️ <b>Function Pointers</b><br/>Resume Function · Destroy Function"] F2["🎯 <b>promise_type 實例</b><br/>結果儲存 · continuation_ 鏈結 · exception_ptr"] F3["💾 <b>Captured Parameters</b><br/>傳入之函式引數 (值或參照捕獲)"] F4["🗄️ <b>跨越暫停點之區域變數</b><br/>跨 co_await 存活之局部變數與臨時物件"] F5["🔢 <b>Suspend Index</b><br/>狀態機暫停點索引"] 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]])推動確定性的堆疊內聯提案,但尚未正式入標。

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

編譯期確定大小的保證

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

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

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

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

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

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

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

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

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

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

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

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

flowchart LR subgraph SelfRef["📦 Self-Referential Struct (自我引用狀態機)"] direction TB Buf["💾 <b>buf: [u8; 1024]</b><br/>自身內部緩衝區 (位址 0x1000)"] Ptr["🔗 <b>ptr: *const u8</b><br/>內部指標欄位 (指向 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<br/>buf: [Data]<br/>ptr: 指向 0x1000 (自身 buf)"] New["移動至新位址 0x2000 (memcpy)<br/>buf: [Data]<br/>ptr: <b>仍指向 0x1000 (懸空損壞!)</b>"] 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;

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

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

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

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

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

// core::pin::Pin 定義
pub struct Pin<Ptr> {
    pointer: Ptr,
}
  1. Unpin auto trait:絕大多數一般 Rust 型別(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 進行艱苦的博弈。

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

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

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

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

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

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

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

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

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

致命暗坑:取消安全性

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

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

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

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

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

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

  1. Readiness 模型(反應器 Reactor):如 Linux epoll、macOS kqueue。核心只通知「檔案描述符何時可讀/可寫」,實際的資料搬移由應用層在就緒後呼叫 read()/write() 完成。
  2. Completion 模型(前導器 Proactor):如 Linux io_uring、Windows IOCP。應用層預先提供 Buffer 將操作提交至核心佇列,核心於背景完成資料搬移後,才通知應用層「I/O 已完成」(詳見 Linux 核心維護者 Jens Axboe 的權威白皮書 Efficient IO with io_uring)。
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["🦀 <b>Rust Pull 模型 (Future::poll)</b><br/>短暫借用 &mut [u8],隨時可安全 Drop"] C3 ==>|天生契合| Push["⚡ <b>C++ Push 模型 (co_await await_suspend)</b><br/>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;

epoll 與 Rust Pull 模型的天然契合

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

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

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

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

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!<br/>Buffer 所在的 0x1000 記憶體被釋放回收 Kernel-->>Mem: 核心非同步完成 DMA 傳輸,直接寫入 0x1000 Note over Mem: 💥 <b>Use-After-Free 記憶體毀損!</b> (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 運作模式。

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 模型。

標準庫與生態哲學的對決

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;

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)、執行緒池以及分散式排程器無縫融合。

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

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

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

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

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

flowchart LR subgraph SyncWorld["🔵 藍色世界 (同步函式)"] direction TB S1["一般同步函式<br/><code>void do_work() / fn do_work()</code>"] S2["❌ <b>無法直接呼叫 co_await / .await</b><br/>因為缺乏獨立堆疊,無法跨一般函式訊框暫停"] S1 --- S2 end subgraph AsyncWorld["🔴 紅色世界 (非同步協程)"] direction TB A1["非同步協程函式<br/><code>Task&lt;void&gt; / async fn</code>"] A2["⚡ <b>型別傳染性 (Viral Propagation)</b><br/>呼叫端必須一路向上改寫為協程,或透過 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)與橋接包裝。

全方位架構對比總結表

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

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

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

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

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

結語

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

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

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

延伸閱讀與經典文獻

經典設計理論

C++ 協程與標準演進

Rust 非同步與安全性契約

作業系統 I/O 架構