Skip to content

✍️ 필사 모드: Lock-Free 並行プログラミング完全ガイド 2025: Atomics, Memory Model, CAS, Lock-Freeデータ構造

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

TL;DR

  • Lock-Freeの本質: ロックなしで並行性を保証。CAS (Compare-And-Swap) などのatomic命令を活用。
  • Memory Model: x86は強い順序保証、ARM/RISC-Vは弱い。Acquire/Release semanticsの理解が必須。
  • 長所: デッドロックなし、優先度逆転なし、状況によりロックより高速。
  • 短所: 非常に難しい、デバッグの悪夢、微細なミスがデータ破損につながる。
  • 使いどころ: カウンタ、シーケンスID、ログバッファなど単純な作業。複雑なデータ構造は検証済みライブラリを使う。

1. Lock-Freeとは

1.1 定義

Lock-Free アルゴリズム: 複数スレッドが同時に作業する際、少なくとも1つのスレッドが常に progress を保証するアルゴリズム。

階層構造:

レベル保証
Wait-Freeすべてのスレッドが有限時間内に完了(最強)
Lock-Free少なくとも1つのスレッドが有限時間内に進展
Obstruction-Free他のスレッドが止まれば進展可能
Blockingスレッドが無限に待ち得る(ロック使用)

1.2 なぜLock-Freeか

ロックの問題点:

  1. デッドロック — 2つのスレッドが互いのロックを待つ。
  2. 優先度逆転 — 低優先度スレッドがロック保持 → 高優先度スレッドが待機。
  3. 競合 — 多数スレッドが同じロックを奪い合い、直列化する。
  4. ブロッキング — ロック保持者がページフォルト、GC、スケジューリングされると他が全て止まる。

Lock-Freeの約束:

  • デッドロック不可能(ロックが存在しない)。
  • システムコール回避(ユーザ空間のみ)。
  • キャッシュフレンドリ(CPU atomic命令)。

1.3 現実的なトレードオフ

Lock-Freeは万能ではない:

  • 実装複雑度: ロックベースの10〜100倍。
  • 検証の難しさ: race conditionが非常に微妙。
  • メモリ回収: 安全なオブジェクト削除が難しい (Hazard Pointers, Epoch)。
  • 競合時の性能: 重い競合下ではロックより遅い場合もある。

助言: 単純な作業にだけLock-Freeを使う。複雑なデータ構造は crossbeam, concurrent_hash_map などの検証済みライブラリに任せる。


2. CPU Memory Model

2.1 なぜMemory Modelが重要か

// Thread 1
x = 1;
y = 2;

// Thread 2
print(y);
print(x);

直感的には print(y)2 なら print(x)1 のはず。しかしCPUとコンパイラが命令を再配置し得るため、常にそうとは限らない。

2.2 x86 vs ARM Memory Model

x86ARMRISC-V
Store-Store 再配置NoYesYes
Load-Load 再配置NoYesYes
Load-Store 再配置NoYesYes
Store-Load 再配置YesYesYes
モデルTSO (Total Store Order)WeakWeak

x86で正常に動いていたコードがARMで失敗することがある。iPhone, M1/M2 Mac, AWS GravitonなどARM時代にはARM Memory Modelの理解が必須。

2.3 Memory Ordering

C++ / Rust の std::memory_order:

#include <atomic>

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

// 最強 (Sequential Consistency, デフォルト)
x.store(1, std::memory_order_seq_cst);

// Release: このstore以前のすべてのメモリ作業が完了してから可視化
x.store(1, std::memory_order_release);

// Acquire: このload以降のすべてのメモリ作業はこのload以降に実行
int v = x.load(std::memory_order_acquire);

// Relaxed: atomic性のみ保証、順序なし (最速)
x.store(1, std::memory_order_relaxed);

Release-Acquire パターン:

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

// Thread 1 (Producer)
data = 42;
ready.store(true, std::memory_order_release);

// Thread 2 (Consumer)
while (!ready.load(std::memory_order_acquire)) {}
assert(data == 42);  // 保証される

release 以前のすべての書き込みは acquire 以降に見える。


3. CAS — Compare-And-Swap

3.1 原理

bool compare_and_swap(int* ptr, int expected, int new_value) {
    if (*ptr == expected) {
        *ptr = new_value;
        return true;
    }
    return false;
}

全体が atomic に実行される。cmpxchg (x86) または ldrex/strex (ARM) が直接サポート。

3.2 CAS ベースのカウンタ

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

void increment() {
    int old_value, new_value;
    do {
        old_value = counter.load();
        new_value = old_value + 1;
    } while (!counter.compare_exchange_weak(old_value, new_value));
}

なぜ weak?: 一部アーキテクチャ(ARM)ではspurious failureがあり得る。ループ内では weak の方が速い。

3.3 std::atomic の fetch_add

上記コードは標準ライブラリでさらに簡潔になる:

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

内部的にLOCK XADD (x86) または LDADDX (ARM v8.1+) を使用。

3.4 Rust の Atomics

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

let counter = AtomicUsize::new(0);

// atomic increment
counter.fetch_add(1, Ordering::Relaxed);

// CAS
let result = counter.compare_exchange(
    0,                     // expected
    1,                     // new
    Ordering::Acquire,     // success ordering
    Ordering::Relaxed      // failure ordering
);

Rustはコンパイル時にデータ競合を検出 — atomicsの誤用はコンパイルエラーになる。


4. Lock-Freeデータ構造

4.1 Treiber Stack — 最もシンプルなLock-Free

template<typename T>
class LockFreeStack {
    struct Node {
        T value;
        Node* next;
    };
    std::atomic<Node*> head{nullptr};

public:
    void push(T value) {
        Node* new_node = new Node{value, nullptr};
        Node* old_head;
        do {
            old_head = head.load();
            new_node->next = old_head;
        } while (!head.compare_exchange_weak(old_head, new_node));
    }

    bool pop(T& value) {
        Node* old_head;
        Node* new_head;
        do {
            old_head = head.load();
            if (!old_head) return false;
            new_head = old_head->next;
        } while (!head.compare_exchange_weak(old_head, new_head));
        value = old_head->value;
        delete old_head;  // 危険! 他スレッドが読んでいる可能性
        return true;
    }
};

delete old_head は安全ではない — 他スレッドが同じノードを読んでいる可能性がある。

4.2 ABA problem

初期: head -> A -> B -> C

Thread 1:                    Thread 2:
old_head = A
new_head = B
                              pop A -> delete A
                              pop B -> delete B
                              push A (再割り当てされたメモリ)
                              head -> A -> C

CAS(old_head=A, new_head=B)  <- 成功! しかし誤り
head -> B (すでに free 済みメモリ)

解決策1: Tagged Pointers (バージョン番号を付加)

struct TaggedPtr {
    Node* ptr;
    uint64_t tag;  // 毎回インクリメント
};
std::atomic<TaggedPtr> head;

CASは (ptr, tag) ペアで比較 — 同じptrでもtagが異なれば失敗。

解決策2: Hazard Pointers

各スレッドが「今使っているポインタ」を announce する。他スレッドはそのポインタを free しない。

解決策3: Epoch-Based Reclamation

グローバル epoch カウンタ。全スレッドが同じ epoch を通過するまで free を遅延。Rustの crossbeam-epoch が標準実装。

4.3 Michael-Scott Queue

最も有名な Lock-Free キュー。

struct Node {
    T value;
    std::atomic<Node*> next{nullptr};
};

std::atomic<Node*> head;
std::atomic<Node*> tail;

void enqueue(T value) {
    Node* new_node = new Node{value};
    Node* old_tail;
    Node* old_next;
    while (true) {
        old_tail = tail.load();
        old_next = old_tail->next.load();
        if (old_tail == tail.load()) {  // 一貫性チェック
            if (old_next == nullptr) {
                // CAS で next 設定を試みる
                if (old_tail->next.compare_exchange_weak(old_next, new_node)) {
                    break;
                }
            } else {
                // tail が遅れている、helping
                tail.compare_exchange_weak(old_tail, old_next);
            }
        }
    }
    // tail を更新 (失敗しても OK、他スレッドが helping してくれる)
    tail.compare_exchange_weak(old_tail, new_node);
}

key trick — Helping: tailが遅れていれば他スレッドが自分の作業前に助ける。これがLock-Freeの本質 — 1スレッドが止まっても他が進展可能。


5. RCU — Read-Copy-Update

5.1 Linux カーネルの秘密兵器

RCUは読みをロックなしにする手法:

  1. Read: ロックなし、ポインタを読むだけ。
  2. Update: データをコピー → 修正 → atomicにポインタ交換。
  3. Reclaim: 既存の reader が全て終わるのを待ってから古いデータを free。
// 擬似コード
struct config* config = rcu_dereference(global_config);
int value = config->some_value;  // ロックなしで読む

// 更新
struct config* new_config = malloc(sizeof(*new_config));
memcpy(new_config, old_config, sizeof(*new_config));
new_config->some_value = 42;
rcu_assign_pointer(global_config, new_config);
synchronize_rcu();  // 全 reader 完了待ち
free(old_config);

なぜ速いか? 読みにatomic命令さえない — 普通のメモリ読み込み。キャッシュフレンドリ。

使いどころ: 読みが書きより1000:1以上多い時。Linuxカーネルのルーティングテーブル、設定など。

5.2 ユーザ空間 RCU

  • liburcu (userspace RCU ライブラリ)
  • crossbeam-epoch (Rust)
  • folly::rcu (Facebook C++)

6. 言語別比較

6.1 Java

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

// 単純カウンタ (低競合)
AtomicLong counter = new AtomicLong(0);
counter.incrementAndGet();

// 高競合カウンタ (Java 8+)
LongAdder adder = new LongAdder();
adder.increment();  // 分割セルで競合分散
long value = adder.sum();  // 集計

LongAdder の秘密: 複数カウンタセルに分散して競合を回避。読みは全セルの合計。カウンタでは LongAdder がほぼ常に高速。

java.util.concurrent.atomic パッケージ:

  • AtomicInteger, AtomicLong, AtomicReference
  • AtomicIntegerArray, AtomicLongArray
  • LongAdder, LongAccumulator (高性能)
  • ConcurrentHashMap (Lock-Free 読み)

6.2 C++

#include <atomic>

std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);

// Memory Orderingを正確に指定する必要がある
std::atomic<bool> flag{false};
flag.store(true, std::memory_order_release);
bool v = flag.load(std::memory_order_acquire);

C++20 追加:

  • std::atomic_ref — 非 atomic 値への atomic 操作。
  • std::atomic_wait/notify — 効率的な spinning の代替。

6.3 Rust

use std::sync::atomic::{AtomicUsize, AtomicBool, Ordering};

static COUNTER: AtomicUsize = AtomicUsize::new(0);
COUNTER.fetch_add(1, Ordering::Relaxed);

Rustの強み: コンパイル時にデータ競合を検出。

crossbeam ライブラリ:

  • crossbeam-channel — Lock-Free MPMC channel
  • crossbeam-epoch — Epoch-based reclamation
  • crossbeam-queue — Lock-Free queue
  • crossbeam-skiplist — 並行 skip list

6.4 Go

import "sync/atomic"

var counter int64
atomic.AddInt64(&counter, 1)
value := atomic.LoadInt64(&counter)

GoのMemory Modelは比較的単純 — 多くの場合は sync.Mutex や channel が推奨。atomics は性能が本当に重要な時だけ。


7. 実戦例

7.1 Lock-Free Logger

class LockFreeLogger {
    static constexpr size_t SIZE = 1024;
    std::atomic<size_t> head{0};
    std::array<std::string, SIZE> buffer;

public:
    void log(const std::string& msg) {
        size_t idx = head.fetch_add(1, std::memory_order_relaxed) % SIZE;
        buffer[idx] = msg;  // 注意: 同時書き込みが起こり得る、実コードではより慎重に
    }
};

簡略化された例。実際は ring buffer とより精巧な同期が必要。

7.2 Sequence ID Generator

use std::sync::atomic::{AtomicU64, Ordering};

struct IdGenerator {
    next_id: AtomicU64,
}

impl IdGenerator {
    fn next(&self) -> u64 {
        self.next_id.fetch_add(1, Ordering::Relaxed)
    }
}

秒間数百万 ID 発行可能 — ロックより 100 倍高速。

7.3 Spin Lock (学習用)

class SpinLock {
    std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acquire)) {
            // busy wait
        }
    }
    void unlock() {
        flag.clear(std::memory_order_release);
    }
};

使いどころ: ロック保持時間が非常に短い時 (数十 ns)。それ以上なら OS mutex が効率的。

改良: x86 の PAUSE 命令で spinning 効率を向上:

while (flag.test_and_set(std::memory_order_acquire)) {
    _mm_pause();  // CPU ヒント
}

8. 性能測定と落とし穴

8.1 false sharing

struct Counters {
    std::atomic<int> a;
    std::atomic<int> b;  // 同じキャッシュライン!
};

ab が同じ 64 byte キャッシュラインにあると、あるコアが a を修正する際に他のコアの b キャッシュが無効化される。性能が 10 倍以上低下し得る。

解決: パディングで分離。

struct Counters {
    alignas(64) std::atomic<int> a;
    alignas(64) std::atomic<int> b;
};

8.2 競合 vs スループット

ベンチマーク (単純カウンタ、4 core):

方式1 スレッド4 スレッド16 スレッド
Mutex30M ops/s8M ops/s2M ops/s
atomic CAS100M ops/s25M ops/s6M ops/s
LongAdder80M ops/s200M ops/s800M ops/s

競合が高い時は単純な atomic にも限界がある。LongAdder のような分割カウンタが圧倒的に速い。

8.3 測定ツール

  • Linux: perf stat -e cache-misses, perf c2c (false sharing 検出)
  • Intel VTune — マイクロアーキテクチャ分析
  • Java: JMH (Java Microbenchmark Harness)
  • Rust: criterion
  • C++: Google Benchmark

9. 検証済みライブラリ

ほぼ常に自作しない。検証済みライブラリを使う:

言語ライブラリ提供内容
RustcrossbeamLock-Free queue, channel, atomic, epoch
RustdashmapLock-Free HashMap
Rustparking_lot高速 mutex (代替)
C++folly (Facebook)各種 Lock-Free データ構造
C++boost::lockfreequeue, stack, spsc_queue
C++concurrencppコルーチン + 並行性
Javaj.u.c.atomic, j.u.c.ConcurrentHashMap標準
JavaJCTools高性能 queue
Gosync/atomic, sync.Map標準

クイズ

1. Lock-Free と Wait-Free の違いは?

Lock-Free は複数スレッドのうち少なくとも1つが常に progress を保証する。他スレッドは starvation の可能性あり。Wait-Free はすべてのスレッドが有限時間内に完了を保証する。Wait-Freeの方が強いが実装は遥かに難しい。実用的なアルゴリズムのほとんどは Lock-Free。

2. ABA problem とは何で、どう解決するか?

値がAからBを経て再びAになった時、CASは現在値が期待値(A)と同じなので成功と判断する。しかし間で変化があり誤った決定となる。解決: (1) Tagged Pointers — バージョン番号を追加、(2) Hazard Pointers — 使用中のポインタを announce、(3) Epoch-Based Reclamation — 安全な時点でのみ free。

3. x86 と ARM の Memory Model の違いは?

x86は Total Store Order (TSO) — Load-Load, Store-Store, Load-Store の再配置は禁止(Store-Loadのみ可能)。ARMは Weak Memory Model — ほぼ全ての再配置が可能。結果として、x86で動作していたコードが ARM で失敗することがある。iPhone, M1/M2 Mac, AWS Graviton 時代には Acquire/Release semantics の正確な理解が必須。

4. False Sharing とは何で、どう防ぐか?

2つのatomic変数が同じ 64 byte キャッシュラインにあると、あるコアが一方を修正した時に他のコアのキャッシュが無効化される。性能が 10 倍以上低下し得る。解決: alignas(64) (C++) または #[repr(align(64))] (Rust) でキャッシュライン境界に整列するかパディングを追加。perf c2c で検出可能。

5. Lock-Free を使うべきでないのはいつか?

(1) 単純なロックで十分な時 — ロック競合が低ければ mutex の方が速いことがある、(2) 複雑なデータ構造 — 自作せず crossbeam, folly など検証済みライブラリを使う、(3) 保守コスト — チームが Memory Model を理解していないと微細なバグの危険、(4) デバッグ可能性 — Lock-Freeバグは再現が難しくデバッグが非常に難しい。単純なカウンタ、シーケンスID、フラグにのみ自作を検討する。


参考資料

현재 단락 (1/332)

- **Lock-Freeの本質**: ロックなしで並行性を保証。CAS (Compare-And-Swap) などのatomic命令を活用。

작성 글자: 0원문 글자: 11,124작성 단락: 0/332