RCU(Read-Copy-Update)の原理
読み取りをロックもアトミック操作もゼロにする同期の極北がRCU。grace periodとクォースセント状態という発想で、なぜLinuxカーネルが大量採用したのかを原理からつかめます。
- 1.RCUは読み取り側のコストを実質ゼロ(ロックもアトミック操作も書き込みもなし)にする同期手法。更新側が新旧2つの版を共存させ、古い版を読む人がいなくなってから解放する。
- 2.鍵はgrace period。更新前から走っていた全読み取りが終わるまで待つ期間で、判定にはクォースセント状態(その文脈ではもう古いデータを参照しないと保証できる瞬間)を使う。
- 3.新しいデータをポインタ差し替えで公開するpublish-subscribeパターンで一貫性を守る。読み取りが圧倒的に多い経路に効くため、Linuxカーネルで大量採用された。
RCUが解く問題──読み取りが多すぎる
多くのカーネルデータ構造は、読み取りが圧倒的に多く、更新がまれ です。ルーティングテーブル、デバイスのリスト、各種設定。これらは1秒に何百万回も読まれる一方、書き換えは数秒〜数分に一度しか起きません。
ここで素朴にリーダーライターロックを使うと、読み取りのたびにロックのカウンタへアトミック操作 が走ります。アトミック操作はキャッシュラインを各コアで奪い合うため、コア数が増えるほど遅くなる。読むだけなのに、読み手同士がスケールしないのです。
RCU(Read-Copy-Update) は、この前提を逆手に取ります。読み取り側からロックもアトミック操作も書き込みも完全に取り除き、コストのしわ寄せをすべて「まれな更新側」へ寄せる。これがRCUの基本思想です。
Read(読む)・Copy(旧データを複製して新版を作る)・Update(ポインタを差し替えて公開する)。更新側は既存データを壊さず、新しい版を別に作って差し替えます。考え方の土台は コピーオンライト(CoW) と同じで、「読み手を止めずに新旧を共存させる」点が共通します。
読み取り側:ただのポインタ参照に同期を載せる
RCUの読み取りは、クリティカルセクションを区切るだけ です。Linuxでは rcu_read_lock() と rcu_read_unlock() で囲みますが、これらは名前に反して ロックを取りません。プリエンプション無効化などの軽い印を付けるだけで、多くの構成では実質ノーオペレーションに近いコストです。
rcu_read_lock();
p = rcu_dereference(gp); /* 共有ポインタを安全に読む */
if (p)
do_something(p->field);
rcu_read_unlock();
ここで本質的に効いているのは rcu_dereference() です。これは ポインタ経由のデータ依存を守るためのフェンス相当 で、「ポインタ p を読んでから p->field を読む」順序が、CPUの並べ替えで崩れないことを保証します。詳しい背景は メモリオーダリングとメモリバリア を参照してください。
リーダーライターロックは読み取りでもカウンタを更新するため、読み手同士がキャッシュラインを取り合います。RCUの読み手は共有状態を一切書き換えないので、各コアが自分のキャッシュだけで完結し、コア数に対して理想的にスケールします。
publish-subscribe:新版を「安全に公開する」
更新側が新しいデータを作って差し替えるとき、危ないのは 初期化がまだ他コアに見えていないのに、ポインタだけ先に見えてしまう ことです。new->field への書き込みより、gp = new のポインタ書き込みが先に他コアへ届くと、読み手が未初期化のフィールドを掴みます。
これを防ぐのが publish-subscribe パターン です。公開側は rcu_assign_pointer()、購読側は前述の rcu_dereference() を使い、「中身を全部書き終えてからポインタを差し替える」順序 をペアで保証します。
/* 更新側(publish) */
new = kmalloc(sizeof(*new), GFP_KERNEL);
new->a = 1;
new->b = 2;
rcu_assign_pointer(gp, new); /* ここまでの初期化が先に見える */
rcu_assign_pointer() は内部的にリリース相当のバリアを発行し、rcu_dereference() 側のデータ依存と対になって、初期化 → 公開 → 参照 → フィールド読み の順序を成立させます。読み手は古い版か新しい版か、どちらか 完全な版 だけを見るのであって、中途半端な版を見ることはありません。
grace period とクォースセント状態──いつ旧版を捨てられるか
新版を公開した後、旧版を読んでいる読み手 がまだ残っています。彼らがいる間に旧版を解放するとダングリングポインタになる。では「もう誰も旧版を見ていない」とどう判定するのか。ここがRCUの心臓部です。
RCUは個々のポインタを追跡しません。代わりに grace period(猶予期間) という時間区間で考えます。grace periodとは、公開時点で進行中だった読み取りクリティカルセクションが、すべて完了するまで の期間です。
その判定材料が クォースセント状態(quiescent state、静止状態) です。あるCPUがクォースセント状態にあるとは、そのCPUが今この瞬間、いかなるRCU読み取りクリティカルセクションの中にもいない と保証できることを指します。古典的(非プリエンプト)RCUでは、次のような瞬間がクォースセント状態の目印になります。
- コンテキストスイッチ が起きた(読み取り区間はスイッチをまたがない)
- ユーザー空間で実行している
- アイドルループに入っている
読み取りクリティカルセクション内ではコンテキストスイッチが起きない、という規律を敷くと、判定がとても簡単になります。公開後に全CPUが少なくとも1回クォースセント状態を通過すれば、公開時点で走っていた読み手は全員が区間を抜け終えたと断定できます。これで grace period の完了が分かります。土台となる切り替えの仕組みは コンテキストスイッチ、判定が走るタイミングは プロセススケジューリング と密接です。
更新側はこの grace period の完了を待ってから、旧版を解放します。待ち方には2通りあります。
| API | 動作 | 使い所 |
|---|---|---|
| synchronize_rcu() | 呼び出しスレッドを grace period 完了までブロックする | 更新がまれで、待ってよい文脈。コードが単純になる |
| call_rcu(head, func) | ブロックせず、grace period 後に func を非同期で呼ぶ | 待てない文脈(割り込みハンドラ等)。解放を後回しにする |
/* 更新側の典型形:差し替え → 待つ → 解放 */
old = gp;
rcu_assign_pointer(gp, new);
synchronize_rcu(); /* 旧版を読む全読み手が抜けるまで待つ */
kfree(old); /* ここで安全に解放できる */
grace periodは個別の読み手を追わず、全CPUが静止を通過する という保守的な条件で完了を判定します。そのためミリ秒オーダーで待つことも珍しくありません。これは読み取りを限界まで軽くするための意図的なトレードオフで、コストを「まれな更新側のレイテンシ」へ全振りした結果です。更新が高頻度な構造にRCUは向きません。
リーダーライターロックとの本質的な違い
同じ「読み多・書き少」を狙う両者ですが、設計思想は正反対です。
| 観点 | リーダーライターロック | RCU |
|---|---|---|
| 読み取りコスト | ロックのアトミック操作が必要 | ロック・アトミック操作・書き込みすべてなし |
| 読み手のスケール | カウンタ競合でコア数に対し頭打ち | 各コア独立でほぼ線形にスケール |
| 読みと書きの関係 | 互いに排他(読み手がいると書けない) | 読みと書きが完全に並行(新旧共存) |
| 更新コスト | ロック取得のみで即時 | grace period を待つぶん高く遅い |
| メモリ | 追加なし | 旧版を解放まで保持(一時的に二重) |
最大の違いは、RCUでは 更新が読み手をブロックしない ことです。リーダーライターロックは書き手と読み手が排他しますが、RCUは新旧2版を共存させるため、更新の真っ最中でも読み手はノンストップ で走り続けます。読み手から見ればロックの存在自体が消えるのです。関連する排他制御全般の整理は 排他制御とデッドロック を参照してください。
非プリエンプトRCUでは、読み取りクリティカルセクション内で ブロック・スリープ・コンテキストスイッチを起こす操作をしてはいけません。なぜなら、スイッチが起きないことを利用してクォースセント状態を判定しているからです。区間内で寝ると grace period が永久に完了せず、更新側が解放できなくなります。スリープを許す用途には別系統の SRCU(Sleepable RCU) を使います。
なぜLinuxカーネルで大量採用されたのか
Linuxカーネルには現在、数千か所のRCU利用箇所があります。理由は、カーネルの典型パターンとRCUの得意分野が一致する からです。
- 読み取り経路がとにかく多い:パケット転送のたびのルーティング表参照、
openのたびのファイルディスクリプタ表、ネットワーク名前空間やモジュールリストの走査など、ホットパスのほとんどが読み取り主体。 - メニーコアでスケールさせたい:コア数が数十〜数百になると、読み取りごとのアトミック操作がそのままボトルネックになる。RCUの読み手は競合ゼロなので、コアを足すほど性能が伸びる。
- 既存のロックを置き換えやすい:リスト走査やハッシュ表参照を
rcu_read_lock()で囲み、更新を publish-subscribe とcall_rcu()に変えるだけで、APIの形を大きく変えずに移行できる。
読み取り(ホットパス) 更新(まれ)
rcu_read_lock() 新版を作成・初期化
p = rcu_dereference(gp) rcu_assign_pointer(gp, new)
... use p ... synchronize_rcu() / call_rcu()
rcu_read_unlock() 旧版を解放
└ 競合なし・ほぼ無料 └ grace period を待つぶん高い
RCUの一言まとめは「読み取りをロックフリー化する代わりに、更新側が新旧2版を共存させ、grace period を待って旧版を解放する」です。grace periodは「公開時に進行中だった全読み取りが終わるまでの期間」、クォースセント状態は「そのCPUがRCU読み取り区間の外にいると保証できる瞬間(コンテキストスイッチ・ユーザー空間・アイドル)」と区別して答えられると強いです。
まとめ
RCU は、読み取りが圧倒的に多い経路で、読み手からロックもアトミック操作も書き込みも取り除く同期手法です。更新側は旧データを 複製して新版を作り(Read-Copy)、publish-subscribe(rcu_assign_pointer と rcu_dereference の対)で中途半端な版を見せずに公開し(Update)、grace period の完了を待ってから旧版を解放します。grace periodの完了は、各CPUが クォースセント状態(読み取り区間の外にいると保証できる瞬間)を通過したかで判定します。コストは「まれで遅くてよい更新側」に全振りされるため、メニーコアで読み取りをスケールさせたいLinuxカーネル の無数のホットパスにぴたりとはまり、大量採用されました。前提は メモリオーダリングとメモリバリア、思想の親戚は コピーオンライト(CoW)、判定の土台は コンテキストスイッチ も合わせてどうぞ。
OS Article
RCU(Read-Copy-Update)の原理を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
RCU
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 5
導入後に効く点
鍵はgrace period。更新前から走っていた全読み取りが終わるまで待つ期間で、判定にはクォースセント状態(その文脈ではもう古いデータを参照しないと保証できる瞬間)を使う。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 5
判断チェックリスト
- 自社の用途が「RCU / ロックフリー」に近いか確認する。
- 強みである「RCUは読み取り側のコストを実質ゼロ(ロックもアトミック操作も書き込みもなし)にする同期手法。更新側が新旧2つの版を共存させ、古い版を読む人がいなくなってから解放する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。