所有文章

C++ atomic 用法入門

發布於 · 更新於

多個執行緒共用一個計數器時,普通的 count++ 不能直接拿來用。 先認識 std::atomic 怎麼處理這件事,再看什麼時候能用 relaxed。

動畫講解 · 2 分 30 秒

用計數器與資料交接動畫,整理 atomic、relaxed 和 release/acquire 的差別。

無配音,搭配字幕與輕音樂,可自行靜音。點播放才會載入影片,完整程式碼在下方。

基本操作

加一包含「讀取、加一、寫回」。假設 count 原本是 0,兩個執行緒都先讀到 0, 各自算出 1 再寫回,就可能把兩次加一算成一次。 這只是幫助理解衝突的示意;C++ 普通變數這樣讀寫會有 data race(資料競爭), 屬於未定義行為,結果不只可能少算。

std::atomic<int> 提供原子操作:對同一個 atomic 的其他原子操作來說, 一次操作不可分割。讀取不會拼到一半新值、一半舊值;原子加法也不會因為同時加一而漏算。

cpp
#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 以上編譯。

cpp
#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 沒有「幾毫秒內一定讀到最新值」的保證。

看到完成旗標,才讀報告:需要同步

cpp
#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,兩邊就建立同步關係:

text
寫 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 的預設順序。