ロックフリー・ウェイトフリーとCAS命令
ロックを使わずに並行データ構造を安全に更新する原理が分かれば、デッドロックと無縁の高並行コードが書ける。CASとABA問題、進行保証を内部から解説します。
- 1.ロックフリーの土台はCAS(Compare-And-Swap)。「期待値と一致したら新値に差し替える」をハードウェアが不可分に行い、失敗したら読み直して再試行する。
- 2.進行保証は obstruction-free ⊂ lock-free ⊂ wait-free の階層。lock-freeは「全体として誰かが必ず前進」、wait-freeは「各スレッドが有限ステップで完了」を保証する。
- 3.再試行ループの典型的な罠がABA問題。値がA→B→Aと戻るとCASが誤って成功する。タグ付きポインタやハザードポインタで回避する。
なぜロックを外したいのか
ミューテックスによる排他は正しさを保証しますが、本質的な弱点を抱えます。ロックを保持したスレッドが停止すると、待っている全員が巻き添えで止まることです。ロック保持中にOSがそのスレッドをプリエンプト(横取り)すれば、ロックを待つ他スレッドは保持者が再スケジュールされるまで進めません。これがデッドロック・優先度逆転・コンボイ現象(一台の遅延が後続を数珠つなぎに滞らせる)の温床です。
ロックフリー・プログラミングは、ロックという「排他の所有権」を捨て、代わりにハードウェアの不可分命令で共有状態を直接更新する設計です。あるスレッドが途中で止まっても、他スレッドは独立して前進できる——これが核心の狙いです。ロックの難しさ全般の地図は並行性モデル(CSP・アクター・STM)に整理してあります。
CAS:すべての土台となる不可分命令
ロックフリーの中心にあるのが CAS(Compare-And-Swap) です。CPUが提供する不可分命令で、概念的には次の三引数を取ります。
CAS(address, expected, new):
# 以下が「割り込み不能な1ステップ」として実行される
if *address == expected:
*address = new
return true # 成功:誰にも邪魔されず差し替えた
else:
return false # 失敗:その間に他者が値を変えた
重要なのは、この比較と書き込みの全体が他のコアから見て不可分である点です。x86 では lock cmpxchg、ARM では LDXR/STXR(Load-Exclusive/Store-Exclusive。MIPS等の Load-Linked/Store-Conditional, LL/SC と同じ「読んでから条件付きで書く」方式)が対応します。CASを使う典型は「読んで、計算して、書き戻す」を楽観的に回すループです。
loop:
old = atomic_load(counter) # 現在値を読む
new = old + 1 # 望む新値を計算
if CAS(counter, old, new): # 「読んだときと同じなら」差し替え
break # 成功
# 失敗=間に誰かが更新した → 読み直して再試行
ロックなら「区間を占有して直列化」しますが、CASは占有せず、衝突したら再試行する。この差が進行保証の性質を根本から変えます。
CASが正しく働くには、関与する読み書きがアトミック変数であり、かつメモリ順序が指定されていることが前提です。CPUとコンパイラは性能のため命令を並べ替えるため、ロックフリー構造では他スレッドに見せる順序を acquire/release などのメモリオーダで明示します。誤ったオーダ指定は、単体テストでは再現しない極めて稀なデータ競合として表面化します。
進行保証の階層
「ロックを使わない」と「ロックフリーである」は別物です。進行保証(progress guarantee)は、システム内のスレッドが必ず前進すると言えるかを形式的に分類した階層で、強い順に整理できます。
| 保証 | 意味 | 飢餓(スターベーション) | 代表例 |
|---|---|---|---|
| wait-free | 各スレッドが有限ステップで必ず完了 | 起きない | アトミックなフェッチ加算 |
| lock-free | 全体として最低1スレッドが必ず前進 | 個別には起こり得る | CASループのスタック |
| obstruction-free | 単独実行なら有限ステップで完了 | ライブロックあり | 一部のSTM実装 |
| blocking | 停止したスレッドが全体を止め得る | デッドロックあり | ミューテックス |
- obstruction-free:もし他スレッドが全員止まれば、残った1スレッドは有限ステップで完了できる。ただし複数が干渉し合うと、互いの再試行を無限に誘発するライブロックが起こり得ます。
- lock-free:どこかのスレッドは必ず有限ステップで前進する、をシステム全体として保証します。CASループは「自分のCASが失敗した」=「他の誰かのCASが成功して値を変えた」を意味するため、全員が同時に永久に失敗し続けることはありません。ただし特定の1スレッドが運悪く負け続ける飢餓は理論上あり得ます。
- wait-free:最も強く、個々のスレッドが他者の挙動に依存せず、決まった上限ステップ内で完了します。飢餓が原理的に起きません。
「ロックフリー」と日常的に言うとき、その多くは厳密には lock-free(全体前進保証)です。wait-free は各スレッドの完了上限まで保証する別格で、実装は格段に難しくなります。面接や試験では「lock-free=誰かは必ず進む/wait-free=全員が有限ステップで進む」の差を即答できると強い。素朴なCAS再試行ループは lock-free であって wait-free ではありません(負け続ける可能性が残るため)。
ABA問題:CASの最大の落とし穴
CASは「値が expected と一致するか」だけを見ます。ここに致命的な盲点があります。値が A → B → A と変化して元に戻った場合、CASは「変わっていない」と誤認して成功してしまうのです。これが ABA問題 です。
ポインタを差し替えるロックフリー・スタックを考えます。スレッド1が先頭ノード A を読み、A.next(= B)を新しい先頭にしようと準備したところでプリエンプトされたとします。その間に他スレッドが A をpopし、B もpopし、メモリアロケータが同じアドレスを再利用して新ノードを再び A としてpushしたら——。
# スレッド1の意図:先頭が A なら、先頭を「Aのnext」に差し替える
top = A
next = A.next # この時点の A.next は B
# ← ここで中断。その間に pop A, pop B が起き、A のアドレスが再利用される
CAS(stack.top, A, next) # top はアドレス的に A と一致 → 成功してしまう!
# だが next(=旧B) は既に解放済み。先頭が壊れる
アドレスとしては A に一致するためCASは成功しますが、スレッド1が握っていた next(旧 B)はすでに解放され、リストの整合性が崩壊します。ポインタと参照を直接CASで付け替える設計に特有の罠です。
ABA問題は、アロケータがアドレスを再利用し、かつ絶妙なタイミングでプリエンプトされたときだけ顕在化します。負荷試験でも数百万回に一度しか踏まないことがあり、再現困難なメモリ破壊として現れます。「CASが成功した=対象は不変だった」という直観は誤りで、正しくは「対象のビット列が一致した」に過ぎません。値の同一性と論理的な不変性は別物だと肝に銘じる必要があります。
ABAを封じる:タグとハザードポインタ
ABA問題への対策は、大きく二系統あります。
| 手法 | 原理 | 代償 |
|---|---|---|
| タグ付きポインタ(versioned CAS) | ポインタに更新カウンタを併せ、ポインタ+タグを一括CAS | 二重幅CAS命令が必要・カウンタ巻き戻り |
| ハザードポインタ | 使用中ノードを公示し、公示中は解放を遅延 | 公示テーブルの走査コスト |
| RCU / エポックGC | 読み手を妨げず、誰も参照しなくなってから一括解放 | 回収の遅延・メモリ滞留 |
タグ付きポインタは、ポインタ値に単調増加するバージョン番号を抱き合わせ、両方をまとめてCASします。アドレスが A に戻っても、その間に行われた更新でタグが進んでいるため、(A, version=5) と (A, version=7) は不一致となりCASが正しく失敗します。(ポインタ, タグ) の組をまとめて入れ替えるには、x86 の cmpxchg16b のような**二重幅CAS(DWCAS)**が要ります。
ハザードポインタは、各スレッドが「いま自分が参照中のノード」をグローバルな公示テーブルに掲示する方式です。ノードを解放したいスレッドは、解放前に公示テーブルを走査し、誰かが公示しているなら解放を保留します。これによりABAの根本原因である「使用中アドレスの即時再利用」を断ちます。**RCU(Read-Copy-Update)**やエポックベースの回収も、誰も古い版を見なくなったタイミングまで解放を遅らせる点で同じ思想です。これらは安全な世代管理を「変えないものは共有してよい」というイミュータビリティ(不変性)の発想で支えています。
ロックフリーは万能ではない
ロックフリーは強力ですが、銀の弾丸ではありません。設計判断の勘所を押さえます。
- 高競合下では遅くなり得る:衝突が多いとCAS再試行が空転し、
lock cmpxchgのキャッシュコヒーレンシ通信が増えて、よく書かれたロックに性能で負けることがあります。スループットは競合度に強く依存します。 - 正しさの検証が極端に難しい:メモリ順序とABAとメモリ回収が絡み合い、テストで再現しないバグが残りやすい。実績ある定理証明済みアルゴリズム(Treiberスタック、Michael-Scottキュー等)を踏襲するのが定石です。
- メモリ回収が別問題として残る:GCのない言語では「いつ安全に解放できるか」がハザードポインタ等で別途要り、ここが実装の大半を占めます。
ロックフリー化は、計測でロック競合がボトルネックだと実証してから着手すべきです。多くの場面では、粒度を細かくしたロックや並行性モデルの選び直し(メッセージ受け渡し等)の方が、安全かつ十分高速です。CASを手書きするのは、ホットパスのカウンタやキューなど効果が大きく、検証済みアルゴリズムを使える局面に絞るのが賢明です。
頻出は三点。(1) CASの定義——「期待値と一致したら新値に差し替え、不一致なら失敗する不可分命令」。(2) 進行保証の階層——obstruction-free ⊂ lock-free ⊂ wait-free、lock-freeは全体前進・wait-freeは各スレッド有限完了。(3) ABA問題——A→B→Aで値が戻るとCASが誤成功する、対策はタグ付きポインタ/ハザードポインタ。「ロックフリーはデッドロックしないが飢餓やライブロックは別問題」という線引きも押さえておくと盤石です。
まとめ
ロックフリー・プログラミングは、(1) CAS という不可分命令で共有状態を楽観的に更新し、失敗したら読み直して再試行する、(2) その進行保証は lock-free(全体前進)と wait-free(各スレッド有限完了) という階層で精密に語られる、(3) 再試行ループ特有の ABA問題 をタグやハザードポインタで封じる、という三本柱で成り立ちます。「ロックを外せば速くなる」ではなく、「停止したスレッドが全体を止めない進行保証を、競合と検証コストと引き換えに得る」設計だと捉えるのが正確です。並行設計全体の見取り図は並行性モデルと同期処理と非同期処理と合わせて押さえると、いつCASまで踏み込むべきかの判断がつきます。
プログラミング Article
ロックフリー・ウェイトフリーとCAS命令を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
並行処理
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
進行保証は obstruction-free ⊂ lock-free ⊂ wait-free の階層。lock-freeは「全体として誰かが必ず前進」、wait-freeは「各スレッドが有限ステップで完了」を保証する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「並行処理 / ロックフリー」に近いか確認する。
- 強みである「ロックフリーの土台はCAS(Compare-And-Swap)。「期待値と一致したら新値に差し替える」をハードウェアが不可分に行い、失敗したら読み直して再試行する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。