釋放序列與柵欄 (Release Sequences and Fences)
Overview Table
| 概念 | 重點 |
|---|---|
| 釋放序列 | release store 後同一變數上的連續 RMW 鏈;acquire 讀到鏈上任一值即與最初的 store 同步 |
| 柵欄 (fence) | std::atomic_thread_fence;release 柵欄 + acquire 柵欄配對,讓 relaxed 操作建立同步 |
| 柵欄的同步點 | 柵欄本身:release 柵欄必須放在兩個 store 之間,否則無效 |
| 以原子操作排序非原子操作 | release-acquire 對或柵欄同樣能為非原子變數建立 happens-before(代碼 5.2、5.13 的原理) |
| 高階同步機制 | mutex/thread/future/latch/barrier 內部皆以 acquire/release 建立先行;條件變數本身不提供同步 |
釋放序列 (Release Sequence) 與同步
定義:對原子變數的一個 release store(標記 release、acq_rel 或 seq_cst)之後,同一變數上接連的原子讀-改-寫 (RMW) 操作構成一條釋放序列——鏈上的 RMW 可來自任意執行緒、可用任意記憶體順序(甚至 relaxed)。一個 acquire(或 consume/seq_cst)載入只要讀到鏈上任一操作寫入的值,就與最初的 store 同步發生(consume 則為前序依賴)。
典型場景:用 atomic<int> 對共享佇列元素計數(代碼 5.11):
std::vector<int> queue_data; // 非原子共享資料
std::atomic<int> count;
// 生產者:填好 20 筆資料後
count.store(20, std::memory_order_release); // ① 初始存儲
// 每個消費者:
int idx = count.fetch_sub(1, std::memory_order_acquire); // ② RMW
if (idx > 0) process(queue_data[idx - 1]); // ③ 安全讀取
生產者 count 的修改順序(釋放序列)
① store(20, release) ─────── 20 ← 序列頭
│
消費者1 ② fetch_sub(1,acquire)─ 19 ← RMW,鏈延續
│
消費者2 ② fetch_sub(1,acquire)─ 18 ← RMW,仍在鏈上
① synchronizes-with 消費者1 的 ②,也 synchronizes-with 消費者2 的 ②
⇒ 生產者寫 queue_data happens-before 每個消費者的讀取 ③
(消費者彼此之間不需要同步)
- 只有一個消費者時:store(release) ↔ fetch_sub(acquire) 直接同步,沒有懸念。
- 兩個消費者時的關鍵:第二個
fetch_sub讀到的是第一個消費者寫入的 19,不是 store 寫入的 20。若沒有釋放序列規則,第二個消費者與生產者之間就沒有先行關係,讀取queue_data不安全——除非第一個fetch_sub改用 release 語義,但那會在兩個消費者之間建立不必要的同步。 - 釋放序列規則讓 store① 直接同步於鏈上每一個 acquire RMW,鏈可以任意長。
本書(C++17)的定義中,鏈也可由「同一執行緒的後續 store」延續;C++20 起釋放序列只由 RMW 操作延續,同執行緒的普通 store 會斷鏈。依賴「後續 store 延續序列」的程式碼不可移植,實務上只依賴 RMW 鏈即可。
柵欄 (Fences):std::atomic_thread_fence
柵欄是全局操作,不針對任何特定變數,又稱記憶體柵欄 (memory barriers)——像畫了一條代碼無法跨越的線,限制 relaxed 操作的重排。典型用法:與 relaxed 原子操作搭配,把同步約束從每個操作上移到柵欄上。
// 代碼 5.12:assert 不會觸發
void write_x_then_y() {
x.store(true, std::memory_order_relaxed); // ①
std::atomic_thread_fencememory_order_release; // ② 釋放柵欄
y.store(true, std::memory_order_relaxed); // ③
}
void read_y_then_x() {
while memory_order_relaxed); // ④ 讀到 ③ 的值
std::atomic_thread_fencememory_order_acquire; // ⑤ 獲取柵欄
if memory_order_relaxed) ++z; // ⑥ 必為 true
}
執行緒 a 執行緒 b
① x.store(true, relaxed)
② fence(release) ─────┐
③ y.store(true, relaxed) ──讀到──→ ④ while(!y.load(relaxed));
└─ synchronizes-with ─→ ⑤ fence(acquire)
⑥ x.load(relaxed) 必為 true
前提:④ 讀到「② 之後的 store ③」寫入的值,且 ④ 在 ⑤ 之前
⇒ ① happens-before ⑥,assert(z!=0) 不觸發
柵欄同步的三種配對(條件都是「載入讀到那個存儲寫入的值」):
| 釋放端 | 獲取端 | 同步關係 |
|---|---|---|
| release 柵欄 + 其後 store | acquire load | 柵欄 synchronizes-with 載入 |
| release store | 先 load、後 acquire 柵欄 | 存儲 synchronizes-with 柵欄 |
| release 柵欄 + 其後 store | 先 load、後 acquire 柵欄 | 柵欄 synchronizes-with 柵欄 |
若把 write_x_then_y 中的釋放柵欄移到「兩個 store 之前」,柵欄不再分隔 store x 與 store y,順序保證消失,斷言可能觸發。release 柵欄必須放在「先做的寫入」與「發布用的寫入」之間;acquire 柵欄必須放在「觀察用的載入」與「後續讀取」之間。柵欄也不會影響本來就有先行關係的操作。
以原子操作為非原子操作排序
把代碼 5.12 的 x 換成普通(非原子)bool,行為完全相同(代碼 5.13):
bool x = false; // 非原子!
std::atomic<bool> y;
// 執行緒 a:x=true; fence(release); y.store(true,relaxed);
// 執行緒 b:while(!y.load(relaxed)); fence(acquire); if(x) ++z; // 安全
- 柵欄仍為「存 x / 存 y」與「載 y / 載 x」建立執行順序 → 對非原子 x 的存取有先行關係,沒有資料競爭,斷言不觸發。
- y 必須保持原子:它是被兩個執行緒無鎖存取的訊號變數,改成非原子就在 y 上產生資料競爭。
- 不只柵欄:release/acquire(以及 consume)配對本身就能為非原子存取排序——這正是代碼 5.2「原子旗標發布非原子 data」的原理(見 05-Memory-Model-and-Atomics/03-Synchronizes-With-and-Happens-Before),許多例子中的 relaxed 原子變數其實可以直接用普通非原子變數代替。
鎖與同步機制如何建立 happens-before
規則:非原子操作 A 序前於某原子操作 B,而 B 先行於另一執行緒的操作 C → A 也先行於 C。這是 C++ 標準庫所有高階同步工具的基礎。
自旋鎖範例(代碼 5.1 的原理):lock() 是 flag.test_and_setmemory_order_acquire) 迴圈,unlock() 是 flag.clear(std::memory_order_release。
執行緒 1 執行緒 2
lock() (acquire)
修改受保護資料 lock() … test_and_set 自旋等待
unlock()(release) ── 同步發生 ──→ test_and_set 成功 (acquire)
讀到執行緒 1 的全部修改
持鎖期間的修改序前於 unlock(),unlock() 的 release 與下一個 lock() 的 acquire 同步 → 修改先行於下一個持鎖者的存取。所有互斥鎖原理相同:同一記憶體位置上,lock() 是獲取操作、unlock() 是釋放操作。
標準庫同步機制提供的同步關係(5.3.7 整理):
| 機制 | 同步保證 |
|---|---|
std::thread |
構造函式與新執行緒中函式的呼叫同步;執行緒的完成與 join() 的返回同步 |
| mutex 家族(含 timed/recursive) | unlock() 與其後的 lock()/成功的 try_lock*() 同步(形成鎖序);失敗的 try_lock 無同步關係 |
std::shared_mutex / shared_timed_mutex |
同上,lock_shared/unlock_shared 一併納入鎖序 |
std::promise / std::future |
成功的 set_value()/set_exception() 與成功的 wait()/get()(或回傳 ready 的 wait_for/until)同步 |
std::packaged_task |
函式呼叫運算子的執行與成功的 wait()/get() 同步 |
std::async |
launch::async 任務的完成與 wait()/get() 同步(deferred 則為任務執行與之同步) |
std::experimental::latch |
count_down/count_down_and_wait 與成功的 wait/count_down_and_wait 同步 |
std::experimental::barrier/flex_barrier |
arrive_and_wait/arrive_and_drop 與其他執行緒隨後完成的 arrive_and_wait 同步 |
條件變數 (condition_variable[_any]) |
本身不提供任何同步關係——只是忙等待的優化,所有同步都由搭配的 mutex 提供 |
常見誤解:「notify 與 wait 之間有同步」。標準規定條件變數不提供同步關係,共享資料的可見性完全靠互斥鎖的鎖序保證——這也是 wait 必須搭配 mutex 與判斷式(predicate)的原因(見 04-Synchronizing-Operations/01-Condition-Variables-and-Thread-Safe-Queue)。
Exam/Test Patterns
| 情境/關鍵字 | 答案 |
|---|---|
| 生產者 store(release) 一次,兩個消費者各 fetch_sub(acquire),安全嗎? | 安全;釋放序列讓 store 同步於鏈上每個 acquire RMW,消費者間毋須同步 |
| 釋放序列鏈上 RMW 的記憶體順序要求 | 任意(連 relaxed 都可),鏈即延續;同步強度由讀端的 acquire 決定 |
| 「第二個讀者讀到的值不是 store 寫的,還同步嗎?」 | 同步——只要該值在釋放序列上,acquire 就與最初的 store 同步 |
| relaxed store/load 如何建立同步 | store 前插 release 柵欄、load 後插 acquire 柵欄(且 load 讀到該 store 的值) |
| 柵欄放在兩個 store 之前 | 無效;同步點是柵欄本身,必須位於兩個操作之間 |
| 代碼 5.13 中訊號變數 y 能否也改非原子 | 不能;兩執行緒無鎖存取 y 會直接構成資料競爭 |
lock() / unlock() 的記憶體語義 |
lock = acquire、unlock = release → 臨界區修改 happens-before 下一持鎖者 |
失敗的 try_lock 有同步關係嗎 |
沒有;只有成功取得鎖才參與鎖序 |
| 「condition_variable 的 notify 建立同步嗎?」 | 不建立;同步全由 mutex 提供,CV 只是忙等的優化 |
join() 提供什麼保證 |
執行緒的完成同步發生於 join() 返回 → 其所有效果對呼叫者可見 |
Related Notes
- 05-Memory-Model-and-Atomics/01-Memory-Model-Basics
- 05-Memory-Model-and-Atomics/02-Atomic-Types-and-Operations
- 05-Memory-Model-and-Atomics/03-Synchronizes-With-and-Happens-Before
- 05-Memory-Model-and-Atomics/04-Memory-Ordering-Options
- 05-Memory-Model-and-Atomics/Practice-Memory-Model-and-Atomics
- 04-Synchronizing-Operations/02-Futures-and-Asynchronous-Tasks
- 07-Lock-Free-Data-Structures/04-Lock-Free-Queue-and-Memory-Ordering