C++ atomic 用法入門
發布於 · 更新於
多個執行緒共用一個計數器時,普通的 count++ 不能直接拿來用。 先認識 std::atomic 怎麼處理這件事,再看什麼時候能用 relaxed。
動畫講解 · 2 分 30 秒
用計數器與資料交接動畫,整理 atomic、relaxed 和 release/acquire 的差別。
無配音,搭配字幕與輕音樂,可自行靜音。點播放才會載入影片,完整程式碼在下方。
基本操作
加一包含「讀取、加一、寫回」。假設 count 原本是 0,兩個執行緒都先讀到 0, 各自算出 1 再寫回,就可能把兩次加一算成一次。 這只是幫助理解衝突的示意;C++ 普通變數這樣讀寫會有 data race(資料競爭), 屬於未定義行為,結果不只可能少算。
std::atomic<int> 提供原子操作:對同一個 atomic 的其他原子操作來說, 一次操作不可分割。讀取不會拼到一半新值、一半舊值;原子加法也不會因為同時加一而漏算。
#include <atomic>
int main() { std::atomic<int> count{0}; count.store(10); // 原子寫入:設成 10 int value = count.load(); // 原子讀取:讀到 10 int old = count.fetch_add(1); // 原子加一:變成 11,回傳舊值 10 ++count; // 也是原子加一:變成 12 // 此處依序執行,沒有其他執行緒同時修改。}fetch_add(1) 把讀取、加一、寫回合成一次原子操作。 若寫成 count.store(count.load() + 1),雖然讀寫各自都是原子的, 中間仍可能被其他執行緒插入更新,多個 writer 就可能漏算。這次沒有 count 本身的 data race, 但邏輯仍然錯了。
記憶體順序
atomic 管的是這個值的存取;記憶體順序還決定能不能透過它同步其他資料。 不填參數時預設是 memory_order_seq_cst。先用預設寫對, 確認只需要這個 atomic 值本身時,再考慮 memory_order_relaxed。
一個執行緒更新封包數 rx,另一個每 10 ms 讀一次做監控。 只有一個 writer,能把 std::atomic<uint64_t> 換成普通的 uint64_t 嗎?
不能直接換。不同執行緒讀寫同一個普通變數,沒有鎖或其他同步, 就會有 data race,造成未定義行為。只有一個 writer、讀得很少,都不能取代同步。
不過,若 rx 只拿來顯示統計數字,保留 atomic、搭配 memory_order_relaxed 就夠了。 差別要看讀完它之後做什麼。
只讀計數:relaxed 就夠
把收封包簡化成加 1000 次,監控也只讀一次。以下兩個程式都可以用 C++11 以上編譯。
#include <atomic>#include <iostream>#include <thread>
int main() { std::atomic<int> count{0};
std::thread worker([&] { for (int i = 0; i < 1000; ++i) count.fetch_add(1, std::memory_order_relaxed); });
std::thread monitor([&] { std::cout << count.load(std::memory_order_relaxed) << '\n'; });
worker.join(); monitor.join(); std::cout << count.load(std::memory_order_relaxed) << '\n';}第一行可能是 0~1000,取決於讀到哪次更新;第二行一定是 1000, 因為主執行緒已經透過 join() 等 worker 結束並完成同步。
relaxed 保留每次操作的原子性,讀寫 count 本身不會有 data race。 monitor 只拿這個值來印,沒有藉它判斷其他資料能不能讀,因此不需要用它交接其他資料。 但 relaxed 沒有「幾毫秒內一定讀到最新值」的保證。
看到完成旗標,才讀報告:需要同步
#include <atomic>#include <iostream>#include <string>#include <thread>
int main() { std::string report; std::atomic<bool> done{false};
std::thread worker([&] { report = "result: 42"; done.store(true, std::memory_order_release); });
std::thread viewer([&] { while (!done.load(std::memory_order_acquire)) { } std::cout << report << '\n'; });
worker.join(); viewer.join();}viewer 讀 done,是為了接著讀 另一份資料 report。 當 acquire 讀到這次 release 寫入的 true,兩邊就建立同步關係:
寫 report → release 寫入 true → acquire 讀到這個 true → 讀 report這條關係保證 report 的寫入 happens-before(先行於)它的讀取。 此例只交接一次,worker 發出訊號後也不再修改 report,所以可以安全讀取。 空迴圈是為了凸顯交接;長時間等待時可改用 condition_variable 等阻塞機制。
若把這裡的 release 和 acquire 都改成 relaxed,done 本身仍然安全, 但 report 的讀寫失去同步,會有 data race。這是未定義行為, 不能只當成「偶爾印出空字串」。主執行緒最後的兩次 join,也補不了 worker 與 viewer 之間缺少的交接。
省略記憶體順序參數也能讓這個例子正確:done.store(true) 和 done.load() 預設使用 memory_order_seq_cst,已包含這裡需要的 release/acquire 保證。
relaxed 會改變行為嗎?
它會放寬程式要求的記憶體順序保證。上面的完成旗標改用 relaxed, 就會從正確程式變成有 data race 的程式,所以它有實際語意。
但不保證比較快。同一個 atomic 操作在某些平台上,relaxed 和預設順序可能產生相同機器碼; 能省下多少排序成本,要看編譯器、CPU 和操作種類。
relaxed 的值仍然會給其他執行緒用,第一個例子的 monitor 就在用。 它沒有提供的是:只憑看見這個值更新,就推論其他資料也已經可以讀。
回到 rx:只顯示「收到 100 包」,可以用 relaxed。 如果看到 rx == 100 就去讀第 100 包的內容,便需要另外建立資料交接的同步。 這是判斷這兩類用途的起點;複雜的多變數演算法仍要逐一確認順序需求。
參考
C++ 標準草案: data race 與 happens-before、atomic 記憶體順序、join 的同步保證、load/store 的預設順序。