ロックからの解放:atomicによる高速な排他制御入門

スレッド間でデータを安全に共有するための最も一般的な方法はMutexによるロックです。しかし、ロックはスレッドの待機(ブロッキング)を引き起こす可能性があり、パフォーマンス上のボトルネックになることも少なくありません。

この記事では、ロックを使わずにデータの安全性を保証する、より低レベルで高速な仕組みであるアトミック操作と、それを実現するための**std::atomic**について解説します。


目次

アトミック操作とは?

アトミック操作(不可分操作)とは、処理の途中で他のスレッドから割り込まれることが絶対にないと保証された操作のことです。

例えば、「カウンタの値を読み取り、1を足して、書き戻す」という一連の処理がアトミックに実行されると、他のスレッドが中途半端な値を読み取ってしまう心配がありません。

標準ライブラリでは、std::atomic<T>というクラステンプレートが提供されています。atomic<int>atomic<bool>のように、変数宣言をこのテンプレートで囲むことで、その変数への操作(代入、読み取りなど)をアトミックに行うことができます。整数型、浮動小数点数型、ポインタ型など、多くの型がアトミック操作の対象となります。


メモリオーダー:コンパイラとCPUの「勝手な最適化」を制御する

アトミック操作を正しく理解する上で最も重要なのがメモリオーダーです。

パフォーマンスを向上させるため、コンパイラやCPUは、プログラムの動作に影響がないと判断した場合に、コードの命令の順序を入れ替えることがあります。シングルスレッドの環境では問題ありませんが、マルチスレッド環境ではこの「勝手な並べ替え」が致命的なバグの原因となります。

メモリオーダーは、この並べ替えをどこまで許可するかを細かく制御するための指示です。


実践:release-acquireでスレッド間の同期

メモリオーダーの最も代表的な使い方の一つが、生産者(Producer)スレッドと消費者(Consumer)スレッド間の同期です。以下のコードは、一方のスレッドがデータを準備し、もう一方のスレッドがそのデータを安全に読み取る例です。

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

// スレッド間で共有されるデータ
std::string shared_message;
// スレッド間の同期に使われるアトミックなフラグ
std::atomic<bool> data_is_ready{false};

// データを準備し、フラグを立てる「生産者」スレッド
void producer_task() {
    std::cout << "Producer: データを準備中...\n";
    shared_message = "このメッセージは安全に渡されます。";
    
    // releaseストア:この操作より前のすべてのメモリ書き込みが、
    // このストア操作完了より前に完了することを保証する。
    data_is_ready.store(true, std::memory_order_release);
    std::cout << "Producer: データの準備が完了しました。\n";
}

// フラグを監視してデータを読み取る「消費者」スレッド
void consumer_task() {
    std::cout << "Consumer: データ待機中...\n";
    // acquireロード:対応するreleaseストアが行われるまで待機する。
    // この操作より後のすべてのメモリ読み込みが、
    // このロード操作完了より後に実行されることを保証する。
    while (!data_is_ready.load(std::memory_order_acquire)) {
        // データが準備されるまで待機(ビジーループ)
    }
    
    // この時点で、shared_messageが安全に読み取れることが保証されている
    std::cout << "Consumer: データを受信しました: \"" << shared_message << "\"\n";
}

int main() {
    std::thread producer(producer_task);
    std::thread consumer(consumer_task);

    producer.join();
    consumer.join();

    return 0;
}

コードのポイント解説

  • memory_order_release (ストア操作) 生産者側のstoreで使われるこのメモリオーダーは、「私が今からフラグをtrueにするが、それより前に書いたshared_messageへの代入などのすべての書き込みが、このフラグの書き込みより先に行われることを保証せよ」という強い制約を課します。これにより、shared_messageが書き換わる前にdata_is_readytrueになる、という最悪の順序入れ替えを防ぎます。
  • memory_order_acquire (ロード操作) 消費者側のloadで使われるこのメモリオーダーは、「私がこのフラグをtrueとして読み取った後に行うすべての読み込みは、必ずこのフラグの読み取りよりも後に行われることを保証せよ」という制約を課します。

このreleaseacquireがペアになることで、「消費者がtrueを読み取ったならば、生産者がtrueを書き込む前に行ったすべての書き込みが、消費者側から視える」という同期が成立します。これにより、Mutexを使わずにデータの受け渡しが安全に行えるのです。

その他のメモリオーダー

他にもいくつかのメモリオーダーが存在します。

  • memory_order_relaxed: 最も制約が緩く、順序を保証しません。アトミックなカウンタなど、純粋な不可分性のみが必要な場合に使われます。
  • memory_order_seq_cst: 最も制約が強く、すべてのスレッドで処理の順序が同じに見えることを保証します。デフォルトのメモリオーダーであり最も安全ですが、性能的なオーバーヘッドが最も大きくなる可能性があります。

まとめ

std::atomicは、Mutexによるロックのオーバーヘッドを避けたい場合に非常に強力なツールです。特に、単純なフラグやカウンタの同期において、高いパフォーマンスを発揮します。

しかし、その力を正しく引き出すには、メモリオーダーの概念を正確に理解する必要があります。誤ったメモリオーダーは、再現性の低い非常に厄介なバグの原因となります。まずは最も安全なmemory_order_seq_cstから始め、性能測定に基づいて慎重に、より弱いメモリオーダーを検討するのが良いでしょう。

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

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次