カーネルのRCUと並行データ構造の設計パターン
ロックフリー構造で必ずぶつかる「いつメモリを解放してよいか」を、RCU・ハザードポインタ・エポック回収の三択で整理できます。読み取りコストと回収レイテンシのトレードオフを原理から選び分けられます。
- 1.ロックフリー構造の本当の難所は更新ではなく安全なメモリ回収(SMR)。読み手がまだ参照中のノードを解放するとuse-after-freeになるため、誰も触っていないと確証できるまで解放を遅延する仕組みが要る。
- 2.三大手法はRCU(読み手はほぼ無料・回収はgrace period待ちで遅い)、ハザードポインタ(ノード単位で正確に回収・読み手ごとにストア+フェンスのコスト)、エポックベース回収(軽いが1スレッドが固まると回収が止まる)。
- 3.選択軸は「読み取りの軽さ」と「回収の即時性・上限保証」のトレードオフ。読み多寡が極端ならRCU、回収レイテンシ上限が要るならハザードポインタ、汎用ランタイムの中庸ならエポックが定番。
本当の難所は「更新」ではなく「回収」
ロックフリーなリストやキューを書くとき、ポインタを CAS で差し替えて要素を繋ぐ部分は、実は易しい方です。本当に難しいのは 削除したノードをいつ free してよいか の判断です。あるスレッドがノードへのポインタを読んだ直後に横取り(プリエンプト)され、その隙に別スレッドが同じノードを論理削除して解放したら、再開した最初のスレッドは 解放済みメモリ(use-after-free) を読みます。CAS の差し替え自体は成功していても、回収のタイミングを誤れば構造は壊れます。
この問題を SMR(Safe Memory Reclamation、安全なメモリ回収) と呼びます。本質は「いま誰かがそのノードを参照中かもしれない間は解放できない」こと。GCのある言語なら処理系が肩代わりしますが、カーネルやC/C++ランタイムでは自前で実装します。差し替えの土台となる CAS とABA問題は カーネルのロックフリー同期とCAS を、なぜポインタ参照に順序保証が要るのかは メモリオーダリングとメモリバリア を前提にすると読みやすくなります。
解放したノードのアドレスがアロケータですぐ再利用されると、CAS がA→…→Aの往復を見逃すABA問題が起きやすくなります。SMRは「参照中のノードを再利用させない」ため、回収を遅延させること自体がABA対策にもなります。三手法はいずれも、解放を安全な瞬間まで先送りする点で共通しています。
RCU:読み手を限界まで軽くする
RCU(Read-Copy-Update) は、読み取りからロックもアトミック操作も取り除き、コストをすべて更新側へ寄せる手法です。読み手は rcu_read_lock()/rcu_read_unlock() で区間を囲むだけ(多くの構成でほぼノーオペレーション)、更新側は旧ノードを書き換えず新版を作ってポインタ1発で公開し、grace period(全読み手が区間を抜ける猶予期間) の完了を待ってから旧版を解放します。原理の詳細は RCU(Read-Copy-Update)の原理、数百コアでの実装は RCUの実装詳細 を参照してください。
/* 読み手:ほぼ無料 */
rcu_read_lock();
p = rcu_dereference(head);
while (p) { use(p); p = rcu_dereference(p->next); }
rcu_read_unlock();
/* 更新側:差し替え → grace period 待ち → 解放 */
rcu_assign_pointer(prev->next, target->next); /* リストから外す */
call_rcu(&target->rcu, free_node); /* 後で安全に解放 */
RCUの強みは 読み手が共有状態を一切書かない ことで、コア数に対してほぼ線形にスケールします。代償は回収レイテンシで、grace periodは全CPUの静止を保守的に待つためミリ秒オーダーになりえます。読み多寡が極端でメモリ二重保持を許せる経路に最適です。新旧を共存させる発想は コピーオンライト(CoW) と同じ系譜です。
ハザードポインタ:参照を「宣言」して守る
ハザードポインタ は、回収を正確に行う方向の手法です。各スレッドは「いま参照しているノード」を、グローバルに公開された自分専用のスロット(ハザードポインタ)に書き込んでから アクセスします。回収側はノードを解放する前に、全スレッドのハザードポインタを走査 し、どこからも指されていないと確認できたものだけを解放します。指されているノードは「保留リスト」に積み、後で再走査します。
読み手の手順:
do {
p = atomic_load(&head) # ① 候補を読む
HP[me] = p # ② 「参照中」と宣言(ストア)
fence(seq_cst) # ③ 宣言を回収側に確実に見せる
} while (p != atomic_load(&head)) # ④ 宣言後も同じか再確認(ABA回避)
use(p)
HP[me] = NULL # 終わったら宣言を取り下げる
正確さの代償は 読み手ごとのコスト です。参照するたびにストアと 逐次一貫フェンス相当 が必要で、RCUのほぼ無料な読み手には及びません。一方で「ノード単位で、不要になった瞬間に近いタイミングで解放できる」ため、回収レイテンシに上限を引け ます。保留ノード数が「スレッド数 × 1スレッドあたりHP本数」で抑えられるのが理論的な美点です。
②の宣言ストアと④の再確認ロードの間に強い順序がないと、回収側が「宣言を見ていないのに読み手は参照を確定している」窓が開き、use-after-freeに戻ります。だからここだけは省けない逐次一貫フェンスを置きます。読み手が完全に無料にならない根本原因がこの一手です。
エポックベース回収:軽さと引き換えに「足を引っ張られる」
エポックベース回収(EBR、Epoch-Based Reclamation) は、RCUのユーザー空間版とも言える中庸の手法です。グローバルな エポックカウンタ(世代番号)を用意し、スレッドはクリティカルセクションに入るとき現在のエポックを自分のローカルに記録(アナウンス)します。削除されたノードは「削除時のエポック」付きで退避され、全アクティブスレッドが、その世代から2つ進んだエポックに到達したら 安全に解放します。読み手のコストはローカル変数の読み書き程度で、RCUに近い軽さです。
あるスレッドがクリティカルセクション内で長く滞留(ブロックやプリエンプト)すると、グローバルエポックを進められず、退避ノードがいつまでも解放されずメモリが膨張 します。RCUの読み取り区間で寝てはいけないのと同じ制約で、最悪ケースの回収レイテンシに上限を保証できません。ハザードポインタが上限を引けるのと対照的です。
ロックフリーキューが回収を必要とする理由
これらの回収手法は、具体的なロックフリー構造と組で初めて意味を持ちます。代表例が Michael-Scott キュー(連結リスト+ダミーの先頭ノード)で、enqueueは末尾ポインタを、dequeueは先頭ポインタを CAS で進めます。問題はdequeueで外した 旧ダミーノードをいつ解放するか。別スレッドがまさにそのノードのポインタを読んでいる最中かもしれません。ここでRCU/ハザードポインタ/EBRのいずれかを噛ませて初めて、解放が安全になります。「差し替えはロックフリー、回収はSMRに委ねる」が定石の分業です。
三手法の選び分け
| 観点 | RCU | ハザードポインタ | エポック回収(EBR) |
|---|---|---|---|
| 読み取りコスト | ほぼ無料(書き込みなし) | 毎回ストア+強フェンス | ローカル読み書き程度で軽い |
| 回収の即時性 | grace period待ちで遅い | ノード単位で速く正確 | エポック数世代ぶん遅延 |
| 回収レイテンシ上限 | 保証なし(保守的に待つ) | 保証できる(HP本数で有界) | 保証なし(停止で膨張) |
| 区間内スリープ | 古典RCUは不可(SRCUで可) | 可(宣言は明示的) | 原則不可(停止で全体停滞) |
| 主な使い所 | 読み極多のカーネルホットパス | 上限が要る汎用ロックフリー | 汎用ランタイム・DBエンジン |
選択の軸は2つに集約できます。読み取りをどこまで軽くしたいか と、回収の即時性・上限保証をどこまで求めるか。読み多寡が極端でメモリ二重保持を許せるならRCU。回収レイテンシに上限が要る、あるいはメモリ消費を厳しく抑えたいならハザードポインタ。読み取りの軽さと実装の素直さのバランスを取りたい汎用ランタイムやDBストレージエンジンならEBR、というのが現場の相場観です。
- ロックフリー構造の真の難所は 安全なメモリ回収(SMR)=「参照中かもしれないノードを解放しない」。
- RCU=読み手ほぼ無料・grace period待ちで回収は遅い・上限保証なし。
- ハザードポインタ=参照を宣言(ストア+フェンス)して守る・回収は正確で上限を引ける・読み手は重い。
- EBR=軽いがエポックを進めるため全スレッドの協調が要り、1つ固まると回収が止まる。
- 三者は 読み取りコスト vs 回収の即時性・上限 のトレードオフで選ぶ。
まとめ
ロックフリーな並行データ構造でポインタを CAS で差し替える部分は易しく、難所は 削除ノードをいつ解放してよいか=安全なメモリ回収(SMR)です。読み手がまだ参照中なら解放できず、誤ればuse-after-freeとABA問題を招きます。解決策は3つ。RCU は読み手をほぼ無料にする代わりにgrace period待ちで回収が遅く上限も保証しない。ハザードポインタ は参照を宣言して守るため回収が正確で上限を引けるが、読み手ごとにストアと強フェンスのコストを払う。エポックベース回収 は軽さが魅力だが、1スレッドの停滞が全体の回収を止める。Michael-Scott キューのようなロックフリー構造は「差し替えはロックフリー、回収はSMRに委ねる」分業で成り立ちます。選択は 読み取りの軽さ vs 回収の即時性・上限保証 のトレードオフ次第。前提は カーネルのロックフリー同期とCAS と メモリオーダリングとメモリバリア、回収の原理は RCU(Read-Copy-Update)の原理 を合わせてどうぞ。
OS Article
カーネルのRCUと並行データ構造の設計パターンを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
並行データ構造
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
三大手法はRCU(読み手はほぼ無料・回収はgrace period待ちで遅い)、ハザードポインタ(ノード単位で正確に回収・読み手ごとにストア+フェンスのコスト)、エポックベース回収(軽いが1スレッドが固まると回収が止まる)。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「並行データ構造 / RCU」に近いか確認する。
- 強みである「ロックフリー構造の本当の難所は更新ではなく安全なメモリ回収(SMR)。読み手がまだ参照中のノードを解放するとuse-after-freeになるため、誰も触っていないと確証できるまで解放を遅延する仕組みが要る。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。