TopC++ 入門 › STEP 12 › 第70回 std::atomic の基礎

第70回 std::atomic の基礎 — ロック無しで安全な共有

前回の mutex は「複数行の作業」を守るのに向くが、1 変数だけを守るにはオーバーヘッドが大きい。std::atomic<T> は CPU の原子命令を使い、ロック無しで安全に ++ や読み書きを行えます。

最低限ここだけ

  • std::atomic<int> cnt{0};
  • cnt++/cnt.load()/cnt.store(x)
  • 1 変数だけなら mutex より速い

余裕があれば

  • fetch_add / compare_exchange
  • memory_order の 3 レベル
  • atomic_flag と spinlock

1. まず触ってみる — atomic カウンタ

第 59 回の int n;std::atomic<int> n; に変えるだけで、データ競合が消えます。CPU のハードウェア命令が「読んで+1して書く」を不可分に実行してくれる、というのが正体です。

1-1. イメージ — 回転扉

mutex は「鍵のかかる個室(複数行の作業を全部守れる)」。atomic は「1 回転 1 人の回転扉(1 つの値の更新だけ、しかし一瞬)」。単一変数しか守らない代わりに、鍵の掛け外しが不要で桁違いに速い。

1-2. 最小コード

atomic_basic.cppC++
#include <atomic> #include <thread> std::atomic<int> n{0}; // ← 0 で初期化 void inc() { for (int i=0; i<100000; ++i) ++n; // ← 原子的にインクリメント } int main() { std::thread a(inc), b(inc); a.join(); b.join(); std::cout << n; // 必ず 200000 }

++n は内部で fetch_add(1) に展開され、x86 では lock xadd 命令になります(1 命令)。mutex の lock/unlock(複数命令 + カーネル呼び出しの可能性)より圧倒的に軽量です。

1-3. よくある質問

  • Q. 何でも atomic にできる? 自明に複製できる型(POD / trivially copyable)なら OK。std::string は不可。基本型・ポインタ・small struct が主な対象です。
  • Q. 真にロックフリー? ほとんどの実装で int / long / ポインタはハードウェア命令で実装されロックフリー。16 バイトを超える型はロック実装にフォールバック。is_lock_free() で確認可能。
  • Q. volatile と何が違う? volatile はコンパイラ最適化抑制(デバイス I/O 向け)。マルチスレッド同期には使えない。必ず atomic を使う。

2. mutex との性能比較

同じ「2 スレッドで 100 万回 ++」を mutex 版と atomic 版で比較するとだいたいこうなります(環境により差はあります)。

▶ 実行時間の目安

std::mutex + lock_guard で ++counter

std::atomic<int> ++

計測結果がここに表示されます。
ただし万能ではない: 複数の変数をまとめて更新したい(例: 送金先から減らして送金元に足す)場合、atomic だけでは不可能。そういう時は mutex が必要。

3. 主要メンバ関数

操作意味
load()現在の値を読むint v = n.load();
store(x)値を書くn.store(42);
exchange(x)書いて旧値を返すint old = n.exchange(0);
fetch_add(n)加算して旧値を返すint idx = n.fetch_add(1);
fetch_sub(n)減算
fetch_or/and/xorビット演算フラグビットの更新
compare_exchange_weakCAS(後述)ロックフリー DS

3-1. 演算子と fetch_xxx は等価?

ops.cppC++
std::atomic<int> n{0}; ++n; // fetch_add(1) と等価 n++; // 同上(旧値を返す点も同じ) n += 5; // fetch_add(5) n = 10; // store(10) int v = n; // load() // ただし "n = n + 1" は原子ではない(load → add → store の 3 段階) n = n + 1; // ← NG: 途中で割り込まれうる

4. compare_exchange と CAS

CAS(Compare-And-Swap)は「現在の値が期待通りなら新しい値に置き換える」原子操作。ロックフリーデータ構造を組む基本パーツです。

cas.cppC++
std::atomic<int> n{0}; int expected = 0; bool ok = n.compare_exchange_strong(expected, 42); // もし n == 0 なら n = 42 にして ok = true // そうでなければ expected に現在値を書き戻して ok = false

4-1. CAS ループで「原子な更新」を合成

cas_max.cppC++
// 最大値への atomic update(ライブラリに無いので自作) void atomic_max(std::atomic<int>& a, int v) { int old = a.load(); while (v > old && !a.compare_exchange_weak(old, v)) { // 失敗時 old に最新値が入ってループ再試行 } } // ロックを使わず複雑な更新を合成できる = lock-free パターン

weak と strong の違い: weak は偽失敗(値が一致してても失敗する)を許容する代わりに速い。ループの中では weak、単発なら strong。

5. memory_order をざっくり

atomic 操作にはメモリ順序という引数があります。CPU は速度のため命令を並べ替える(out-of-order execution)ことがあり、スレッド間で見える順序をどこまで保証するかを指定します。

memory_order意味使いどころ
seq_cst(既定)最強保証。全スレッドが同じ順序で見る迷ったらこれ
acquire / releaseペアで使う。リリース後の書き込みがアクワイア以降に見えるフラグ同期
relaxed原子性は保証、順序は気にしない単純カウンタ(統計値など)
relaxed.cppC++
std::atomic<long> hits{0}; void onReq() { // 統計カウンタだけ。他の変数との順序は不問 hits.fetch_add(1, std::memory_order_relaxed); }
acq_rel.cppC++
std::atomic<bool> ready{false}; int data; // プロデューサ data = compute(); ready.store(true, std::memory_order_release); // コンシューマ while (!ready.load(std::memory_order_acquire)) {} use(data); // ← data の書き込みが見える
結論: 性能にシビアでない限り既定の seq_cst で書く。最適化したい箇所だけ緩める。memory_order の誤用は再現性の低いバグに直結するので、緩めるなら Thread Sanitizer で検証しましょう。

6. 実務の注意

  • 1 変数の単純更新なら atomic、複数変数や条件付き更新なら mutex。この判断軸で普段は十分。
  • atomic 同士の組み合わせは原子にならないa.load(); b.store(a.load()+1); は 3 つの原子操作で、間に割り込まれる。
  • false sharing に注意。別々の atomic が同じキャッシュラインに乗ると互いを遅らせる。alignas(64) で分離。
  • atomic<shared_ptr>(C++20)も用意されている。スレッドセーフな所有権更新に。
  • ロックフリーは難しい。自作 lock-free キューは専門家の仕事。普段は concurrent_queue ライブラリを使おう。
STEP 12 のまとめ: 並行処理は「thread で起動 → mutex/atomic で共有データ保護 → async/future で結果受け取り」の 3 点セット。さらに jthread(C++20)や coroutine(C++20)で書き味は改善中。この基礎を押さえれば現実のマルチコア性能を引き出せます。

7. 理解度チェック

4 問。

Q1. 次のうち atomic で安全にできる操作は?

std::atomic<int> n; n.fetch_add(1);
std::atomic<int> n; n = n + 1;
std::atomic<std::string> s;(中身を書き換える操作)
fetch_add は 1 命令で原子。n = n + 1 は load→add→store の 3 ステップで途中割り込まれうる。string は trivially copyable ではないので atomic に入れられない。

Q2. 単純なカウンタを守るのに最適なのは?

std::mutex + lock_guard
std::atomic<int>
volatile int
1 変数なら atomic が最速。mutex も動くが重い。volatile は最適化抑制のみで同期には使えない。

Q3. compare_exchange_weak が偽失敗することがあるのはなぜ?

一部 CPU の LL/SC 命令が内部的にスプリアスフェイルするため、許容してループで再試行させる方が全体として速い
C++ 標準のバグ
実装手抜きのため
ARM などの LL/SC は割り込みやキャッシュラインの追い出しで失敗しうる。strong はそれを包み隠すループを内包するため微妙に重い。CAS ループの中では weak が標準。

Q4. memory_order の既定値(seq_cst)の特徴は?

最も緩い保証で最も高速
最も強い保証。全スレッドが同じ順序で観測でき、迷ったらこれを使えばよい
指定しないとコンパイルエラーになる
seq_cst は全順序を保証する最強モード。代わりに最も遅い。性能問題がない限りこれで書くのがモダン C++ のベストプラクティス。