釋放序列與柵欄 (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(標記 releaseacq_relseq_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 每個消費者的讀取 ③
   (消費者彼此之間不需要同步)
C++20 收緊了釋放序列

本書(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;   // 安全

鎖與同步機制如何建立 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 提供
條件變數不建立 happens-before

常見誤解:「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() 返回 → 其所有效果對呼叫者可見