在單一執行緒(thread)裡,先執行 X = 10,接著執行 Y = 20,直覺上我們會認定「第一件事做完,才做第二件事」。但當另一個執行緒讀到 Y == 20 時,我們能不能百分之百相信 X 此時也已經是 10?
答案是否定的。
要回答這個問題,必須深入認識 記憶體模型(Memory Model):它是一份語言規格與硬體架構之間的法律契約,嚴格規範了多個執行緒存取共享記憶體時,哪些讀取結果是合法的,以及各執行緒之間如何建立可信的同步關係。
無論是 C++(自 C++11 起確立,並在 C++20 持續演進的 <atomic>)還是 Rust(標準庫中的 std::sync::atomic 與 core::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 的保護罩就瞬間失效了。
誰動了我的程式碼?從高階軟體到晶片硬體的重排層級
當我們在原始碼中寫下兩行看似簡單的賦值操作時,這兩行指令在真正轉化為電晶體狀態改變的漫長路途中,必須穿透多個軟硬體抽象層。
為了榨乾現代處理器的每一分運算潛力,從編譯器最佳化到矽晶片電路,各層級都被賦予了重排與快取的自由:
• 循序語意:C++ / Rust Program Order
• 單執行緒 as-if 原則保障結果正確
• 尚未考慮多核心記憶體可見性問題
• 指令調度 (Scheduling):隱藏管線延遲
• 暫存器提升 (Promotion):延遲寫回記憶體
• 冗餘消除 (DSE / CSE):刪除中間存取
• 指令解碼為微操作 (μops) 平行派發
• 分支預測與推測讀取 (Speculative Load)
• 保留站 (Reservation Station) 動態調度
• 運算元資料就緒即脫離循序執行
• 重排序緩衝區 (ROB):維護暫存器假象
• 指令循序引退 (In-order Retirement)
• 吸收寫入延遲,非同步背景排空 (Drain)
• 支援寫入合併 (Store Merging)
• ★ 物理根源:誘發 Store-Load 亂序
• 暫存跨核心失效請求,延遲確認 (ACK)
• 避免 CPU 等待快取行使無效廣播
• 弱架構下引發過期快取資料讀取
• 本地 L1 / L2 快取與共享 L3 快取
• 快取未命中 (Miss) 產生巨大延遲落差
• 造成各變數寫入發布時程產生偏斜
• MESI / MOESI 協定維持快取行狀態
• 局限:僅保證單一位址,不保證跨位址順序
• 晶片互連網路 (Ring/Mesh) 傳播延遲差
以下由上至下(從軟體層深入至硬體晶片)逐一解析各層級如何「篡改」我們的指令順序:
高階語言與編譯器最佳化層(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) 誕生的根本目的:
它在混亂的底層現實與純粹的高階語意之間築起一道契約牆。開發者透過標註 Relaxed、Acquire、Release 或 SeqCst,精確指示編譯器在適當的位置插入 編譯器屏障(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 的 AtomicI32、AtomicBool 等)保證了操作的不可分割性;而你在每次操作時所指定的 memory_order,則決定了它要對周邊其他記憶體存取施加多強的順序約束。
四大核心因果關係
C++ 與 Rust 的記憶體模型使用數學上的偏序關係(Partial Order)來定義並行操作的合法性。其中最關鍵的四個概念如下:
(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;
- sequenced-before(循序先於):同一執行緒內部的程式執行順序。在 Thread A 裡,程式碼上一行 sequenced-before 下一行。單靠此關係無法跨越執行緒邊界。
- reads-from(讀取自):某個執行緒的 load 操作讀取到了另一個執行緒的 store 操作所寫入的值。
- synchronizes-with(同步於):跨執行緒的關鍵橋樑。當一個帶有 Release 語意的 store 被另一個帶有 Acquire 語意的 load 成功讀到(reads-from 成立)時,這兩個操作之間就建立了 synchronizes-with 關係。
- happens-before(先發生於):由 sequenced-before 與 synchronizes-with 組合傳遞而成的全域偏序關係。如果操作 A happens-before 操作 B,則 A 對記憶體所做的所有修改,對於 B 都是保證可見的。
最核心的原則是:單純「我讀到了你寫入的數值(reads-from)」,並不代表「你在寫入該數值前所做的所有事情,都已經同步給我(happens-before)」。 必須使用正確的 memory ordering 才能建立起跨執行緒的同步橋樑。
思考實驗:Relaxed 為什麼可能讀到 0, 20?
為了驗證我們的直覺盲點,來看這個經典的訊息傳遞(Message Passing)問題。
假設全域有兩個整數變數 X 與 Y,初始值皆為 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 順序只給予兩個保證:
- 單一操作的原子性:不會讀到撕裂的半個整數。
- 單一變數的修改順序(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 硬體則負責「如何兌現(或打破)這些約定」。
了解底層硬體運作,能幫助我們建立直觀的物理心智模型:
Store Buffer(寫入緩衝區)
CPU 核心的時脈在數 GHz,而存取快取(Cache)需要數個週期,存取主記憶體(RAM)甚至需要上百個週期。如果 CPU 每做一次寫入都要等快取行(Cache Line)取得所有權並確認寫入完成,執行管線將頻繁停頓(Stall)。
因此,現代 CPU 核心在管線與快取之間加入了一個硬體佇列——Store Buffer。 當核心執行 store 指令時,它將位址與數值直接丟進 Store Buffer,隨即宣告指令完成,管線繼續執行下一條指令。Store Buffer 會在背景非同步地將資料排空(Drain)至 L1 快取。
這就產生了時間差:本核心已經在邏輯上完成了寫入,但該數值根本還沒進入快取系統,其他核心完全看不見。
在前面的例子中(此場景特別容易發生在弱記憶體架構如 ARM64 上):
- 核心 0 執行
X = 10,丟入 Store Buffer。 - 核心 0 接著執行
Y = 20,也丟入 Store Buffer。 - 如果
Y所在的快取行原本就處於核心 0 的 Modified 狀態,而X所在的快取行發生了快取未命中(Cache Miss),弱架構硬體可能優先將Y = 20排入快取並廣播給其他核心,而X = 10仍被滯留在 Store Buffer 裡! - 結果核心 1 的快取先收到了
Y = 20,卻在讀取X時拿到舊值 0。
快取一致性協定(Cache Coherence)不能拯救跨變數順序
常有人誤以為:「我們有 MESI / MOESI 快取一致性協定,快取不是隨時保持一致嗎?為什麼還會亂序?」
這個理解偏差在於:快取一致性協定僅僅保證「單一記憶體位址(單一快取行)」在所有核心之間的狀態轉換是一致的。
它能保證對於同一個變數 X,所有核心最終會看到相同的修改歷史(即 Modification Order),但它完全不負責協調多個不同記憶體位址(X 與 Y)之間的寫入先後順序!
跨變數的先後順序,必須依賴記憶體屏障(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):阻止後續讀取被推測執行重排到此指令前
為什麼 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_add、fetch_sub 或 compare_exchange:
- 讀取部分 具備 Acquire 語意:接收先前其他執行緒發布的資料。
- 寫入部分 具備 Release 語意:將本次更新連同先前的資料變更發布給後續的執行緒。
最典型的範例是參考計數(Reference Counting),例如 C++ 的 std::shared_ptr 或 Rust 的 Arc:
- 計數增加(Clone / Retain):使用
Relaxed即可,因為增加引用只是為了防止物件被提前釋放,不需要同步資料。 - 計數減少(Drop / Release):使用
AcqRel或Release扣減。 - 最後一個持有者析構(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 的 MFENCE 或 XCHG,ARM 的重型記憶體屏障),確保 Store Buffer 立即排空,保證絕不可能兩邊同時讀到對方的初始值。
SeqCst 的代價與迷思
- 效能代價高昂:在 x86-64 上,普通 Acquire/Release 是零額外成本的
MOV,而SeqCst寫入必須加上帶有匯流排鎖定鎖延遲的指令;在 ARM64 上,SeqCst需要更嚴苛的管線排空。 - SeqCst 不能合成複合交易:
SeqCst只能保證原子變數本身的操作順序,絕對不會把兩行獨立的原子操作自動打包成一個不可分割的交易(Transaction)。 - SeqCst 救不了記憶體生命週期:即使你把所有指標讀取與 CAS 全部改成
SeqCst,如果另一個執行緒在你讀出指標與解引用(Dereference)的短暫空隙中把該記憶體free掉了,依然會引發嚴重的 Use-After-Free 記憶體損毀。
Rust 型別系統與 Memory Model 的深度融合
Rust 在語言層級上實踐記憶體模型時,最令人驚豔的特點在於其所有權(Ownership)與型別系統如何與記憶體模型渾然天成地結合。
Send 與 Sync 的編譯期防線
在 C++ 中,如果你不小心把一個普通的 int 跨執行緒讀寫,編譯器不會給予任何警告,程式直接陷入未定義行為(Undefined Behavior, UB)的大坑。
而在 Rust 中:
T: Send代表型別T的所有權可以安全轉移到另一個執行緒。T: Sync代表型別T的不可變引用&T可以安全地被多個執行緒同時存取。- 標準庫規定:當且僅當
T: Sync時,&T才是Send。
普通的純量型別(如 i32)雖然也是 Sync(不可變引用 &i32 可在多執行緒間安全共享讀取),但它無法在共享引用的情況下直接修改內容(Rust 禁止透過 &i32 進行 Shared Mutation)。
原子型別(如 AtomicBool、AtomicPtr<T>)則透過內部可變性(Interior Mutability,底層奠基於 UnsafeCell<T>),對編譯器做出執行緒安全保證,因此它們被標準庫實作了 Sync。這意味著:
你可以在多個執行緒同時持有 &AtomicT 的情況下並行修改它,而 Rust 在編譯期就從型別層面全面杜絕了非原子變數引發的普通 Data Race!
獨立記憶體屏障:fence 與 compiler_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 又怕程式崩潰。
以下梳理出並行程式設計中最實用的決策心智模型:
其他周邊資料的有效性?"} Q1 -- "否 (純獨立狀態或統計)" --> S_Relaxed["使用 Relaxed
(例如:全域計數器、Metrics 採樣、終止旗標宣告)"] Q1 -- "是 (需要訊息傳遞 / Safe Publication)" --> Q2{"操作的性質是什麼?"} Q2 -- "單純讀取 (Load)" --> S_Acquire["使用 Acquire
(接收發布者準備好的資料)"] Q2 -- "單純寫入 (Store)" --> S_Release["使用 Release
(確保資料已寫入完成才發布旗標/指標)"] Q2 -- "讀改寫複合操作 (RMW / CAS)" --> Q3{"該操作需要單向發布、
接收還是雙向同步?"} Q3 -- "只接收" --> S_RMW_Acq["使用 Acquire (如 CAS 失敗重試分支)"] Q3 -- "只發布" --> S_RMW_Rel["使用 Release (如 CAS 成功發布新頂端)"] Q3 -- "同時接收與發布" --> S_AcqRel["使用 AcqRel (如引用計數扣減、雙向狀態機移轉)"] Q1 -- "需要排除 Store-Load 盲區
(如 Dekker 雙向旗標判定)" --> S_SeqCst["使用 SeqCst
(強制全域單一操作總序)"] 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,是現代系統級工程師從「只會加鎖保護」邁向「高效能並行架構」的最關鍵轉折點:
- 不要仰賴直覺推理時間線:在弱記憶體模型與現代編譯器面前,牆上時鐘的先後不代表因果先後,唯有數學上的
happens-before鏈條才能給予可信保證。 - 區分狀態更新與資料發布:單次 atomic 的成功換值只代表該數值本身不撕裂,要讓讀者安全看見其關聯資料,必須藉由 Release-Acquire 配對建立
synchronizes-with。 - 理解硬體代價:在 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 四大安全記憶體回收機制的架構權衡與實作細節。