閉じる

C++ atomicの使い方:競合を防ぐスレッドセーフな実装とメモリオーダーの解説

現代のハイパフォーマンスなC++開発において、マルチスレッドプログラミングは避けて通れない技術要素となっています。

複数のスレッドが同一のメモリ領域に同時にアクセスし、少なくとも一方が書き込みを行う際に発生する「データ競合」は、プログラムの予測不能な挙動やクラッシュを引き起こす最大の要因です。

std::atomicは、ミューテックス(std::mutex)のような重量級の同期機構を使わずに、ハードウェアレベルで提供される原始的な操作を利用してデータ競合を防ぐための強力なテンプレートクラスです。

本記事では、2026年現在の最新のC++標準を踏まえ、std::atomicの基本的な使い方から、パフォーマンスを最大限に引き出すためのメモリオーダーの詳細、そして実務で役立つ高度な実装パターンまでを論理的に解説していきます。

std::atomicの基本概念とデータ競合の防止

マルチスレッド環境において、複数のスレッドが同じ変数に対して同時に読み書きを行うと、未定義動作が発生する可能性があります。

これを「データ競合(Data Race)」と呼び、バグの特定が非常に困難な問題として知られています。

通常、データ競合を防ぐためにはstd::mutexを用いた排他制御が行われますが、ロックの取得と解放には一定のオーバーヘッドが伴います。

そこで、CPUの命令セットレベルで保証される原子的な操作を利用するのがstd::atomicクラスです。

「アトミック(原子的な)」とは、その操作が途中で分割されることなく、一気に完了するか、全く行われないかのどちらかであることを意味します。

まずは、データ競合が発生する典型的な例と、std::atomicによってそれがどのように解決されるかを確認してみましょう。

C++
// データ競合が発生する例
#include <iostream>
#include <thread>
#include <vector>

int g_counter = 0; // 通常の整数型

void increment() {
    for (int i = 0; i < 100000; ++i) {
        g_counter++; // 読み取り、加算、書き戻しの間に他のスレッドが介入する可能性がある
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "Final counter: " << g_counter << std::endl;
    return 0;
}
実行結果
Final counter: 142536 (実行のたびに変化し、期待値の200000にならない)

上記のコードでは、g_counter++という操作が「読み取り」「加算」「書き込み」という複数のステップに分かれているため、スレッド間で干渉が起こります。

次に、std::atomic<int>を使用して、この問題を解決するコードを見てみましょう。

C++
// std::atomicを使用した安全な例
#include <iostream>
#include <thread>
#include <atomic>
#include <vector>

std::atomic<int> g_atomic_counter(0); // アトミックな整数型

void atomic_increment() {
    for (int i = 0; i < 100000; ++i) {
        g_atomic_counter++; // 原子的なインクリメント操作
    }
}

int main() {
    std::thread t1(atomic_increment);
    std::thread t2(atomic_increment);
    t1.join();
    t2.join();
    std::cout << "Final atomic counter: " << g_atomic_counter.load() << std::endl;
    return 0;
}
実行結果
Final atomic counter: 200000

std::atomicを使用することで、インクリメント操作が不可分な操作として実行されるため、常に正しい結果が得られるようになります。

std::atomicで利用可能なデータ型と制約

std::atomicはテンプレートクラスであり、さまざまな型に対して適用することが可能です。

一般的には、intlongboolchar、ポインタ型などが頻繁に利用されます。

また、C++20からは浮動小数点数(floatdouble)に対するアトミック操作も標準でサポートされるようになりました。

ユーザー定義の構造体もstd::atomicに包むことができますが、いくつかの厳格な制約が存在します。

具体的には、その型が「Trivially Copyable(トリビアルにコピー可能)」である必要があります。

これは、ビット単位のコピー(memcpyなど)で正しく複製できる型であることを意味します。

また、特定の型がハードウェア的にロックフリー(ミューテックスを使わずに実装されているか)であるかどうかを確認するためには、is_lock_free()メンバ関数を使用します。

型カテゴリ特徴
整数型std::atomic<int>, std::atomic<uint64_t>加算、減算、ビット演算などの算術操作がサポートされる。
浮動小数点型std::atomic<float>, std::atomic<double>C++20より導入。加算、減算などの原子的な操作が可能。
ポインタ型std::atomic<T*>アドレスの算術演算(ポインタ移動)がサポートされる。
ユーザー定義型std::atomic<MyStruct>構造体が小さく、トリビアルにコピー可能である必要がある。

2026年現在のモダンなCPU環境では、64ビットまでの整数型はほぼ間違いなくロックフリーで動作します。

しかし、サイズの大きな構造体などをアトミック化した場合、内部的にミューテックスが使用される「ロックベース」の実装にフォールバックされる可能性があるため注意が必要です。

パフォーマンスが重視される場面では、static_assert(std::atomic<T>::is_always_lock_free)を用いて、コンパイル時にロックフリーであることを保証するのがベストプラクティスです。

主要なメンバ関数とアトミック操作の種類

std::atomicを使いこなすためには、単なる代入や参照以外の専用メンバ関数を理解する必要があります。

アトミック変数は、通常の変数と同じように = 演算子で値を代入したり、暗黙の型変換で値を取り出すことも可能ですが、明示的なメンバ関数の使用が推奨されます。

loadとstore:基本的な読み書き

アトミック変数から値を読み出すには load() を使い、値を書き込むには store() を使用します。

これらを使用することで、どの操作が同期の対象であるかをコード上で明確に示すことができます。

C++
std::atomic<int> value(10);
int current = value.load(); // 値を読み取る
value.store(20);            // 値を書き込む

exchange:値の入れ替え

exchange() は、新しい値をアトミック変数に書き込むと同時に、その直前の値を返却します。

これは「アトミックな置換」として知られ、フラグの管理などに非常に便利です。

C++
std::atomic<bool> flag(false);
bool previous = flag.exchange(true); // flagをtrueにし、以前の値を返す

compare_exchange(CAS操作)

アトミック操作の中で最も重要かつ強力なのが、Compare-And-Swap (CAS) と呼ばれる compare_exchange_weak および compare_exchange_strong です。

これは、「現在の値が期待通りであれば新しい値を書き込む」という条件付きの更新を行います。

ロックフリーなデータ構造(スタックやキューなど)を実装する際の核となる機能です。

C++
std::atomic<int> target(100);
int expected = 100;
int desired = 200;

// targetがexpectedと同じならtarget = desiredとし、trueを返す
// 異なる場合は、expectedをtargetの現在の値で更新し、falseを返す
bool success = target.compare_exchange_strong(expected, desired);

weak版は、値が等しくてもハードウェアの都合で偽の失敗(Spurious Failure)を返す可能性がありますが、ループ内で使用する場合はstrong版よりも高速に動作することがあります。

一方で、ループを用いない単発のチェックであればstrong版を使用するのが一般的です。

メモリオーダー(Memory Order)による最適化

std::atomicの操作には、第二引数としてstd::memory_orderを指定することができます。

デフォルトでは最も制約の強い std::memory_order_seq_cst(逐次一貫性)が適用されますが、これを適切に緩和することで、CPUのメモリバリアコストを削減し劇的なパフォーマンス向上を図ることが可能です。

メモリオーダーは非常に難解なトピックですが、以下の主要な4つの概念を整理して覚えることが近道です。

1. memory_order_relaxed(緩和順序)

このオーダーは、操作の原子性のみを保証し、他のメモリ操作との順序関係については一切関知しません。

カウンタのインクリメントなど、他の変数との同期が必要ない場合に適しています。

2. memory_order_acquire / memory_order_release(獲得・解放順序)

最も頻繁に使用される重要なオーダーの組み合わせです。

release操作が行われた書き込みを、他のスレッドがacquire操作で読み取ったとき、その書き込みより前に行われたすべてのメモリ操作が、読み取ったスレッドからも見えるようになることが保証されます。

これは、スレッド間の「データの受け渡し」を同期する際に必須となります。

3. memory_order_acq_rel(獲得・解放の同時適用)

読み取り・修正・書き込み(Read-Modify-Write)の操作に対して、acquirereleaseの両方のセマンティクスを同時に適用します。

4. memory_order_seq_cst(逐次一貫性)

すべてのスレッドで操作が同じ順序で見えることを保証する、最も安全で最もコストの高いオーダーです。

深い知識がない場合は、まずこのデフォルト設定を使用することが推奨されます。

メモリオーダー目的パフォーマンス
relaxed単なる数値の増減、統計情報の記録非常に高い(最高)
acquire / releaseデータの同期、スレッド間のメッセージ受け渡し高い
seq_cstデフォルトの安全な動作普通

適切なメモリオーダーの選択は、特にARMアーキテクチャのような「弱一貫性」のCPUにおいて、実行速度に大きな影響を与えます。

しかし、誤ったオーダーの指定は再現の極めて難しいバグ(メモリの可視性に関する問題)を引き起こすため、確信がない限りはデフォルトのseq_cstを使用するのが賢明です。

C++20以降の新機能:atomic waitとnotify

C++20において、std::atomicには非常に強力な通知機能が追加されました。

これまで、アトミック変数の値が変わるまで待機するためには、whileループによるビジースピン(スピンロック)を行うしかありませんでした。

しかし、ビジースピンはCPUリソースを無駄に消費するという欠点があります。

新たに追加された wait()notify_one()notify_all() メンバ関数を使用することで、条件変数(std::condition_variable)のような動作をアトミック変数単体で実現できます。

C++
#include <iostream>
#include <thread>
#include <atomic>
#include <chrono>

std::atomic<int> data_ready(0);

void worker() {
    std::cout << "Worker: 処理を開始します..." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 擬似的な負荷
    
    data_ready.store(1); // 状態を更新
    data_ready.notify_one(); // 待機中のスレッドを1つ起こす
    std::cout << "Worker: 完了通知を送りました。" << std::endl;
}

int main() {
    std::thread t(worker);

    std::cout << "Main: 完了を待機中..." << std::endl;
    
    // data_readyが0である間、スレッドをサスペンド(効率的な待機)
    data_ready.wait(0);

    std::cout << "Main: 完了を確認しました! 値: " << data_ready.load() << std::endl;

    t.join();
    return 0;
}
実行結果
Main: 完了を待機中...
Worker: 処理を開始します...
Worker: 完了通知を送りました。
Main: 完了を確認しました! 値: 1

wait() 関数は、指定された値(上記の例では0)と現在の値が等しい間、スレッドの実行を一時停止します。

値が変更され、notify_one() などが呼ばれると、OSのスケジューラを介して効率的にスリープから復帰します。

これにより、低レベルなロックフリープログラミングにおいて、パフォーマンスと電力効率を両立させることが可能になりました。

実践的なスレッドセーフな実装例:スピンロック

std::atomicの具体的な活用例として、軽量な「スピンロック」の実装を紹介します。

スピンロックは、ロックの保持時間が非常に短いことが予想される場合に、スレッドのコンテキストスイッチを避けて高速に同期を行うための仕組みです。

ここでは、std::atomic_flag を使用します。

これは、アトミック操作の中でも最もシンプルで、確実にロックフリーであることが保証されている型です。

C++
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>

class SimpleSpinLock {
private:
    std::atomic_flag flag = ATOMIC_FLAG_INIT;

public:
    void lock() {
        // flagがすでにセットされている間、ループして待機
        // test_and_setは値をtrueにし、以前の値を返す
        while (flag.test_and_set(std::memory_order_acquire)) {
            // CPUにヒントを与える(スピンループの最適化)
            #if defined(__cpp_lib_atomic_wait)
                // C++20以降ならOSレベルの待機も併用可能
            #endif
        }
    }

    void unlock() {
        // flagをクリアする
        flag.clear(std::memory_order_release);
    }
};

SimpleSpinLock spinlock;
int shared_resource = 0;

void safe_increment() {
    for (int i = 0; i < 1000; ++i) {
        spinlock.lock();
        shared_resource++; // クリティカルセクション
        spinlock.unlock();
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(safe_increment);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "Shared Resource: " << shared_resource << std::endl;
    return 0;
}
実行結果
Shared Resource: 10000

このスピンロックの実装では、lock()時に memory_order_acquire を指定し、unlock()時に memory_order_release を指定しています。

これにより、ロックを取得したスレッドが、他のスレッドが行ったクリティカルセクション内の変更を正しく読み取れることが保証されます。

実務では、スピンが長引くとCPUリソースを過剰に消費するため、一定回数試行した後にstd::this_thread::yield()を呼ぶなどの工夫がなされます。

std::atomic_refによる既存変数のアトミック化

C++20で導入されたもう一つの重要な機能が std::atomic_ref です。

これまでは、ある変数をアトミックに操作するためには、最初から std::atomic<T> として宣言しておく必要がありました。

しかし、巨大な配列の一部や、既存のインターフェースから渡された通常の変数を、特定の期間だけアトミックに扱いたい場合があります。

std::atomic_ref を使用すれば、通常の変数への参照を介して原子的な操作を適用できます。

C++
#include <atomic>
#include <vector>
#include <numeric>
#include <iostream>
#include <thread>

void parallel_sum(std::vector<int>& data, int& result) {
    // result変数をアトミックに参照する
    std::atomic_ref<int> atomic_result(result);
    
    for (auto val : data) {
        atomic_result.fetch_add(val, std::memory_order_relaxed);
    }
}

int main() {
    std::vector<int> data(1000, 1);
    int total_sum = 0;

    // 二つのスレッドで同じtotal_sumを更新する
    std::thread t1(parallel_sum, std::ref(data), std::ref(total_sum));
    std::thread t2(parallel_sum, std::ref(data), std::ref(total_sum));

    t1.join();
    t2.join();

    std::cout << "Total Sum: " << total_sum << std::endl;
    return 0;
}
実行結果
Total Sum: 2000

注意点として、std::atomic_ref を通じて変数にアクセスしている間は、その変数に対して通常の(非アトミックな)アクセスを行ってはいけません。

もし非アトミックなアクセスが混在すると、データ競合が発生し、プログラムの健全性が失われます。

この機能は、大規模な数値計算や、並列アルゴリズムの最適化において非常に重宝されます。

パフォーマンスと偽共有(False Sharing)への配慮

std::atomicを使えばスレッドセーフにはなりますが、必ずしも高速になるとは限りません。

マルチコアプロセッサにおいては、キャッシュラインの仕組みを理解しておく必要があります。

複数のスレッドが、同じキャッシュライン(通常64バイト)上に存在する別々のアトミック変数に対して頻繁に書き込みを行うと、「偽共有(False Sharing)」が発生します。

偽共有が発生すると、CPUコア間でキャッシュの一貫性を保つための通信(コヒーレンス・トラフィック)が激増し、パフォーマンスが著しく低下します。

これを防ぐためには、C++17で導入された std::hardware_destructive_interference_size を利用して、アトミック変数の間に適切なパディングを入れることが有効です。

C++
#include <new>
#include <atomic>

struct KeepApart {
    // キャッシュラインのサイズに合わせて配置を調整し、偽共有を防ぐ
    alignas(std::hardware_destructive_interference_size) std::atomic<int> cat;
    alignas(std::hardware_destructive_interference_size) std::atomic<int> dog;
};

このように設計することで、それぞれの変数が異なるキャッシュラインに配置されることが保証され、マルチコア環境での並列性能が最大化されます。

2026年のハイコア環境においては、このようなミクロな最適化がアプリケーション全体ののスループットを左右する重要な鍵となります。

スマートポインタのアトミック操作(C++20/C++26)

かつて、std::shared_ptrを複数のスレッドから安全に操作するのは非常に困難でした。

std::shared_ptr自体の参照カウント操作はアトミックですが、ポインタの差し替え自体はスレッドセーフではないからです。

C++20からは、std::atomic<std::shared_ptr<T>> および std::atomic<std::weak_ptr<T>> が正式に導入されました。

これにより、共有オブジェクトの所有権をスレッド間で安全かつ直感的に受け渡すことができるようになっています。

C++
#include <memory>
#include <atomic>
#include <iostream>
#include <thread>

struct Config {
    int value;
};

std::atomic<std::shared_ptr<Config>> global_config;

void update_config() {
    auto new_config = std::make_shared<Config>(Config{42});
    global_config.store(new_config); // 原子的にポインタを差し替え
}

void read_config() {
    std::shared_ptr<Config> local_ptr = global_config.load(); // 原子的にポインタを取得
    if (local_ptr) {
        std::cout << "Config Value: " << local_ptr->value << std::endl;
    }
}

以前は std::atomic_load などのフリー関数を使用する必要がありましたが、クラス型として定義されたことで、より安全で型安全な記述が可能になりました。

2026年のシステム開発では、この std::atomic<shared_ptr> を活用して、読み取り頻度が高い設定情報( Read-Mostly Data)の動的更新をロックフリーに実装するのが定石となっています。

まとめ

C++におけるstd::atomicは、単なる「同期のための道具」を超え、現代のマルチコアプロセッサの性能を極限まで引き出すための基盤技術です。

本記事で解説した loadstorecompare_exchange といった基本操作から、memory_order による高度な最適化、そして C++20 以降の wait/notifyatomic_ref といった新機能を適切に組み合わせることで、堅牢かつ高速なスレッドセーフ・プログラムが実現できます。

最後に、std::atomicを使用する際の要点を整理します。

  • データ競合を避ける最も軽量な手段として、まずは std::atomic の利用を検討すること。
  • 複雑な条件分岐が必要な更新には compare_exchange_strong を活用すること。
  • パフォーマンスが極めて重要な場面以外では、デフォルトのメモリオーダー(seq_cst)を使用し、安全性を優先すること。
  • スレッド間の効率的な待機には C++20 の wait() / notify() を利用すること。
  • 偽共有などのハードウェア特性に配慮し、適切なメモリ配置を行うこと。

アトミック操作は一見シンプルですが、その背後には深いメモリモデルの理論が存在します。

まずは基本を確実に押さえ、実際のコードでその挙動を確認しながら、一歩ずつロックフリープログラミングの世界を習得していってください。

URLをコピーしました!