記憶體模型與原子操作練習題 (Practice - Memory Model and Atomics)
Related Concepts
- 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/05-Release-Sequences-and-Fences
| 關鍵字 | 答案 |
|---|---|
| 相鄰位元欄位 | 同一記憶體位置,不能分開保護 |
| 無同步的併發寫 | 資料競爭 → 未定義行為 |
| 唯一保證 lock-free | std::atomic_flag |
| CAS 值相等仍失敗 | weak 的偽失敗 → 搭配迴圈 |
| 預設記憶體順序 | memory_order_seq_cst(全局總順序) |
| relaxed 保證 | 僅單一變數修改順序,無跨執行緒同步 |
| release ↔ acquire | 成對同步(acquire 須讀到 release 的值) |
| store 之後的 RMW 鏈 | 釋放序列,acquire 讀鏈上任一值即與 store 同步 |
| relaxed + 柵欄 | release fence(store 間)+ acquire fence(load 後) |
| lock/unlock | acquire / release |
Question 1 - 位元欄位與記憶體位置 [recall]
某 struct 內有相鄰的位元欄位
bf1:10與bf2:25,兩個執行緒想分別用兩把不同的 mutex 保護並同時寫入這兩個欄位,可行嗎?若不可行,如何讓它們可被獨立保護?
不可行。相鄰位元欄位屬於同一個記憶體位置,同時寫入即使各有 mutex 仍是對同一位置的無序併發寫 → 資料競爭 → 未定義行為。解法:在兩者之間插入寬度 0 的未命名位元欄位(int :0;),強制下一欄位對齊到新邊界,把它們切成兩個記憶體位置。
Question 2 - 資料競爭與原子操作的界線 [recall]
兩個執行緒無任何同步地讀寫同一個非原子
int,後果是什麼?把它改成std::atomic<int>後,「解決」了什麼、又「沒解決」什麼?
後果:資料競爭 → 未定義行為(C++ 的黑洞,整個程式行為不可預測)。改用原子型別後:每次存取不可分割、行為回到有定義的範圍,且該變數有全體一致的修改順序——這是解決的部分。沒解決的是:競爭本身仍在,哪個執行緒先存取依然未指定;要控制先後,還需要記憶體順序/同步建立 happens-before。
Question 3 - std::atomic_flag [recall]
哪個原子型別是標準唯一保證 lock-free 的?它如何初始化、支援哪些操作、有什麼致命限制?
std::atomic_flag(唯一保證無鎖,連 is_lock_free() 都不提供)。必須以 ATOMIC_FLAG_INIT 初始化,初始一律為「清除」狀態。僅支援:test_and_set()(RMW:設置並回傳舊值)與 clear()(store 操作,不可用 acquire/acq_rel 語義)。限制:沒有非修改查詢(不能單純讀值),連普通布林旗標都當不了 → 只適合當建構元件(如自旋鎖),一般用途改用 std::atomic<bool>。
Question 4 - compare_exchange weak vs strong [recall]
compare_exchange_weak()回傳false,但檢查發現變數值其實等於 expected——為什麼?weak 與 strong 各該在什麼情況使用?
這是偽失敗 (spurious failure):在缺少單條 CAS 指令的平台上,時序因素(如執行緒中途被切換)可使值相等仍失敗,因此 weak 必須搭配迴圈。compare_exchange_strong() 內部含迴圈,回傳 false 必定因值不相等。選擇:本來就要寫迴圈或存值便宜 → weak;desired 計算昂貴、不想重算 → strong。另注意:失敗序不能是 release/acq_rel(失敗不發生存儲)。
Question 5 - 六種順序與三種模型 [recall]
列出六種
memory_order並歸類到三種記憶體模型;預設是哪一種?哪一種被 C++17 明確不建議使用?
順序一致:memory_order_seq_cst(預設,全局總順序);獲取-釋放:memory_order_consume、memory_order_acquire、memory_order_release、memory_order_acq_rel(成對同步);自由:memory_order_relaxed(僅單一變數修改順序)。C++17 明確不建議 memory_order_consume(只同步資料依賴鏈,編譯器實務上當 acquire 處理)——一律改用 acquire。
Question 6 - 釋放序列的定義 [recall]
什麼是釋放序列 (release sequence)?鏈上的讀-改-寫操作對記憶體順序有何要求?acquire 載入讀到鏈中間的值時,與誰同步?
release store(標記 release/acq_rel/seq_cst)之後,同一原子變數上接連的 RMW 操作鏈構成釋放序列;鏈上 RMW 可來自任意執行緒、用任意記憶體順序(甚至 relaxed)。acquire(或 consume/seq_cst)載入只要讀到鏈上任一操作寫入的值,就與最初的 release store 同步發生(而非只與寫入該值的那個操作)。
Question 7 - 自旋鎖的記憶體順序 [application]
用
std::atomic_flag實作自旋鎖:lock()與unlock()各應選什麼記憶體順序?寫出核心程式碼並說明為何臨界區內對非原子資料的修改對下一個持鎖者可見。
lockmemory_order_release;。原理:持鎖者的修改序前於 unlock;unlock 的 release 與下一個 lock 成功的 test_and_set 的 acquire 同步發生;再由序前接到後續存取 → 修改 happens-before 下一個持鎖者的讀寫,非原子資料因此無資料競爭。這也是所有 mutex 的通用原理:lock = acquire、unlock = release。
Question 8 - 用柵欄修復 relaxed 程式 [application]
執行緒 A 依序
x.store(true,relaxed)、y.store(true,relaxed);執行緒 B 以 relaxed 迴圈等 y 為 true 後讀 x,斷言 x==true 可能失敗。不改變四個操作的 relaxed 標記,如何用std::atomic_thread_fence修復?柵欄位置有何硬性要求?
在 A 的兩個 store 之間插入 std::atomic_thread_fencememory_order_release);在 B 的 load y 迴圈之後、load x 之前插入 std::atomic_thread_fence(std::memory_order_acquire)。當 B 讀到「release 柵欄之後的 store」寫入的值,兩柵欄同步發生 → store x happens-before load x。位置要求:同步點是柵欄本身——release 柵欄若放在兩個 store 之前即無效。此模式對非原子的 x 也成立(y 必須保持原子。
Question 9 - seq_cst 與 acquire-release 的差異 [analysis]
執行緒 1 寫 x、執行緒 2 寫 y(不同變數),執行緒 3 等 x 後讀 y、執行緒 4 等 y 後讀 x,讀到 true 就對 z 加一。比較:全部用 seq_cst 時 z 為何不可能為 0?全部改成 release/acquire 後為何 z==0 變合法?兩者成本差在哪?
seq_cst:所有操作構成單一全局總順序,store x 與 store y 必有先後;若執行緒 3 看到 yfalse,代表 store x 全局在前,則執行緒 4(已等到 ytrue)必看到 xtrue → z ≥ 1。acquire-release:同步只在「同一變數的 release/acquire 對」之間成對成立,x 與 y 之間沒有全局順序,執行緒 3、4 可看到相反的寫入順序(各活在自己的世界)→ 兩者都讀到 false、z0 合法。成本:seq_cst 需跨處理器全局同步,最昂貴;acquire-release 在 x86/x86-64 幾乎免費,是「夠用就好」的折衷。
Question 10 - 釋放序列的雙消費者分析 [analysis]
生產者填好 20 筆
queue_data後執行count.store(20, release);兩個消費者各自count.fetch_sub(1, acquire)取索引後讀取非原子的queue_data。第二個消費者讀到的計數值(19)是第一個消費者寫入的,而非生產者——為什麼它讀queue_data仍然安全?若標準沒有釋放序列規則,該如何補救、代價是什麼?
fetch_sub 是 RMW,延續了以 store(release) 為頭的釋放序列;acquire 載入讀到鏈上任一值(20 或 19)都與最初的 store 同步 → 生產者寫 queue_data happens-before 每個消費者的讀取,即使兩消費者互相之間沒有同步。若無此規則:第一個 fetch_sub 與生產者同步,但第二個只與第一個消費者的寫入「接上」而無先行關係 → 讀資料不安全;補救是讓每個 fetch_sub 帶 release(即 acq_rel)語義,代價是在消費者之間建立不必要的成對同步,徒增開銷。釋放序列正是為了免除這種負擔。
| 關鍵字 | 答案 |
|---|---|
| 相鄰位元欄位 + 兩把 mutex | 不行:同一記憶體位置;插寬度 0 欄位分離 |
| 原子操作解決什麼 | 未定義行為;競爭先後仍未指定 |
atomic_flag |
唯一保證 lock-free;ATOMIC_FLAG_INIT;僅 test_and_set/clear |
| weak 偽失敗 | 搭配迴圈;strong 內含迴圈;失敗序禁 release/acq_rel |
| 六種順序 | seq_cst(預設)/ acquire-release 四種 / relaxed;consume 勿用 |
| seq_cst vs acq-rel | 全局總順序 vs 成對同步(可見相反順序) |
| relaxed | 只保單一變數修改順序;跨變數可重排 |
| 釋放序列 | store(release) 後的 RMW 鏈;acquire 讀鏈上任一值即與 store 同步 |
| 柵欄 | release fence 在 store 間、acquire fence 在 load 後;同步點是柵欄本身 |
| lock()/unlock() | acquire / release → 臨界區 happens-before 下一持鎖者 |
| condition_variable | 本身不提供同步;靠 mutex |