Zenn
⚛️

Rustの並行処理入門:アトミック徹底解説

に公開

表紙

原子型とアトミック操作

アトム(atom)とは、CPU のコンテキストスイッチによって中断されない一連の機械命令のことです。これらの命令が組み合わさることでアトミック操作が形成されます。マルチコア CPU では、ある CPU コアがアトミック操作を開始する際に、他の CPU コアによるメモリアクセスを一時的に停止させ、アトミック操作が他の CPU コアに干渉されないようにします。

アトミック操作(atomic operation)とは、分割不可かつ中断不可の一つまたは複数の操作を指します。並行プログラミングにおいて、CPU レベルでこれらの操作が一体として扱われることを保証する必要があります。アトミック操作は、単一のステップである場合も、複数のステップを含む場合もありますが、それらの順序は変更されることなく、実行中に他の仕組みによって中断されることもありません。

注:アトミック操作は命令によってサポートされるため、ロックやメッセージ伝達に比べてパフォーマンスが大きく向上します。ロックと比べて、アトミック型は開発者がロックの取得や解放といった処理を行う必要がなく、変更や読み取りなどの操作をサポートしつつ高い並行性能を持ちます。ほとんどのプログラミング言語がアトミック型をサポートしています。

アトミック型は、開発者がアトミック操作をより簡単に実現するためのデータ型です。アトミック型はロックを使用しないノンロック型ですが、ノンロックであることが「待機不要」を意味するわけではありません。内部では CAS ループ(Compare and Swap)が使用されているため、競合が多発する状況では待機が発生します!とはいえ、ロックよりは効率的です。

注:CAS とは「Compare and Swap(比較して交換)」の略で、特定のメモリアドレスを読み取り、値が事前に指定された値と等しいかどうかを判断し、等しければその値を新しい値に更新する操作です。

アトミック操作は並行処理のプリミティブ(基本原語)として、すべての並行処理プリミティブを実現する基盤です。ほとんどすべての言語でアトミック型とアトミック操作がサポートされています。たとえば、Java の java.util.concurrent.atomic パッケージには多くのアトミック型が用意されており、Go 言語では sync/atomic パッケージがアトミック操作をサポートしています。Rust も例外ではありません。

注:アトミック操作は CPU の概念ですが、プログラミング言語においても同様の概念があり、それは「並行プリミティブ(concurrency primitive)」と呼ばれます。並行プリミティブとは、カーネルがユーザー空間へ提供する関数であり、実行中に中断されることはありません。

Rust における Atomic 並行プリミティブ

Rust におけるアトミック型は、std::sync::atomic モジュールに含まれています。

このモジュールのドキュメントでは、アトミック型について次のように説明されています:Rust のアトミック型はスレッド間での低レベルな共有メモリ通信を提供し、他のすべての並行型の構築基盤となるものです。

std::sync::atomic モジュールでは、現在以下の 12 種類のアトミック型が提供されています:

AtomicBool
AtomicI8
AtomicI16
AtomicI32
AtomicI64
AtomicIsize
AtomicPtr
AtomicU8
AtomicU16
AtomicU32
AtomicU64
AtomicUsize

アトミック型は、通常の型と基本的には大きな違いはありません。例えば AtomicBoolbool は、前者がマルチスレッド環境で使用されるのに対して、後者は主にシングルスレッド環境で使用されるという違いがあります。

例として AtomicI32 を見てみましょう。これは構造体として定義されており、以下のようなアトミック操作に関連するメソッドを持っています:

pub fn fetch_add(&self, val: i32, order: Ordering) -> i32 - アトミック型に加算(または減算)を行う
pub fn compare_and_swap(&self, current: i32, new: i32, order: Ordering) -> i32 - CASRust 1.50で非推奨、compare_exchange に置き換え)
pub fn compare_exchange(&self, current: i32, new: i32, success: Ordering, failure: Ordering) -> Result<i32, i32> - CAS
pub fn load(&self, order: Ordering) -> i32 - アトミック型から値を読み取る
pub fn store(&self, val: i32, order: Ordering) - アトミック型に値を書き込む
pub fn swap(&self, val: i32, order: Ordering) -> i32 - 値を交換する

各メソッドには Ordering 型の引数があります。Ordering は列挙型で、この操作のメモリバリアの強度を表しており、アトミック操作におけるメモリ順序(Memory Ordering)を制御します。

注:メモリ順序とは、CPU がメモリへアクセスする順序のことを指します。この順序は次のような要因の影響を受ける可能性があります:

  • コード内での記述順
  • コンパイラの最適化による順序変更(メモリのリオーダリング)
  • 実行時における CPU のキャッシュ機構による順序の乱れ
pub enum Ordering {
    Relaxed,
    Release,
    Acquire,
    AcqRel,
    SeqCst,
}

Rust における Ordering 列挙型の各値の意味は以下のとおりです:

  • Relaxed:最も緩い規則で、コンパイラや CPU に対して一切の制限を課さず、順序が無視される可能性があります。
  • Release(解放):メモリバリアを設定し、それ以前の操作が必ずこの操作より前に完了することを保証します。ただし、その後の操作はこの操作より前に移動する可能性があります(主に書き込みに使用)。
  • Acquire(取得):メモリバリアを設定し、この操作以降のすべてのアクセスが必ず後に実行されるようにします。ただし、以前の操作がこの後に移動する可能性があります。通常は他スレッドの Release と組み合わせて使用します(主に読み取りに使用)。
  • AcqRelAcquireRelease の両方の保証を提供します。fetch_add などに使われます。読み込みには Acquire、書き込みには Release の意味を持ち、前後の読み書き操作が再順序化されないようにします。
  • SeqCst(順序一貫性):AcqRel の強化版で、アトミック操作の前のすべてのデータ操作が後ろに移動されず、後ろの操作も前に移動されません。また、すべてのスレッドが SeqCst 操作の順序を同一に観測することを保証します(パフォーマンスは低いが最も安全です)。

この Ordering 列挙型の引数によって、開発者はメモリ順序をカスタマイズできます。

注:「メモリ順序(Memory Ordering)」とは何かについて、Wikipedia からの定義を抜粋します:
Memory Ordering(メモリの順序)は、CPU が主記憶にアクセスする順序のことです。これはコンパイラによるコンパイル時の生成または CPU の実行時に発生します。メモリ操作の順序変更やアウト・オブ・オーダー実行を反映し、メモリバスの帯域を最大限に活用します。現代のプロセッサの多くはアウト・オブ・オーダー実行を行います。そのため、メモリバリアによってマルチスレッドの同期を保証する必要があります。
メモリ順序を理解するには、2 つのスレッドが AtomicI32 型のデータを操作する状況を考えてみましょう。初期値は 0 で、一方のスレッドが読み取りを行い、もう一方のスレッドが値を 10 に書き込みます。書き込み操作が完了した後に読み取りスレッドが読み込めば、必ず 10 が読み取れるのでしょうか?答えは「不確定」です。コンパイラの実装や CPU の最適化戦略により、書き込みスレッドが書き込みを完了していても、最新の値は CPU のレジスタ内にあり、メモリに反映されていない可能性があります。
このような場合、レジスタからメモリへの同期を保証するには、メモリ順序が必要になります。Release はレジスタの値をメモリに同期する操作と理解できます。Acquire は、現在のレジスタ値を無視して、直接メモリから最新の値を読み取る操作です。例えば、アトミック型の store メソッドで Release を指定し、load メソッドで Acquire を指定すれば、読み取りスレッドがレジスタ内の最新の値を確実に取得できます。

マルチスレッドでの Atomic の使用

アトミック型はすべて Sync トレイトを実装しているため、スレッド間で安全に共有することができます。ただし、それ自体は共有メカニズムを提供していないため、よく使われる方法は、アトミック型を参照カウント付きスマートポインタ Arc に包むことです。以下は、公式ドキュメントに記載されている簡単なスピンロックの例です:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;

fn main() {
    // アトミック型を使ってロックを作成し、参照カウントで共有所有権を取得
    let spinlock = Arc::new(AtomicUsize::new(1));
    // 参照カウント +1
    let spinlock_clone = spinlock.clone();

    let thread = thread::spawn(move || {
        // SeqCst順序:書き込み操作(store)は release セマンティクス:書き込みバリア以前の読み書き操作はバリア以後に再配置されない
        spinlock_clone.store(0, Ordering::SeqCst);
    });

    // while ループを使って、あるクリティカルセクションが利用可能になるのを待つロック
    // SeqCst順序:読み取り操作(load)は acquire セマンティクス:読み取りバリア以後の操作は、バリア以前に再配置されない
    // 上記スレッド内の store(書き込み)命令に対し、以下の load(読み取り)命令はその後の操作が先に実行されないように制御
    while spinlock.load(Ordering::SeqCst) != 0 {}

    if let Err(panic) = thread.join() {
        println!("Thread had an error: {:?}", panic);
    }
}

注:スピンロックとは、あるスレッドがロックを取得しようとしたときに、そのロックがすでに他のスレッドによって取得されていると、スレッドはロックを取得できず、一定の間隔で再試行を続ける仕組みです。スピンロックは CPU を空回し(spin)させてビジーウェイト(busy wait)しながら、クリティカルセクションが利用可能になるのを待つロックの一種です。
スピンロックはスレッドのブロックを減らすことができ、ロックの競合が激しくなく、ロック保持時間が非常に短い場合に適しています。ただし、競合が激しかったり、ロック保持時間が長かったり、保護されるクリティカルセクションが広範囲になると、スピンによる消費がスレッドのブロック・中断処理のコストを上回り、CPU が無駄に使われてしまい、他のスレッドが CPU を獲得できず、システム性能が急激に低下する可能性があります。

上記の例は、スピンロックの機能を実装しており、Ordering::SeqCst を使用しています。次は、スピンロックの実装を自作してみましょう:

use std::sync::{
    atomic::{AtomicBool, Ordering},
    Arc,
};
use std::thread;
use std::time::Duration;

struct SpinLock {
    lock: AtomicBool,
}

impl SpinLock {
    pub fn new() -> Self {
        Self {
            lock: AtomicBool::new(false),
        }
    }

    pub fn lock(&self) {
        while self
            .lock
            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
            .is_err()
        // ロック取得を試みる。失敗したらスピンを続ける
        {
            // CAS のコストは比較的大きいため、ロック取得に失敗した場合は単純な load によりロックの状態を読み取る。
            // ロックが解放されたことを確認してから再度 CAS によるロック取得を試みる。
            while self.lock.load(Ordering::Relaxed) {}
        }
    }

    pub fn unlock(&self) {
        // ロック解放
        self.lock.store(false, Ordering::Release);
    }
}

fn main() {
    let spinlock = Arc::new(SpinLock::new());
    let spinlock1 = spinlock.clone();

    let thread = thread::spawn(move || {
        // サブスレッドでロック取得、内部で compare_exchange を呼び出して状態を変更
        spinlock1.lock();
        thread::sleep(Duration::from_millis(100));
        println!("do something1!");
        // サブスレッドでロック解放
        spinlock1.unlock();
    });

    thread.join().unwrap();

    // メインスレッドでロック取得
    spinlock.lock();
    println!("do something2!");
    // メインスレッドでロック解放
    spinlock.unlock();
}

このスピンロックの実装は、本質的には AtomicBool というアトミック型を使用しており、その初期値は false です。

lock メソッドでロックを取得する際には、CAS(Compare and Swap)の性質を利用しています。compare_exchange が失敗した場合、ロックを取得しようとするスレッドは while ループでスピン状態になります。ここで、パフォーマンスの最適化として、CAS のコストが高いため、失敗した際には簡単な load によりロックの状態を確認し、ロックが解放されたことを検知してから再び CAS によるロック取得を試みるという流れにしています。この方法はより効率的です。

unlock メソッドでは、単に AtomicBoolfalse に設定する store 操作を行っており、Ordering::Release を使用することで、レジスタの値をメモリに同期させます。ロック中のスレッドが while ループでスピンしている場合、この store によって false がメモリに書き込まれ、それを Acquire メモリ順序で読むことで、レジスタの値を無視して最新のメモリ値を取得し、CAS が成功し、ロック取得が完了します。

Atomic はロックの代わりになるか?

それでは、アトミック型が非常に万能であることを踏まえて、「ロックの代わりになるのか?」という疑問が浮かびます。答えは「いいえ」です:

  • 複雑なシナリオにおいては、ロックの使用は単純で分かりやすく、バグを生みにくい
  • std::sync::atomic パッケージで提供されているのは数値型のみであり(AtomicBool, AtomicIsize, AtomicUsize など)、一方ロックはあらゆる型に対応可能
  • 一部のケースではロックとの組み合わせが必須(例:Mutex, RwLock, Condvar など)

Atomic の使用シーン

実際のところ、Atomic はエンドユーザーが直接使用することは少ないかもしれませんが、高性能なライブラリや標準ライブラリの開発者にとっては非常に一般的です。並行処理プリミティブの基礎であり、次のようなシナリオにも適しています:

  • ロックフリー(lock free)データ構造の実装
  • グローバル変数(例:グローバルな自動増分 ID。後の章で紹介)
  • スレッド間のカウンター(例:メトリクス収集など)

以上は Atomic の代表的な適用場面に過ぎません。具体的な使用可否は、今後それぞれのニーズに応じて選択・判断していく必要があります。

まとめ

「アトム(atom)」とは、生物学における「それ以上分解できない粒子」を比喩しており、アトミック操作(atomic operation)とは「中断されることのない一連の操作」を意味します。アトミック型は、開発者がこのようなアトミック操作を容易に実現するためのデータ型です。並行処理プリミティブは、カーネルがユーザ空間に提供する関数であり、実行中に中断されることはありません。

Atomic アトミック型はノンロック型で、内部で CAS ループを使用しており、開発者がロックの取得や解放を意識せずにすみます。また、変更や読み取りなどのアトミック操作をサポートしており、これらの操作は命令レベルでサポートされているため、ロックやメッセージパッシングよりもパフォーマンスが高くなります。

アトミック操作では Ordering(メモリ順序)を指定する必要があり、この Ordering 列挙型を通じて開発者は低レベルなメモリ順序をカスタマイズできます。

Atomic はロックに比べて高いパフォーマンスを持つため、Rust においてはグローバル変数やスレッド間の共有変数など、多くのシーンで使用されています。ただし、完全にロックの代替とはならず、ロックの方がシンプルであるため適している場面も多くあります。

アトミック操作は、主に以下の 5 種類に分類できます:

  • fetch_add:加算(または減算)操作
  • compare_and_swap および compare_exchange:比較し、等しければ交換
  • load:アトミック型から値を読み取る
  • store:アトミック型に値を書き込む
  • swap:値を交換する

私たちはLeapcell、Rustプロジェクトのホスティングの最適解です。

Leapcell

Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:

複数言語サポート

  • Node.js、Python、Go、Rustで開発できます。

無制限のプロジェクトデプロイ

  • 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。

比類のないコスト効率

  • 使用量に応じた支払い、アイドル時間は課金されません。
  • 例: $25で6.94Mリクエスト、平均応答時間60ms。

洗練された開発者体験

  • 直感的なUIで簡単に設定できます。
  • 完全自動化されたCI/CDパイプラインとGitOps統合。
  • 実行可能なインサイトのためのリアルタイムのメトリクスとログ。

簡単なスケーラビリティと高パフォーマンス

  • 高い同時実行性を容易に処理するためのオートスケーリング。
  • ゼロ運用オーバーヘッド — 構築に集中できます。

ドキュメントで詳細を確認!

Try Leapcell

Xでフォローする:@LeapcellHQ


ブログでこの記事を読む

Discussion

ログインするとコメントできます