RCUの実装詳細(grace period検出とコールバック)
RCUの原理は分かっても「数百コアでgrace periodをどう検出するのか」が謎のまま――Tree RCUの階層集約と状態機械、call_rcuの非同期処理を追えば、その実装の巧みさが腑に落ちます。
- 1.Tree RCUはCPUごとのクォースセント状態をrcu_nodeツリーでボトムアップに集約する。全CPUを1つのフラグで監視するとロック競合で破綻するため、扇形に分割してロックの取り合いを各部分木に閉じ込める。
- 2.grace periodは専用カーネルスレッドrcu_gp_kthreadが回す状態機械で進む。GP開始でツリーを初期化し、各CPUの静止報告がrootまで伝播しきった瞬間にGPを終了、待っていたコールバックを起動する。
- 3.call_rcuはコールバックをCPUごとのリストに積み、GP完了後にsoftirqまたはrcuocスレッドで一括実行する。SRCUは読み取り区間内でのスリープを許し、Tasks RCUはトランポリン回収のため別の静止条件を使う。
なぜ実装が難しいのか──「全CPUの静止」を数百コアで検出する
RCUの原理は「公開後に全CPUがクォースセント状態(QS、読み取り区間の外にいると保証できる瞬間)を一度ずつ通過すれば、grace period(GP、猶予期間)が完了する」というものです。原理そのものは RCUの原理 で扱いました。本記事はその一段下、「数百コアで、誰がいつ静止したかをどう集約し、いつGPを終わらせ、貯まったコールバックをどう処理するか」 という実装の核心を追います。
素朴に実装するなら、GPごとに「未報告CPUのビットマスク」を1つ用意し、各CPUが静止するたびに自分のビットを下ろし、全ビットが0になったらGP完了とすればよさそうです。しかしこれは メニーコアで即座に破綻 します。1つのマスクを保護する1つのロックを、数百コアが静止のたびに奪い合うため、そのキャッシュラインがコア間を行き来し続ける(バウンシング)。RCUは読み取りを限界まで軽くするのが目的なのに、GP検出の側で別のスケーラビリティ問題を作ってしまうのです。
初期のLinuxには単一グローバルロックで全CPUを監視するClassic RCUがありましたが、コア数増加で破綻し、2008年にPaul McKenneyらが Tree RCU へ置き換えました。現在のカーネルでQS集約・GP検出・コールバック処理を担うのはすべてTree RCUです。本記事の「RCU」は断りのない限りこのTreeRCUを指します。
rcu_nodeツリー──ロック競合を部分木に閉じ込める
Tree RCUの中心的アイデアは、CPU群を木構造に分割し、ロックの取り合いを各部分木の内側に閉じ込める ことです。1枚のフラットなビットマスクの代わりに、rcu_node 構造体を多分木(典型的な扇出は64、CONFIG_RCU_FANOUT)に組みます。
[ root rcu_node ] ← 全体のGP状態を集約
/ | \
[ rcu_node ] [ rcu_node ] [ rcu_node ] ← 中間ノード(大規模時)
/ | \
CPU0 CPU1 ... CPU63 ← 葉ノード1つが最大64CPUを束ねる
(各CPUは rcu_data を1つ持つ)
各 rcu_node は、自分の子(下位ノードまたはCPU)のうち まだ当該GPで静止していないもの をビットマスク qsmask で持ちます。CPUが静止すると、まず自分が属する 葉ノードのロックだけ を取って自分のビットを下ろす。これで全ビットが0になったら、初めて1つ上の親へ「この部分木は全員静止した」と報告し、親のビットを下ろしにいきます。
この設計の妙は、ほとんどのQS報告が 葉ノードのロックで完結 する点です。64CPUを束ねる葉ノードのロックを取り合うのは最大でも64コアで、しかも親のロックに触れるのは「葉が全員静止した最後の1回」だけ。ロックのバウンシングが部分木に閉じ込められ、rootロックの競合は劇的に減ります。コア数が増えても木を深く・広くするだけでスケールします。
データ構造の役割分担は次のとおりです。
| 構造体 | 粒度 | 主な役割 |
|---|---|---|
| rcu_data | CPUごと(per-CPU) | そのCPUのQS報告状態・コールバックリスト・進行中GP番号を保持 |
| rcu_node | CPU群(扇出単位) | 子のqsmaskを集約。GP番号や待ちCPUを部分木単位で管理 |
| rcu_state(global) | システム全体 | 現在のGP番号gp_seqやrcu_gp_kthreadへの参照など、GP全体の制御点 |
QSの検出──いつ「このCPUは静止した」と言えるか
葉ノードのビットを下ろす前提として、そのCPUが本当にQSを通過したか を判定する必要があります。非プリエンプトRCUでは コンテキストスイッチ・ユーザ空間実行・アイドルがQSの目印でした。Tree RCUはこれを能動的に回収する仕組みを持ちます。
- スケジューラティック(
rcu_check_callbacks相当):周期タイマ割り込みのたびに、そのCPUがユーザ空間にいたか、あるいはアイドルだったかを調べ、QSなら記録する。 - コンテキストスイッチ:プロセススケジューリング が走るたびにQSとして報告される。
- プリエンプタブルRCU(
CONFIG_PREEMPT_RCU):読み取り区間内でのプリエンプションを許すため、QSの定義が変わる。rcu_read_lock()がカウンタを上げ、区間内でプリエンプトされたタスクは葉ノードのblkd_tasksリストに繋がれ、そのタスクがrcu_read_unlock()を実行するまでQSにならない。区間内で寝てよくなった代償として、ブロック中タスクの追跡が要るわけです。
計算ループに入ったまま一度もスケジューラに戻らないCPUがあると、QSを報告できずGPが永遠に終わりません。これを避けるため、GPが長引くとRCUは resched_cpu() で未報告CPUへ 再スケジュール要求のプロセッサ間割り込み(IPI) を送り、強制的にスケジューラを通過させてQSを取りにいきます。nohz_full(ティックレス)CPUの扱いも同様に特別な配慮が要る箇所で、Tree RCU実装の複雑さの多くはこの「静止しないCPUをどう静止させるか」に費やされています。
grace period状態機械──rcu_gp_kthreadが回す
GPの開始・監視・終了は、システムに1つだけある専用カーネルスレッド rcu_gp_kthread が状態機械として駆動します。CPUごとに勝手にGPを進めるのではなく、1点に制御を集約することで、GP番号の整合とツリー初期化を一貫させています。GPは大きく3フェーズで進みます。
(待機)── コールバックが現れる ──▶ [GP-INIT]
[GP-INIT] グローバルgp_seqを進め、ツリー全rcu_nodeのqsmaskを
「全CPU未報告」に初期化(新しいGPの開始を宣言)
│
▼
[QS-WAIT] 各CPUがQSを報告 → 葉のビットdown → 全部0なら親へ伝播
rcu_gp_kthreadは周期的にforce_quiescent_state()で
停滞CPUへIPIを送り、報告を促す
│ rootのqsmaskが空になった=全CPUが静止を通過
▼
[GP-CLEANUP] gp_seqを「完了」へ進め、各CPUのrcu_dataに
「このGPは完了した」と記録。待っていたコールバックを
「実行可能」へ移し、softirq/rcuocを起こす
│
▼
(待機に戻る。保留コールバックが残っていれば次のGPを即開始)
ここで効くのが gp_seq(GPシーケンス番号) です。GP番号を単調増加のカウンタで持ち、下位ビットで「GP進行中か完了済みか」の位相を表します。各CPUの rcu_data は「自分が待っているGP番号」を覚えており、グローバルな gp_seq がそれを追い越したかを比較するだけで「自分の待ちGPは完了したか」を ロックなしで判定 できます。個々のコールバックにGP番号の刻印(スタンプ)を付け、番号比較で完了を見るこの方式が、コールバック処理全体の土台です。
gp_seq は単調増加ですが有限ビットなので、いつかは一周(ラップアラウンド)します。完了判定を seq_a > seq_b のような素朴な大小比較で書くと、ラップ直後に逆転して 「完了したGPを未完了と誤判定」 しかねません。カーネルは符号付き差分((s32)(a - b) < 0 相当)でこれを安全に比較します。実装を読む際は、GP番号の比較が必ずこの差分形式になっている点に注目すると、なぜ単純な不等号でないのかが腑に落ちます。
call_rcuとコールバック処理──貯めて、待って、一括で流す
更新側が call_rcu(head, func) を呼ぶと、func は その場では実行されません。GP完了後に非同期で呼ぶのがこのAPIの役目で、割り込みハンドラ のように待てない文脈から安全に解放を予約できます。処理の流れはこうです。
- 登録:
call_rcuはコールバックを 呼び出したCPUのrcu_dataのリスト へ繋ぐ。グローバルなロックを取らずper-CPUで完結するため、登録は極めて軽い。 - GPとの紐付け:そのコールバックには「現在進行中(または次の)GP番号」が割り当てられる。
rcu_segcblist(セグメント化コールバックリスト)が、コールバックを「DONE(実行可能)/WAIT(このGPを待つ)/NEXT(次のGPを待つ)」のセグメントに区分けして管理する。 - GP完了の波及:
rcu_gp_kthreadがGPを終えると、各CPUのリストで「そのGPを待っていた」セグメントが DONEへ昇格 する。 - 実行:DONEになったコールバックを、RCU_SOFTIRQ(ソフト割り込み文脈)で、あるいは設定により専用スレッド
rcuoc(callback-offload kthread) が、一括して呼び出す。
/* 更新側:call_rcu は即座に戻り、解放はGP後に肩代わりされる */
void replace(struct foo *new) {
struct foo *old = rcu_dereference_protected(gp, lockdep_is_held(&lock));
rcu_assign_pointer(gp, new); /* 新版を公開 */
call_rcu(&old->rcu, free_foo); /* old は GP 完了後に free_foo で解放 */
/* ここで戻る。ブロックしない */
}
1コールバックごとに1GPを回していては割に合いません。Tree RCUは 1回のGPで、その間に貯まった全コールバックをまとめて処理 します。GPはミリ秒オーダーで「重い」一方、その1回の待ちに数千個のコールバックを相乗りさせれば、コールバック1個あたりのコストは償却されて小さくなる。これがRCUを高頻度な解放にも耐えさせる鍵です。call_rcu が即座に戻り、実処理を後ろにまとめるのは、このバッチングを成立させるためです。
GPが追いつかないほど call_rcu が殺到すると、未処理コールバックがメモリを食いつぶします。これを防ぐため、保留数が閾値を超えると GPを急かす(force quiescent state を早める) とともに、極端な場合は登録側を一時的に律速します。rcu_barrier() は「いま登録済みの全コールバックが完了するまで待つ」APIで、モジュール解放時などに「自分の予約した解放が全部終わったか」を保証するのに使います。
RCUの派生──SRCUとTasks RCU
基本のTree RCUには「読み取り区間内で寝てはいけない」「QSはスケジューラ事象で取る」という制約があります。用途に合わせてQSの定義そのものを差し替えたのが派生RCUです。
| 種類 | 読み取り区間内のスリープ | QS/GPの定義 | 主な用途 |
|---|---|---|---|
| Tree RCU(非プリエンプト) | 不可 | コンテキストスイッチ・ユーザ空間・アイドル | カーネル全般のホットパス |
| SRCU(Sleepable RCU) | 可 | ドメインごとのper-CPUカウンタ2組で『旧世代の読み手』が尽きたかを判定 | 区間内で寝うる経路(一部のドライバ・通知チェーン) |
| Tasks RCU | 可(自発的スリープ込み) | 各タスクが一度でも自発的にコンテキストスイッチ/ユーザ空間/アイドルを通過 | ftrace等のトランポリン回収 |
SRCU はGPを ドメイン(srcu_struct)ごとに分離 するのが核心です。srcu_read_lock() はper-CPUカウンタを上げ、unlock で下げる。GP検出は「世代を切り替え、旧世代のカウンタ合計が読み手の入った数と一致した(=旧世代の読み手が全員抜けた)」ことで行います。区間内で寝てもよい代わりに、読み手は軽量ながら 実際にper-CPUカウンタを操作する(純粋なTree RCUの読み手は何も書かない)点が対価です。ドメインを分けることで、ある経路の長い読み手が無関係な経路のGPを巻き込んで遅らせる事態を避けられます。
Tasks RCU は、call_rcu では追えない対象——具体的には ftraceがコード書き換えで挿入する「トランポリン」 の安全な回収のために生まれました。トランポリンは rcu_read_lock() で囲めない素のコード片なので、QSを「各タスクが一度でも自発的にスケジューラを通過したか」と再定義し、現在トランポリン上を走っているタスクがいなくなった ことを保証します。
- Tree RCU はQSを
rcu_node多分木で ボトムアップ集約 し、ロック競合を部分木に閉じ込めてメニーコアでスケールさせる(フラットな単一マスクの破綻を回避)。 - GPは専用スレッド
rcu_gp_kthreadが状態機械(INIT→QS待ち→CLEANUP)で駆動し、進行はgp_seqで表す。完了判定は ラップアラウンド安全な差分比較。 call_rcuはコールバックをper-CPUリストに積み、GP完了後に RCU_SOFTIRQ/rcuocスレッド が バッチで一括実行。rcu_barrier()で登録済み全完了を待てる。- 派生は SRCU(区間内スリープ可・ドメイン分離・per-CPUカウンタでGP判定)と Tasks RCU(自発スイッチをQSとし、ftraceトランポリンを回収)。
まとめ──分割集約・状態機械・バッチングの三段構え
RCUの実装の核心は、原理(全CPUのQS通過でGP完了)を メニーコアで破綻させずに動かす 工夫にあります。Tree RCU はCPUごとのQSを rcu_node 多分木でボトムアップに集約し、ロックの取り合いを葉単位の部分木に閉じ込めることで、単一マスクのロック競合を回避します。GPの開始・監視・終了は専用スレッド rcu_gp_kthread が状態機械として回し、進行と完了は gp_seq(ラップアラウンド安全に比較)で表します。更新側の call_rcu はコールバックをper-CPUリストへ軽量に積むだけで即座に戻り、GP完了後に RCU_SOFTIRQ/rcuocスレッド が貯まったコールバックを バッチで一括処理 することでGPの重さを償却します。さらに SRCU(読み取り区間内スリープ可・ドメイン分離)と Tasks RCU(ftraceトランポリン回収)が、QSの定義を差し替えて適用範囲を広げます。原理の復習は RCUの原理、QS判定の土台は コンテキストスイッチ と プロセススケジューリング、待ち手とプリエンプションの絡みは カーネルプリエンプション も合わせてどうぞ。
OS Article
RCUの実装詳細(grace period検出とコールバック)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
RCU
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
grace periodは専用カーネルスレッドrcu_gp_kthreadが回す状態機械で進む。GP開始でツリーを初期化し、各CPUの静止報告がrootまで伝播しきった瞬間にGPを終了、待っていたコールバックを起動する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「RCU / Tree RCU」に近いか確認する。
- 強みである「Tree RCUはCPUごとのクォースセント状態をrcu_nodeツリーでボトムアップに集約する。全CPUを1つのフラグで監視するとロック競合で破綻するため、扇形に分割してロックの取り合いを各部分木に閉じ込める。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。