ABA問題とハザードポインタ(ロックフリーのメモリ回収)
ロックフリー構造で「いつノードを安全に解放できるか」を判断できれば、ABA問題やuse-after-freeを踏まずに高並行コードが書ける。ハザードポインタ・エポックGC・RCUの原理と使い分けを内部から解説します。
- 1.ロックフリーの本質的な難所はメモリ回収。あるスレッドがノードへのポインタを握ったまま、別スレッドがそれをpopして解放すると、use-after-freeとABA問題が起きる。GCなし言語ではこの「安全な解放時点」の判定が実装の大半を占める。
- 2.ハザードポインタは「参照中ノードを公示してから触る」方式。解放側は公示テーブルを走査し、公示中のアドレスは解放を保留する。読み手ごとにO(1)個の単一書き込みで保護でき、メモリ滞留の上限が読み手数で抑えられる。
- 3.エポックベース回収(EBR)とRCUは「読み手のクリティカルセクションが全て抜けるまで一括で解放を遅らせる」方式。読み手のオーバーヘッドはほぼゼロだが、1スレッドが居座ると回収が止まりメモリ滞留が無制限に膨らむ。
なぜロックフリーの「解放」が難しいのか
ロックフリー構造の本当の難所は、ノードの更新ではなく解放にあります。ミューテックスなら「ロック区間で誰も触っていない」が保証されるため、その場でfreeできます。ロックを外した世界には、その保証がありません。あるスレッドが共有ノードへのポインタを読んで握ったまま中断し、その隙に別スレッドが同じノードをデータ構造から外して解放したら——握っていたポインタは**ダングリング(宙ぶらりん)**になり、読み出した瞬間に use-after-free です。
# スレッドR(読み手) # スレッドW(解放側)
p = atomic_load(head)
unlink(p); free(p) # p を構造から外し解放
v = p->value # ← use-after-free。p は既に解放済み
問題の核心は「いま誰がそのノードを指しているかを、ロックなしでどう知るか」です。CASでポインタを付け替える話はロックフリー・ウェイトフリーとCAS命令で扱いましたが、付け替えに成功しても外したノードをいつ捨てられるかは別問題として残ります。GCのある言語は「到達不能になれば回収」でこれを肩代わりしますが、C/C++/Rust のように手動回収する環境では、専用の**安全メモリ回収(SMR: Safe Memory Reclamation)**機構が要ります。
ABA問題は回収問題の一側面
回収を誤ると、CASが論理的に壊れた状態を「成功」と誤認します。これがABA問題です。値が A → B → A と変化して戻ると、CASは「変わっていない」と判断しますが、実際にはその間に A が解放され、アロケータが同じアドレスを別の意味のノードに再利用しているかもしれません。
top = A
next = A->next # この時点の next は B
# ← 中断。その間に pop A, pop B が起き、A のアドレスが新ノードに再利用される
CAS(stack.top, A, next) # アドレスとしては A に一致 → 誤って成功
# だが next(=旧B) は解放済み。先頭が壊れる
タグ付きポインタ(バージョン番号併用のCAS)はCAS自体の誤成功を防ぎますが、解放済みアドレスを読みに行く危険そのものは消しません。「いつ解放してよいか」を決める回収機構が別途必要で、ハザードポインタ・エポックGC・RCUはまさにこの解放時点の決定を担います。ABA問題は、安全回収という大きな問題の一断面だと捉えると見通しがよくなります。
ハザードポインタ:参照を公示してから触る
ハザードポインタ(Maged Michael, 2004)は「触る前に宣言する」方式です。各スレッドは、参照しようとするノードのアドレスを、自分専用のスロット(グローバルに読める単一ワード)へ書き込んでから、改めてそのポインタが有効かを確認します。
# 読み手:head のノードを安全に掴む
do:
p = atomic_load(head)
hazard[myID] = p # 公示(release ストア)
while atomic_load(head) != p # 公示後にもう一度検証(再読込)
# ここまで来れば、p は解放されない(公示済みのため)
use(p)
hazard[myID] = null # 使い終わったら取り下げ
公示後の再検証が肝です。公示する前に他スレッドが p を外して回収判定を済ませてしまう競合を、「公示してからもう一度読み直し、まだ head==p なら安全」と確定させて閉じます。解放側は即freeせず、退役ノードをローカルのretireリストへ溜め、しきい値に達したら全スレッドのhazardスロットを走査します。
# 解放側:p を退役させる
retire(p) # ローカルリストに積む
if retired.size >= threshold:
H = collect_all_hazard_slots() # 全スレッドの公示を集める
for n in retired:
if n not in H: free(n) # 誰も公示していなければ解放
else: keep(n) # 公示中なら次回へ持ち越し
公示の正しさはメモリモデルとhappens-before関係に依存します。公示は release、走査側の読み取りは acquire でなければ、走査側が古い null を見て解放してしまいます。
構造あたりに必要な公示スロットは普通1〜2個。読み手側のコストはO(1)の単一ストアと1回の再検証で済みます。滞留するメモリの上限は「スレッド数 × ノードあたり公示数 + retireしきい値」で有界——これがハザードポインタ最大の利点です。1スレッドが固まっても、滞留は他スレッド分に波及しません。
エポックベース回収とRCU:読み手をほぼ無料にする
ハザードポインタは読み手にストアと再検証を課します。これすら惜しいホットパス向けに、**エポックベース回収(EBR)とRCU(Read-Copy-Update)**があります。発想は「読み手は何も公示しない。代わりに、いま走っている読み手が全員クリティカルセクションを抜けるまで、解放を一括で遅らせる」です。
EBRはグローバルなエポック番号(世代)を持ちます。読み手はクリティカルセクション入場時に「自分は現エポックで読んでいる」と印を付け、退場時に外すだけです。退役ノードはエポック単位のバケツに入れ、**全スレッドが現エポックに追いついた(=古いエポックの読み手が消えた)**ことを確認できたら、2世代前のバケツをまとめて解放します。RCUはLinuxカーネルの中核機構で、同じ原理を「猶予期間(grace period)——全CPUが少なくとも一度クリティカルセクション外になる期間——が経過したら回収」として実装します。
# RCU 読み手(極めて軽量。多くの実装で実質ノーオペ)
rcu_read_lock()
p = rcu_dereference(head) # 依存順序つきロード
use(p)
rcu_read_unlock()
# RCU 更新側
new = copy_and_modify(old)
rcu_assign_pointer(head, new) # release ストアで公開
synchronize_rcu() # 猶予期間を待つ(全読み手の抜けを待機)
free(old) # ここで初めて安全に解放
読み手がほぼ無料な代償は回収の有界性を失うことです。1スレッドがクリティカルセクション内で長時間ブロック・プリエンプトされると、エポックが進まず、退役ノードが無限に積み上がる(メモリリーク同然の滞留)。ハザードポインタが「有界滞留・読み手は重め」なのに対し、EBR/RCUは「読み手は激軽・滞留は無界」という正反対のトレードオフです。RCUは読み多write少(read-mostly)で読み手が短命な場面に最適化されています。
三方式の比較と使い分け
| 項目 | ハザードポインタ | エポックGC(EBR) | RCU |
|---|---|---|---|
| 読み手コスト | 公示ストア+再検証(中) | エポック印付け(小) | ほぼゼロ(最小) |
| メモリ滞留 | 有界(スレッド数で抑制) | 無界(居座りで膨張) | 無界(猶予期間が延びると膨張) |
| 居座りスレッド耐性 | 強い(局所影響) | 弱い(全体が停滞) | 弱い(猶予期間が延びる) |
| 典型用途 | 汎用ロックフリー構造 | ユーザ空間の高速構造 | Linuxカーネル read-mostly |
| 進行保証 | lock-free を維持しやすい | 回収が遅延し得る | 更新側は synchronize_rcu で待つ |
選択の軸は明快です。メモリ滞留の上限を保証したい・読み手が長く居座り得るならハザードポインタ。読み手が極短命で性能を最優先し、滞留リスクを許容できるならEBR/RCU。参照カウントを各ノードに持たせる手もありますが、共有カウンタへのアトミック更新がキャッシュラインを奪い合い、高競合では失速します(参照カウントの仕組みと循環参照)。SMRはこの「カウンタ集中」を避け、回収判定を読み取りと分離する点に価値があります。
GC付き言語なら回収は処理系任せ——ですが、ABA問題そのものは消えません。GCは「到達可能なら生かす」ため use-after-free は防ぎますが、論理的なA→B→A(例:プールから取り出した同一オブジェクトの再利用)でCASが誤成功する余地は残ります。GCはメモリ安全を保証しても論理的同一性までは保証しない、という線引きを忘れないでください。
頻出は三点。(1) 回収問題の本質——ロックフリーで難しいのは更新でなく「外したノードをいつ解放してよいか」の判定で、これがABAと use-after-free の根。(2) ハザードポインタ——参照を公示→再検証→解放側は公示テーブル走査、滞留が有界。(3) EBR/RCU——猶予期間を待って一括回収、読み手は激軽だが居座りで滞留無界。「有界滞留↔読み手コスト」のトレードオフを対比で言えると強い。
まとめ
ロックフリー構造の本丸は、CASによる更新ではなく安全なメモリ回収です。あるスレッドがノードを握る間は解放してはならず、その「握り」をロックなしで知る手段が安全回収機構です。ハザードポインタは参照を公示し再検証することで滞留を有界に抑え、居座りスレッドに強い。EBR・RCUは読み手をほぼ無料にする代わりに、猶予期間が延びると滞留が無界に膨らむ。両者は「読み手の軽さ」と「滞留の有界性」を交換し合う対照的な設計です。ABA問題はこの回収問題の一断面に過ぎず、タグ付きCASでCASの誤成功を塞いでも、解放時点の決定は別途これらの機構で詰める必要があります。並行設計の全体像はロックフリー・ウェイトフリーとCAS命令と並行性モデル(CSP・アクター・STM)と合わせて押さえると、どこまで踏み込むべきかの判断がつきます。
プログラミング Article
ABA問題とハザードポインタ(ロックフリーのメモリ回収)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
並行処理
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
ハザードポインタは「参照中ノードを公示してから触る」方式。解放側は公示テーブルを走査し、公示中のアドレスは解放を保留する。読み手ごとにO(1)個の単一書き込みで保護でき、メモリ滞留の上限が読み手数で抑えられる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「並行処理 / ロックフリー」に近いか確認する。
- 強みである「ロックフリーの本質的な難所はメモリ回収。あるスレッドがノードへのポインタを握ったまま、別スレッドがそれをpopして解放すると、use-after-freeとABA問題が起きる。GCなし言語ではこの「安全な解放時点」の判定が実装の大半を占める。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。