ミューテックスは単純です。ロックし、共有状態を変更し、ロックを解除します。アクセスを希望するすべてのスレッドは順番を待ちます。問題は「順番待ち」です。コア数が多くスループットが高い場合、この直列化が限界となります。ロックを保持しているスレッドはプリエンプトされる可能性があり、他のすべてのスレッドがアイドル状態になります。優先度逆転により、優先度の低いスレッドが優先度の高いスレッドをブロックする可能性があります。数十個のコアで毎秒数百万の操作を処理するシステムでは、ミューテックスの競合は処理速度を低下させるだけでなく、スループットを著しく低下させます。
ロックフリーのデータ構造は、相互排他をアトミック操作に置き換えます。競合を防止するのではなく、競合を許容し、そこから回復します。比較交換に失敗したスレッドは、更新された状態で再試行します。どのスレッドも他のスレッドをブロックすることはありません。少なくとも1つのスレッドは常に処理を進めます。その結果、競合が激しい状況下でもスループットが劇的に向上し、コンボイ効果のない予測可能なレイテンシ、そして設計上デッドロックと優先順位逆転が排除されます。代償は実装の複雑さです。ABA問題、メモリ解放ハザード、偽共有、微妙なメモリ順序要件など、ロックベースのコードには存在しない落とし穴が存在します。この記事では、具体的なコード例を用いてこれらの問題をすべて解説します。
ロックフリー方式、待機フリー方式、ミューテックス方式:どれを使うべきか?
実装方法を選択する前に、システムが実際に必要とする進捗保証のレベルを明確にすることが重要です。3つのモデルは、スレッドが競合する場合にどのような保証を提供するかという点で異なります。
| モデル | 進捗保証 | 典型的なレイテンシ | 複雑 | 以下のためにベスト |
|---|---|---|---|---|
| ミューテックス/ロックベース | ブロッキング、スレッド待機中 | 争いの中で予測不可能 | ロー | 競合の少ない共有状態、単純な正当性要件 |
| ロックフリー | システム全体で、少なくとも1つのスレッドが進行します | 低い競争の下で | ハイ | 高スループットのキュー、スタック、カウンター |
| 待ち時間なし | スレッドごとに、各スレッドは制限されたステップで終了します。 | 最悪の場合が限定される | すごく高い | リアルタイムシステム、安全性が重視されるシステム、厳格なレイテンシSLA |
| 障害物なし | 単独での進歩は、競争相手がいない場合のみに限る。 | 異議なく低価格 | 技法 | トランザクションメモリのプロトタイプ、研究コンテキスト |
ロックフリーは、高並行処理のプロダクションシステムにおいて事実上のデフォルト設定です。マイケル・スコット・キュー、トレイバー・スタック、そしてほとんどのプロダクション用MPMCリングバッファはロックフリーです。個々のスレッドは極度の競合下ではリソース不足に陥る可能性がありますが、システム全体としては処理が進みます。
待機不要方式はスレッドごとの処理速度の上限を保証するが、アルゴリズムは著しく複雑になる。汎用的な構成は存在するが、オーバーヘッドが大きい。待機不要アルゴリズムは、平均スループットよりもテールレイテンシが重要なハードリアルタイム環境に適している。
障害物のない方式は、実運用環境で直接使用されることは稀です。一部のトランザクションメモリの実装に見られ、アルゴリズムの正当性を証明する際の足がかりとして機能します。
ほとんどの高並行システムでは、競合が少なく、正当性が単純な場合はミューテックスを使用し、競合下でのスループットが重要な場合はロックフリーを使用し、スレッドごとの最悪ケースのレイテンシが厳密な要件である場合にのみ待機フリーを使用します。
ロックフリーデータ構造とは何ですか?
データ構造がロックフリーであるとは、そのデータ構造を操作するすべてのスレッドのうち、少なくとも1つのスレッドが、他のスレッドの動作やスケジューリング方法に関係なく、有限ステップで操作を完了することを保証することを意味します。この正式な定義には、明確な意味合いがあります。それは、ロックフリーのアルゴリズムはデッドロックを起こしないということです。1つのスレッドが停止、中断、または低速で実行されていても、他のスレッドは処理を継続します。
そのメカニズムはアトミック操作、つまり単一の不可分な単位として実行されるCPU命令です。普遍的な基本要素は比較交換(CAS)です。
CAS(location, expected, new_value):
if *location == expected:
*location = new_value
return true
else:
return false // someone else changed it first
CASはハードウェアレベルではアトミックです。x86では、 CMPXCHG 命令。ARMでは、次のように実装されます。 LDXR/STXR (ロード排他/ストア排他)は、LL/SC(ロードリンク/ストア条件付き)のバリアントです。スレッドは値を読み取り、新しい値を計算し、その間に他の誰も値を変更していない場合にのみCASを使用してその値をインストールします。CASが失敗した場合、スレッドは新しい値を使用して再試行します。
C++11以降では、これは以下のように公開されます。 std::atomic:
cpp
#include <atomic>
// Atomic increment using CAS loop
void atomic_add(std::atomic<int>& counter, int delta) {
int expected = counter.load(std::memory_order_relaxed);
while (!counter.compare_exchange_weak(
expected,
expected + delta,
std::memory_order_release,
std::memory_order_relaxed))
{
// expected is updated on failure -- retry with fresh value
}
}
compare_exchange_weak CASプリミティブです。失敗すると更新されます。 expected 現在の値に自動的に戻るため、リトライループが慣用的な形になります。
ABAの問題:ロックフリーの最も危険な落とし穴
ABA問題は、ロックフリープログラミングにおいて最も直感に反する正当性ハザードです。CASは、新しい値をインストールする前に、メモリ位置に期待される値がまだ含まれているかどうかを確認します。しかし、読み出しとCASの間で値が変更され、元に戻されたかどうかを検出することはできません。メモリ位置は元の値のように見えますが、システムの根本的な状態はCASが認識できない形で変化しているのです。
シナリオをステップごとに解説
単一のポインタを使用したロックフリースタックについて考えてみましょう。 top:
- スレッドAには次のように書かれている。
top = Node1Node1の次のポインタはNode2です。 - スレッドAは、ポップ処理を完了する前にプリエンプトされます。
- スレッドBはNode1をポップし(トップがNode2になる)、次にNode2をポップする(トップがnullになる)。
- スレッドBは新しいノードをプッシュしますが、これは偶然にもNode1と同じメモリアドレスに割り当てられます(フリーリストアロケータではよくあることです)。Top = Node1(同じポインタ、異なる内容)。
- スレッドAが再開します。そのCASは
top == Node1(期待値)が成功し、top = Node2しかし、Node2は既に解放されていた。大惨事だ。
ABA問題は、データ構造ごとに異なる形で現れる。
- スタックCASは成功するが、古いnextポインタをインストールし、解放後使用(use-after-free)を引き起こす。
- キュー: ヘッドポインターまたはテールポインターは変化していないように見えるが、構造は変化している
- リンクリストノードがまだその位置にあるように見えますが、実際には削除され、再割り当てされています。
LL/SCはABAの問題を回避できるのか?
はい、LL/SC(ロードリンク/ストア条件付き)はCASよりも強力なセマンティクスを提供し、ABAを自然に回避します。LLはロードされたメモリ アドレスを「リンク済み」としてマークします。同じアドレスに対するSCは、LL以降にそのアドレスへのストア操作が行われた場合、たとえ値が元の状態に戻されたとしても失敗します。変更履歴は現在の値だけでなく、ハードウェア レベルで追跡されます。
しかし、実際のハードウェア上でのLL/SCの実装には実際的な制約があります。ARMおよびPOWERでは、競合が発生していなくても(コンテキストスイッチやキャッシュの強制終了など)、誤ったLL/SC障害が発生する可能性があります。実際の競合が発生していなくても、コードはリトライループを考慮する必要があります。x86にはネイティブのLL/SCはなく、CASがハードウェアプリミティブです。x86コードでは、ABAはソフトウェアで防止する必要があります。
ABAの修正:3つのアプローチ
1. バージョンカウンター(タグ付きポインタ)
ポインタと同じアトミックワードにバージョンカウンタを格納します。CAS操作が成功するたびにカウンタが増加します。ポインタが元の値に戻った場合でも、カウンタの値は異なります。
cpp
#include <atomic>
#include <cstdint>
struct TaggedPtr {
uintptr_t ptr : 48; // pointer (48-bit virtual address space)
uintptr_t tag : 16; // version counter
};
std::atomic<TaggedPtr> top;
bool cas_with_tag(std::atomic<TaggedPtr>& loc,
TaggedPtr expected,
void* new_ptr) {
TaggedPtr desired = { (uintptr_t)new_ptr, expected.tag + 1 };
return loc.compare_exchange_strong(expected, desired);
}
これは実際の運用における標準的なアプローチです。16ビットタグは65,536回の演算後にラップアラウンドしますが、これは理論的には安全ではありませんが、ほとんどのシステムでは実際には十分です。スレッドが同じ場所で正確に65,536回のCASサイクルにわたってプリエンプトされる可能性は極めて低いからです。
2. 危険箇所の指摘
各スレッドは、現在アクセスしているノードへのポインタである「ハザードポインタ」の小さなセットを保持します。スレッドは、ノードを解放する前に、すべてのハザードポインタレジストリをチェックして、他のスレッドがそのノードにアクセスしていないことを確認します。ハザードポインタに現れるノードは、そのポインタがクリアされるまで処理が延期されます。
cpp
// Simplified hazard pointer pattern
thread_local void* hazard_ptr = nullptr;
void* safe_load(std::atomic<void*>& head) {
void* ptr;
do {
ptr = head.load(std::memory_order_acquire);
hazard_ptr = ptr; // announce we are using this
// memory fence ensures announcement is visible before validation
std::atomic_thread_fence(std::memory_order_seq_cst);
} while (ptr != head.load(std::memory_order_acquire)); // validate still valid
return ptr;
}
void safe_release() {
hazard_ptr = nullptr;
}
ハザードポインタは、メモリ解放レベルでのABA(自動ブロックアクセス)を防止します。つまり、いずれかのスレッドがハザードポインタを保持している間は、ノードを再利用できません。そのため、解放前にすべてのスレッドのハザードポインタをスキャンする必要があり、スレッド数に比例したオーバーヘッドが発生します。
3. エポックベースの復元(EBR)
スレッドはグローバルに追跡される「エポック」単位で動作します。リタイアされたノードはエポックごとにバッファリングされ、すべてのスレッドがそのノードがリタイアされたエポックを過ぎて進んだ場合にのみ解放されます。EBRはハザードポインタよりも使いやすく、操作ごとのオーバーヘッドも低いですが、静止期間中のメモリ増加は限定的ではあるものの予測不可能です。
Javaでは、 AtomicStampedReference 参照と整数のスタンプをペアにすることで、ABA問題に直接対処します。
ジャワ
import java.util.concurrent.atomic.AtomicStampedReference;
AtomicStampedReference<Node> top =
new AtomicStampedReference<>(null, 0);
void push(Node newNode) {
int[] stamp = new int[1];
Node current;
do {
current = top.get(stamp);
newNode.next = current;
} while (!top.compareAndSet(current, newNode, stamp[0], stamp[0] + 1));
}
ロックフリーキューの実装
キューは、最も一般的に必要とされるロックフリー構造です。代表的なロックフリーキューはマイケル・スコットキューであり、2つのポインタ(ヘッドとテール)と番兵ノードを使用して、同時的なエンキューおよびデキュー操作を可能にします。
SPSCキュー:最小限の同期で最大限のパフォーマンスを実現
シングルプロデューサー・シングルコンシューマーキューは、書き込み競合と読み取り競合をすべて排除します。1つのスレッドが末尾に書き込み、もう1つのスレッドが先頭から読み取ります。プロデューサーとコンシューマー間で共有されるのは、先頭ポインタのみです。
cpp
#include <atomic>
#include <array>
template<typename T, size_t Capacity>
class SPSCQueue {
std::array<T, Capacity> buffer;
alignas(64) std::atomic<size_t> head{0}; // consumer reads head
alignas(64) std::atomic<size_t> tail{0}; // producer writes tail
// alignas(64) puts each on a separate cache line -- prevents false sharing
public:
bool push(const T& item) {
size_t t = tail.load(std::memory_order_relaxed);
size_t next = (t + 1) % Capacity;
if (next == head.load(std::memory_order_acquire))
return false; // full
buffer[t] = item;
tail.store(next, std::memory_order_release);
return true;
}
bool pop(T& item) {
size_t h = head.load(std::memory_order_relaxed);
if (h == tail.load(std::memory_order_acquire))
return false; // empty
item = buffer[h];
head.store((h + 1) % Capacity, std::memory_order_release);
return true;
}
};
その alignas(64) 先頭と末尾のキャッシュラインの共有は非常に重要です。これがないと、両方のデータが同じキャッシュラインに収まってしまいます。末尾への書き込みのたびに、コアの読み取りヘッドから見えるキャッシュラインの無効化が発生し、逆もまた同様です。これは、本来独立して行われるべき操作を直列化してしまう誤った共有です。
MPMC キュー: マルチプロデューサー、マルチコンシューマー
完全並行型のMPMCキューは、はるかに複雑です。実際の実装では、スロットごとにシーケンス番号を使用して、ロックなしでプロデューサーとコンシューマーを調整します。
cpp
#include <atomic>
#include <array>
template<typename T, size_t Capacity>
class MPMCQueue {
struct Slot {
std::atomic<size_t> sequence;
T data;
};
alignas(64) std::array<Slot, Capacity> slots;
alignas(64) std::atomic<size_t> enqueue_pos{0};
alignas(64) std::atomic<size_t> dequeue_pos{0};
public:
MPMCQueue() {
for (size_t i = 0; i < Capacity; ++i)
slots[i].sequence.store(i, std::memory_order_relaxed);
}
bool push(const T& item) {
size_t pos = enqueue_pos.fetch_add(1, std::memory_order_relaxed);
Slot& slot = slots[pos % Capacity];
size_t seq = slot.sequence.load(std::memory_order_acquire);
// wait for the slot to be ready for writing
while (seq != pos) {
seq = slot.sequence.load(std::memory_order_acquire);
}
slot.data = item;
slot.sequence.store(pos + 1, std::memory_order_release);
return true;
}
bool pop(T& item) {
size_t pos = dequeue_pos.fetch_add(1, std::memory_order_relaxed);
Slot& slot = slots[pos % Capacity];
size_t seq = slot.sequence.load(std::memory_order_acquire);
while (seq != pos + 1) {
seq = slot.sequence.load(std::memory_order_acquire);
}
item = slot.data;
slot.sequence.store(pos + Capacity, std::memory_order_release);
return true;
}
};
.NET ConcurrentQueue: 内部動作について
.NETの ConcurrentQueue<T> .NET 5 以降では、セグメント配列構造が使用されます。各セグメントは、先頭と末尾のインデックスが管理される固定サイズの配列です。 Interlocked 操作(基盤となるハードウェア上のCASにマッピングされる)。セグメントは揮発性を介してリンクされる。 _nextSegment ポインタ。エンキューは現在の末尾セグメントにデータを追加し、セグメントチェーンがいっぱいになると拡張します。デキューは先頭セグメントからデータを読み取り、先頭インデックスをアトミックに進めます。
重要な設計上の決定は、グローバルロックを回避することです。生産者は現在のセグメントのテールインデックスのみで競争します。消費者はヘッドインデックスのみで競争します。生産者が消費者と競合することはありません。これにより、 ConcurrentQueue<T> 生産者・消費者間のパイプラインにおいて非常に効率的:
Cシャープ
using System.Collections.Concurrent;
using System.Threading;
// .NET ConcurrentQueue -- lock-free, thread-safe, FIFO
var queue = new ConcurrentQueue<int>();
// Producer thread
var producer = Task.Run(() => {
for (int i = 0; i < 1_000_000; i++)
queue.Enqueue(i);
});
// Consumer thread
var consumer = Task.Run(() => {
long sum = 0;
while (!queue.IsEmpty || !producer.IsCompleted) {
if (queue.TryDequeue(out int item))
sum += item;
else
Thread.SpinWait(1); // brief spin before retry
}
return sum;
});
await Task.WhenAll(producer, consumer);
ロックフリーコードにおけるキャッシュコヒーレンスと偽共有
キャッシュの一貫性は、ロックフリー実装における目に見えないパフォーマンス変数です。コアがメモリ位置に書き込むと、その位置を含む64バイトのキャッシュライン全体が、他のすべてのコアのキャッシュで無効化されます。そのため、他のコアは読み取り前に更新されたキャッシュラインを取得する必要があります。アトミックな更新が頻繁に行われるロックフリーコードでは、このキャッシュラインの無効化が主要なコストとなる可能性があります。
偽共有とは、2つのスレッドが、たまたま同じキャッシュラインを共有する異なる変数を変更した場合に発生する現象です。どちらのスレッドも同じデータにアクセスしているわけではありませんが、キャッシュコヒーレンスプロトコルはキャッシュライン全体を競合状態にあるものとして扱います。
cpp
// BAD: head and tail on the same cache line
struct BadQueue {
std::atomic<size_t> head; // bytes 0-7
std::atomic<size_t> tail; // bytes 8-15
// both fit in the same 64-byte cache line
// producer writing tail invalidates head in consumer's cache
};
// GOOD: head and tail on separate cache lines
struct GoodQueue {
alignas(64) std::atomic<size_t> head;
alignas(64) std::atomic<size_t> tail;
// each on its own cache line -- no false sharing
};
MESIプロトコル(Modified、Exclusive、Shared、Invalid)は、x86 CPUがキャッシュラインの所有権をどのように調整するかを規定しています。コアが他のコアと共有している場所(Shared状態)に書き込みたい場合、書き込み要求をブロードキャストし、すべての共有者からの確認応答を待ってから、Modified状態に遷移する必要があります。このコヒーレンスプロトコルへの往復には、マルチソケットシステムでは50~300サイクル以上かかります。偽の共有は、独立したスレッドローカル操作をキャッシュコヒーレンストラフィックに変えてしまい、コア数に応じて線形にスケーリングするはずのパフォーマンスを低下させます。
不正な共有の検出: つかいます perf stat -e cache-misses,L1-dcache-load-misses Linux の場合は Linux の CPU パフォーマンス プロファイラ、Windows の場合は Visual Studio の CPU パフォーマンス プロファイラを使用します。キャッシュに収まるマルチスレッド プログラムで L1 キャッシュ ミス率が高い場合は、偽共有が発生している可能性が高いです。
メモリ順序付け:なぜ memory_order_relaxed それだけでは十分ではない
C + + std::atomic これらの操作は、C++11 メモリ モデルに基づくメモリ順序付けの全範囲を公開します。誤った順序付けを選択することは、ABA 問題に次いでロックフリー コードで 2 番目に多いバグです。
cpp
// The five memory orderings and when each applies:
// relaxed: no synchronization, only atomicity
// Use for: counters where only the final value matters
counter.fetch_add(1, std::memory_order_relaxed);
// acquire: reads see all writes by threads that released this location
// Use for: reading shared state protected by a flag
if (ready.load(std::memory_order_acquire)) {
use(data); // guaranteed to see all writes made before ready was set
}
// release: writes are visible to threads that acquire this location
// Use for: publishing shared state
data = compute();
ready.store(true, std::memory_order_release);
// acq_rel: acquire + release in one operation
// Use for: read-modify-write operations like CAS in the middle of a chain
node.compare_exchange_strong(expected, desired,
std::memory_order_acq_rel, // success
std::memory_order_acquire); // failure
// seq_cst: total order across all seq_cst operations, all cores
// Use for: when you need a global consistent view (but slowest)
flag.store(true, std::memory_order_seq_cst);
よくある間違い: relaxed リリースフラグ用。 ready = true relaxed 順序付けは、先行する書き込みが data 他のスレッドから読み取ることができる ready取得/解放のペアは、書き手と読み手を結びつける「先に起こること」の関係性を生み出す。
Treiberスタック:C++におけるロックフリースタック
トレイバースタックは、最も単純な非自明なロックフリーデータ構造です。単一のCASループを使用します。 top ポインター:
cpp
#include <atomic>
template<typename T>
class TreiberStack {
struct Node {
T data;
Node* next;
explicit Node(T d) : data(std::move(d)), next(nullptr) {}
};
std::atomic<Node*> top{nullptr};
public:
void push(T data) {
Node* node = new Node(std::move(data));
node->next = top.load(std::memory_order_relaxed);
while (!top.compare_exchange_weak(
node->next, // expected -- updated on failure
node, // desired
std::memory_order_release,
std::memory_order_relaxed))
{ /* retry */ }
}
bool pop(T& result) {
Node* node = top.load(std::memory_order_acquire);
while (node) {
if (top.compare_exchange_weak(
node,
node->next, // new top
std::memory_order_acquire,
std::memory_order_relaxed))
{
result = std::move(node->data);
// WARNING: cannot free node here safely without hazard pointers or EBR
// delete node; -- ABA hazard if another thread reads node->next
return true;
}
}
return false; // empty
}
};
コメント delete node 重要なのは、単純な削除は前述のABAハザードであるということです。本番環境のTreiberスタックでは、ノードの再利用にはハザードポインタ、エポックベースの再利用、またはガベージコレクションが必要です(Java/C#では自動的に処理されます)。
Javaのロックフリーデータ構造
Javaの標準ライブラリは、本番環境で使用可能なロックフリーの実装を提供します。 java.util.concurrent:
| CLASS | Structure | 進捗 | Notes |
|---|---|---|---|
AtomicReference<T> | 単一の値 | ロックフリー | オブジェクト参照によるCAS |
AtomicStampedReference<T> | 価値+切手 | ロックフリー | バージョンカウンターによるABAの予防 |
ConcurrentLinkedQueue<T> | マイケル・スコットの列 | ロックフリー | FIFO、無制限 |
ConcurrentLinkedDeque<T> | ロックフリーデック | ロックフリー | 両端 |
LongAdder | カウンター | ロックフリー | 縞模様、高スループット計数 |
LongAdder 特に注目すべき点として、すべてのスレッドで競合する単一のアトミックカウンターではなく、スレッドのサブセットによってそれぞれアクセスされるカウンターのストライプ配列が維持されます。競合は1つの場所に集中するのではなく、ストライプ全体に分散されます。合計は遅延的に加算されます。 sum()多数のスレッドにわたる高頻度インクリメント操作の場合、 LongAdder 劇的に優れた性能を発揮する AtomicLong.incrementAndGet():
ジャワ
import java.util.concurrent.atomic.LongAdder;
// BAD for high-concurrency counting: all threads contend on one location
AtomicLong counter = new AtomicLong(0);
counter.incrementAndGet(); // single CAS -- all threads collide
// GOOD for high-concurrency counting: contention distributed across stripes
LongAdder adder = new LongAdder();
adder.increment(); // updates thread-local stripe -- minimal contention
long total = adder.sum(); // sum all stripes lazily
認定条件 SMART TS XL ロックフリーシステム開発をサポート
ロックフリーコードは、正しく実装するのが最も難しいコードの一つです。バグは非決定論的で、特定のインターリーブ構成でのみ発生し、多くの場合、大規模な本番環境でのみ顕在化します。静的解析は、競合状態が発生するのを待つのではなく、実行前にコードの構造的特性を調べることで、この問題を解決します。
SMART TS XL 並行コードの実行パス全体を分析し、アトミック操作が保護対象のメモリ位置とどのように関連しているかを追跡します。ロックフリーコードとレガシーコンポーネント、あるいは多言語アーキテクチャが混在するシステムにおいて、単一言語ツールでは不可能な境界を越えた可視性を提供します。例えば、ロックフリーC++コンポーネントがアクセスする共有メモリ領域が、そこから読み取るJavaサービスとどのように関連しているか、あるいは並行キューがCOBOLベースの処理パイプラインにどのようにフィードされるかなどが把握できます。
静的コード解析機能は、並行処理の危険性に関連するパターンを特定します。例えば、対応する取得セマンティクスを持たないアトミックロード、失敗時に期待値を更新しないCASループ、異なるスレッドからアクセスされるフィールドがアライメントパディングなしで同じ場所に配置されている共有データ構造などです。影響分析機能は、下流のどのコンポーネントが共有並行データ構造に依存しているかを追跡し、構造のインターフェースや再利用戦略の変更を、変更前に適切にスコープ設定できるようにします。
ロックフリーコンポーネントが従来のバッチ処理、メッセージキューイングミドルウェア、最新のサービスレイヤーと共存する必要があるエンタープライズシステムの場合、 SMART TS XLさん 依存関係マッピング 異なる言語で記述されたコンポーネントが、システムスタック全体にわたって並行データパスによってどのように接続されるかを示すアーキテクチャ的な視点を提供する。
ロックフリーが間違った選択となる場合
ロックフリーが常にミューテックスより優れているとは限りません。ロックフリー構造の有効性は、ワークロードの実際の競合プロファイルによって異なります。スレッド数が少ない場合や競合が少ない場合は、適切に実装されたミューテックスの方が高速です。これは、アトミックな再試行ループやコア間でのキャッシュライン無効化のオーバーヘッドを回避できるためです。
ミューテックスは次のような場合に使用します。
- スレッド数が少ない(4~8未満)か、競合がまれである
- クリティカルセクションは長く複雑である(セクションの規模に対して、ロックフリーループのコストが高くなる)。
- 正確さはスループットよりも重要であり、ロックを使用した方がアルゴリズムは単純になる。
- このプラットフォームは効率的なfutexベースのロック機能を備えています(Linux)
pthread_mutex(Windows SRWLOCK)
ロックフリーを使用する場合:
- スレッド数が多く、競合が継続している
- 処理時間は短い(CASループのオーバーヘッドは処理時間に比べて小さい)。
- ブロッキングは許容されません(リアルタイムスレッド、割り込みハンドラ、シグナルハンドラなど)。
- ロックがロック順序の依存関係を引き起こすような、他のロックフリー構造との組み合わせ
待ち時間なしを使用するタイミング:
- 他のスレッドの進行状況に関わらず、すべてのスレッドは定められた時間内に完了しなければならない。
- リアルタイム性が求められる、あるいは安全性が極めて重要な要件があり、リソース不足が許容されない場合
- このアルゴリズムは、スレッドごとにステップ数が制限された状態で完了するように構成できます。
ロックフリープログラミングの本質は、あらゆる場面でロックフリーを採用することではなく、その特性、すなわち非ブロッキングな進行、デッドロックの排除、プリエンプションへの耐性がシステムの実際の要件に合致する箇所で、正確にロックフリーを採用することにある。