C++のatomicの使い方について

AI実装検定のご案内

C++のstd::atomicは、複数のスレッドから同じ変数へアクセスする場合に、データ競合を防ぎながら安全に読み書きや更新を行うための仕組みです。

atomicには「不可分」という意味があり、アトミック操作は他のスレッドから途中の状態を観測されない操作として扱われます。

たとえば、通常の整数型に対して次の処理を行うとします。

counter++;

この処理は概念的には、

counterの値を読み込む
↓
1を加算する
↓
counterへ書き戻す

という処理を含みます。

複数のスレッドが通常のintに対して同時にこの処理を実行すると、データ競合が発生して未定義動作になる可能性があります。

一方、std::atomic<int>を使用すると、インクリメントをアトミックな操作として実行できます。

目次

atomicの基本的な使い方

atomicを使用するためのヘッダー

std::atomicを使用する場合は、<atomic>ヘッダーをインクルードします。

#include <atomic>

基本的な宣言方法は次の通りです。

std::atomic<int> counter{0};

一般形では、

std::atomic<T> 変数名;

と記述します。

ただし、std::atomic<T>には型に関する制約があり、任意のクラス型を無条件に指定できるわけではありません。

intbool、ポインタなどは代表的な利用例です。

std::atomic<int> count{0};
std::atomic<bool> ready{false};

atomicを使ったカウンター

複数スレッドから安全に加算する

std::atomicの代表的な用途が、複数スレッドで共有するカウンターです。

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

std::atomic<int> counter{0};

void increment()
{
    for (int i = 0; i < 100000; ++i) {
        ++counter;
    }
}

int main()
{
    std::vector<std::thread> threads;

    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(increment);
    }

    for (auto& thread : threads) {
        thread.join();
    }

    std::cout << counter.load() << '\n';
}

4つのスレッドがそれぞれ10万回加算するため、最終的な値は400000になります。

counterを通常のintとして複数スレッドから同時に更新するとデータ競合になりますが、std::atomic<int>であれば安全に更新できます。

load()で値を読み込む

atomic変数の現在値を取得する

load()は、アトミック変数の現在値を読み込むための関数です。

std::atomic<int> value{10};

int result = value.load();

この場合、resultには10が格納されます。

std::atomic<T>では、次のような暗黙的な読み込みも可能です。

int result = value;

ただし、並行処理のコードでは、

value.load()

と明示した方がアトミック読み込みであることが分かりやすく、メモリオーダーも指定できるためおすすめです。

store()で値を書き込む

atomic変数へ新しい値を設定する

値を書き込む場合はstore()を使用します。

std::atomic<int> value{0};

value.store(100);

その後、

std::cout << value.load();

とすると、

100

と出力されます。

store()による書き込みもアトミックに行われます。

exchange()で値を入れ替える

更新前の値を取得しながら変更する

exchange()は、現在値を新しい値へ変更しながら、変更前の値を返します。

#include <atomic>
#include <iostream>

int main()
{
    std::atomic<int> value{10};

    int oldValue = value.exchange(20);

    std::cout << oldValue << '\n';
    std::cout << value.load() << '\n';
}

出力結果は次の通りです。

10
20

つまり、

value.exchange(20);

では、旧値の取得と新しい値への更新が1つのアトミックなread-modify-write操作として行われます。

fetch_add()で加算する

更新前の値を取得しながら加算する

fetch_add()は、現在値へ指定した値を加算する関数です。

std::atomic<int> count{10};

int oldValue = count.fetch_add(5);

処理後は、

oldValue = 10
count = 15

となります。

重要なのは、fetch_add()が返すのは加算後ではなく、加算前の値である点です。

std::cout << oldValue;     // 10
std::cout << count.load(); // 15

アクセス数や処理件数などを数えるカウンターでよく使用されます。

fetch_sub()で減算する

更新前の値を取得しながら減算する

fetch_sub()は、現在値から指定した値を減算します。

std::atomic<int> count{10};

int oldValue = count.fetch_sub(3);

処理後は、

oldValue = 10
count = 7

となります。

atomicで++や–を使う

インクリメントとデクリメント

整数型などのstd::atomicでは、通常の整数と同じようにインクリメントやデクリメントを利用できます。

std::atomic<int> count{0};

++count;
count++;
--count;
count--;

前置と後置では戻り値が異なります。

std::atomic<int> count{10};

int a = ++count;

この場合は、

count = 11
a = 11

となります。

一方、

std::atomic<int> count{10};

int a = count++;

の場合は、

count = 11
a = 10

となります。

compare_exchangeで条件付き更新を行う

compare_exchange_strong()の使い方

compare_exchange_strong()は、現在値が期待値と一致している場合だけ、新しい値へ変更する関数です。

このような処理は一般にCAS(Compare-And-Swap、Compare-And-Exchange)と呼ばれます。

#include <atomic>
#include <iostream>

int main()
{
    std::atomic<int> value{10};

    int expected = 10;

    bool result =
        value.compare_exchange_strong(expected, 20);

    std::cout << std::boolalpha;
    std::cout << result << '\n';
    std::cout << value.load() << '\n';
}

valueの現在値は10で、expectedも10なので比較に成功します。

結果は、

true
20

となります。

比較に失敗した場合

次の例では、

std::atomic<int> value{10};

int expected = 5;

bool result =
    value.compare_exchange_strong(expected, 20);

現在値10と期待値5が一致しないため、更新は失敗します。

このとき、expectedには実際の現在値が書き込まれます。

result = false
value = 10
expected = 10

この動作は、CASループを実装するときに重要です。

compare_exchange_weak()とstrong()の違い

weakは見せかけの失敗が発生することがある

compare_exchange_weak()は、現在値と期待値が一致していても失敗することがあります。

これをspurious failure、つまり「見せかけの失敗」と呼びます。

std::atomic<int> value{10};

int expected = value.load();

while (!value.compare_exchange_weak(
    expected,
    expected + 1
)) {
}

weakはこのようにループの中で使用されるケースが多くあります。

strongは単発の比較更新に使いやすい

compare_exchange_strong()では、値が一致しているのにspurious failureが発生することはありません。

一般的には、

CASループ
→ compare_exchange_weak()

単発の比較・更新
→ compare_exchange_strong()

という使い分けがよく行われます。

ただし、これは絶対的なルールではなく、処理内容に応じて選択します。

atomicでフラグを管理する

スレッドの終了フラグとして使う

std::atomic<bool>は、スレッド間で状態を通知する用途に向いています。

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<bool> running{true};

void worker()
{
    while (running.load()) {
        // 処理
    }

    std::cout << "終了\n";
}

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

    // 何らかの処理

    running.store(false);

    thread.join();
}

この例では、runningをスレッドの終了フラグとして使用しています。

通常のboolを同期なしに複数スレッドから読み書きするとデータ競合になる可能性がありますが、std::atomic<bool>なら安全にアクセスできます。

ただし、この例のようなループはビジーウェイトになり、CPUを消費する場合があります。

実際のプログラムでは、C++20のwait()std::condition_variableなどが適していることもあります。

atomic_flagとは

シンプルなアトミックフラグ

std::atomic_flagは、非常に単純なアトミックフラグです。

#include <atomic>

std::atomic_flag flag = ATOMIC_FLAG_INIT;

C++20以降では、次のような初期化も利用できます。

std::atomic_flag flag{};

std::atomic_flagには重要な特徴があり、ロックフリーであることが保証されています

一般的なstd::atomic<T>は必ずしもロックフリーではありませんが、std::atomic_flagはロックフリーです。

test_and_set()を使う

代表的な操作がtest_and_set()です。

if (!flag.test_and_set()) {
    // フラグの取得に成功
}

test_and_set()は、

現在値を取得する
↓
値をtrueにする
↓
変更前の値を返す

という処理をアトミックに実行します。

atomic_flagでスピンロックを作る

簡単なスピンロックの例

std::atomic_flagを利用すると、簡易的なスピンロックを実装できます。

#include <atomic>

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

public:
    void lock()
    {
        while (flag.test_and_set(
            std::memory_order_acquire
        )) {
        }
    }

    void unlock()
    {
        flag.clear(
            std::memory_order_release
        );
    }
};

ロックを取得できるまでループし続けるため、待機中もCPUを消費します。

そのため、一般的な排他制御では、まずstd::mutexを検討した方がよいケースが多くあります。

memory_orderとは

atomic操作のメモリ順序を指定する

std::atomicでは、アトミック操作と他のメモリアクセスとの順序関係を、

std::memory_order

で指定できます。

代表的な値は次の通りです。

std::memory_order_relaxed
std::memory_order_consume
std::memory_order_acquire
std::memory_order_release
std::memory_order_acq_rel
std::memory_order_seq_cst

メモリオーダーを指定しない場合、基本的には、

std::memory_order_seq_cst

が使用されます。

memory_order_seq_cst

最も強い基本的なメモリオーダー

memory_order_seq_cstは、sequentially consistentの略です。

seq_cstを指定したアトミック操作について、すべてのスレッドで整合する単一の順序を形成する強いメモリオーダーです。

std::atomic<int> value{0};

value.store(
    10,
    std::memory_order_seq_cst
);

int result =
    value.load(std::memory_order_seq_cst);

デフォルトがseq_cstなので、

value.store(10);

int result = value.load();

と書くこともできます。

メモリオーダーに詳しくない段階では、まずデフォルトのseq_cstを使用する方が安全です。

memory_order_relaxed

原子性だけが必要な場合に使う

memory_order_relaxedでは、対象のアトミック変数に対する操作の原子性は保証されます。

一方で、他の通常のメモリアクセスとの同期については強い順序保証を行いません。

std::atomic<int> counter{0};

counter.fetch_add(
    1,
    std::memory_order_relaxed
);

単純な統計カウンターなど、

値が競合せず正しく加算できればよい

という用途で使われることがあります。

ただし、別のデータの公開やスレッド間同期までrelaxedだけで実現できるとは限りません。

memory_order_acquireとrelease

スレッド間でデータを公開する

acquirereleaseは、あるスレッドで作成したデータを別スレッドへ安全に公開する場合などに使われます。

#include <atomic>
#include <iostream>
#include <thread>

int data = 0;
std::atomic<bool> ready{false};

void producer()
{
    data = 100;

    ready.store(
        true,
        std::memory_order_release
    );
}

void consumer()
{
    while (!ready.load(
        std::memory_order_acquire
    )) {
    }

    std::cout << data << '\n';
}

int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);

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

producer()では、まず、

data = 100;

を実行し、その後、

ready.store(
    true,
    std::memory_order_release
);

を実行しています。

consumer()側のacquireロードが、このreleaseストアによって書き込まれたtrueを観測すると同期関係が成立します。

その結果、producer()側でreleaseより前に行われたdata = 100を、consumer()側から正しく参照できます。

memory_order_acq_rel

acquireとreleaseを組み合わせる

memory_order_acq_relは、

acquire
+
release

の両方の性質を持ちます。

主にread-modify-write操作で使用できます。

counter.fetch_add(
    1,
    std::memory_order_acq_rel
);

ただし、単純なカウンターで常にacq_relが必要というわけではありません。

メモリオーダーは、必要な同期関係に応じて適切に選択することが重要です。

atomicとmutexの違い

単純な共有値ならatomicが向いている

std::atomicstd::mutexは、どちらもマルチスレッド処理で利用されますが、得意な用途が異なります。

項目atomicmutex
主な対象単一の値や単純な状態複数のデータや処理
明示的なロック基本的に不要必要
複数操作の一括保護苦手得意
代表用途カウンター、フラグ、CASコンテナ、複雑な共有状態

たとえば、

std::atomic<int> counter{0};

のような単純なカウンターならstd::atomicが適しています。

一方で、

std::vector<int> values;

に対して、

要素を追加する
↓
条件を確認する
↓
別の要素を削除する

といった複数の処理をまとめて保護したい場合は、std::mutexの方が適しています。

atomicを使っても複数処理がまとめて安全になるわけではない

複数のatomic操作には競合する余地がある

次のコードには注意が必要です。

if (count.load() > 0) {
    count.fetch_sub(1);
}

load()fetch_sub()は、それぞれ単体ではアトミックです。

しかし、

値を確認する
↓
値を減らす

という2つの処理全体が1つのアトミック操作になるわけではありません。

2つの処理の間に別スレッドが値を変更する可能性があります。

したがって、

atomicを使っている
=
複数行の処理全体が自動的に安全になる

という意味ではありません。

必要に応じてCASループやstd::mutexを利用します。

atomicは必ずロックフリーではない

is_lock_free()で確認する

std::atomicを使用しているからといって、必ずロックフリーで実装されるわけではありません。

型やCPUアーキテクチャ、コンパイラ、標準ライブラリの実装などによって異なります。

実行時には、

std::atomic<long long> value{0};

if (value.is_lock_free()) {
    std::cout << "lock-free\n";
} else {
    std::cout << "not lock-free\n";
}

のようにis_lock_free()で確認できます。

is_always_lock_freeを使う

C++17以降では、

std::atomic<T>::is_always_lock_free

も利用できます。

std::cout
    << std::atomic<int>::is_always_lock_free;

その型のアトミック操作が常にロックフリーであればtrueになります。

なお、std::atomic_flagについてはロックフリーであることが保証されています。

atomicはコピーやムーブができない

atomicオブジェクトそのものはコピーできない

std::atomicは、通常の値型のようにコピーすることはできません。

std::atomic<int> a{10};

std::atomic<int> b = a; // エラー

ムーブもできません。

std::atomic<int> c = std::move(a); // エラー

現在値だけを別のatomicへ渡したい場合は、

std::atomic<int> b{a.load()};

のように、値を読み込んでから新しいstd::atomicを初期化します。

atomic_refとは

既存の変数をatomicとして扱う

C++20では、

std::atomic_ref

が利用できます。

std::atomic_refは、既存のオブジェクトを参照し、そのオブジェクトに対してアトミック操作を行う機能です。

#include <atomic>

int value = 0;

std::atomic_ref<int> atomicValue(value);

atomicValue.fetch_add(1);

通常のstd::atomic<int>では、

std::atomic<int> value{0};

のように最初からアトミック型として宣言します。

一方、std::atomic_refは既存の通常オブジェクトを参照します。

atomic_ref使用時の注意点

std::atomic_refにはいくつか重要な制約があります。

参照対象のオブジェクトは、atomic_refより長く生存している必要があります。

また、atomic_refを通して共有アクセスしている対象へ、非アトミックなアクセスを混在させると問題が起こる可能性があります。

さらに、参照対象にはstd::atomic_ref<T>::required_alignmentで要求されるアライメントを満たす必要があります。

そのため、atomic_refは便利ですが、通常のstd::atomicより扱いに注意が必要です。

wait()とnotify_one()・notify_all()

C++20から利用できる待機と通知

C++20以降では、std::atomicに、

wait()
notify_one()
notify_all()

が追加されています。

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> value{0};

void worker()
{
    value.wait(0);

    std::cout << "value changed\n";
}

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

    value.store(1);
    value.notify_one();

    t.join();
}

value.wait(0)は、現在値が0である間待機します。

別スレッドで、

value.store(1);
value.notify_one();

とすると、待機しているスレッドが処理を再開できます。

notifyだけではなく値も変更する

wait(old)は、単に通知を受けたから終了するのではなく、値が指定したoldから変化したことを確認します。

そのため、一般的には、

value.store(1);
value.notify_one();

のように、値の変更と通知を組み合わせて使用します。

従来の単純なビジーウェイトより効率よく待機できる場合があります。

volatileとatomicの違い

volatileはスレッド同期には使えない

std::atomicと混同されやすいのがvolatileです。

volatile int counter = 0;

しかし、volatileを付けても、複数スレッドから安全に読み書きできるようになるわけではありません。

たとえば、

++counter;

を複数スレッドから同時に実行しても、安全なカウンターにはなりません。

スレッド間同期が必要なら、

std::atomic<int>

や、

std::mutex

などを使用します。

C++のvolatileは、一般的なマルチスレッド同期のための機能ではありません。

atomicの代表的な使いどころ

カウンター

アクセス回数や処理回数などを数える用途です。

std::atomic<int> requestCount{0};

requestCount.fetch_add(1);

単純な統計カウンターで、他のデータとの同期が必要ない場合は、

requestCount.fetch_add(
    1,
    std::memory_order_relaxed
);

とする場合もあります。

終了フラグ

別スレッドへ処理終了を通知する用途です。

std::atomic<bool> stop{false};

別スレッドから、

stop.store(true);

とすることで状態を変更できます。

状態管理

複数スレッドから参照・更新される単純な状態値にも利用できます。

std::atomic<int> state{0};

ロックフリーアルゴリズム

compare_exchange_weak()などを利用すると、ロックフリーのデータ構造を実装できます。

compare_exchange_weak()

は、ロックフリースタックやキューなどで利用されます。

ただし、ロックフリーアルゴリズムでは、

メモリオーダー
オブジェクトの寿命
ABA問題
メモリ再利用

なども考慮する必要があります。

非常に難易度が高いため、必要性が明確でない場合はstd::mutexなどを利用した方が安全です。

atomicを使うときの注意点

atomicなら何でもスレッドセーフになるわけではない

std::atomicを使えば、対象となるアトミック操作についてデータ競合を避けられます。

しかし、プログラム全体や複数の処理が自動的にスレッドセーフになるわけではありません。

特に次の点に注意が必要です。

  • 個々のatomic操作が安全でも、複数操作をまとめた処理全体はatomicではない
  • memory_order_relaxedなどを不用意に使うと必要な同期関係を作れないことがある
  • 一般のstd::atomic<T>が必ずロックフリーとは限らない
  • 複雑な共有状態ではstd::mutexの方が安全で分かりやすい場合がある
  • volatilestd::atomicの代わりにはならない
  • std::atomic_refにはライフタイムやアライメントなどの制約がある
  • CASを使ったロックフリー処理は実装難易度が高い

初心者の場合は、まず、

std::atomic<int>
std::atomic<bool>

を単純なカウンターやフラグへ利用するところから始めると理解しやすいでしょう。

メモリオーダーについても、特殊な理由がなければ最初はデフォルトの、

std::memory_order_seq_cst

を利用する方が安全です。

C++のatomicの使い方まとめ

C++のstd::atomicは、複数スレッドから共有される値に対して、安全な読み書きや更新を行うための重要な機能です。

基本的には、

#include <atomic>

std::atomic<int> counter{0};

counter.fetch_add(1);

int value = counter.load();

のように使用します。

代表的な操作には、

load()
store()
exchange()
fetch_add()
fetch_sub()
compare_exchange_weak()
compare_exchange_strong()

などがあります。

また、C++20以降では、

wait()
notify_one()
notify_all()
std::atomic_ref

なども利用できます。

単純なカウンターやフラグにはstd::atomicが便利ですが、複数の変数や複雑な処理をまとめて保護する場合は、std::mutexの方が適していることも少なくありません。

std::atomicを使う際は、「atomicを使えばすべての並行処理が安全になるわけではない」という点を理解し、共有するデータや必要な同期範囲に応じて適切な方法を選ぶことが重要です。

以上、C++のatomicの使い方についてでした。

最後までお読みいただき、ありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次