リーダ・ライタロックとシーケンスロックの内部
読み取りが圧倒的に多いのにライタを排他しすぎて遅い――その悩みを、rwlockの優先度設計とseqlockの楽観的読み取りという2つの武器の原理からほどき、読み書き比率で正しく選べるようにします。
- 1.rwlockは複数リーダの同時読み取りを許す共有ロック。だがリーダ優先設計はライタ飢餓を、ライタ優先設計はリーダのスループット低下を生み、どちらも一長一短。
- 2.seqlockはシーケンスカウンタで楽観的読み取りを実現。リーダはロックを取らずカウンタを前後で読み、奇数や不一致なら再試行する。リーダはライタを一切ブロックしない。
- 3.seqlockは読みが圧倒的に多く書きが稀で短い場面に最適。ただしリーダは値をコピーで持ち出し、ポインタ追跡やページフォルトしうるデータには使えない――その場合はRCU。
なぜ単純な相互排他では足りないのか
ミューテックスは「常にただ1人だけがクリティカルセクションに入れる」という強い保証を与えます。しかし多くの実世界のデータ構造は、読み取りが圧倒的に多く、書き換えは稀です。ルーティングテーブル、設定値、システムクロックの読み出し――こうした対象に毎回ミューテックスを使うと、互いに干渉しないはずの複数のリーダまで直列化され、本来取れたはずの並列性を捨てることになります。
そこで「読み取り同士は同時に走ってよい。書き換えだけが排他を必要とする」という観察を制度化したのが リーダ・ライタロック(rwlock) であり、さらに「読み取りはロックすら取らない」と踏み込んだのが シーケンスロック(seqlock) です。両者は同じ「read-mostly」を狙いますが、達成手段と適用条件がまるで違います。本稿はその内部設計を原理から扱います。土台となる排他制御の全体像は 排他制御とデッドロック を参照してください。
rwlockの不変条件と状態
rwlockが守る不変条件は1つだけです。「ライタが1人いるなら、他のライタもリーダも1人もいない。リーダは何人いてもよいが、その間ライタは0人」。これを満たすため、内部状態はおおむね次のように表せます。
状態 = リーダ数(read count) と ライタフラグ の組
read_lock(): ライタが0なら read count を +1(複数リーダ同時可)
read_unlock(): read count を -1
write_lock(): read count==0 かつ ライタ0 のときだけ取得(完全排他)
write_unlock(): ライタフラグを下ろす
リーダ取得は本質的にアトミックなインクリメント、ライタ取得は「カウンタが0であることの確認+占有」です。この単純な定義の裏に、設計上の難問が隠れています。ライタを待たせている間に、新しいリーダの到着を許すか否かです。ここがrwlockの性格を決定づけます。アトミックな増減の土台は アトミック命令とリード・モディファイ・ライトの原理 を参照してください。
リーダ優先とライタ優先――2つの設計の分岐
| 設計 | ライタ待機中に新リーダを通すか | 主な弊害 | 向く状況 |
|---|---|---|---|
| リーダ優先(read-preferring) | 通す(リーダは即座に入れる) | リーダが途切れないとライタが永久に待つ=ライタ飢餓 | 書き換えが非常に稀で、書き込み遅延を許せる |
| ライタ優先(write-preferring) | 通さない(待機ライタがいれば新リーダを止める) | ライタ待機のたびリーダが足止めされ読み取りスループットが落ちる | 書き込みの応答性・公平性が要る |
| フェア(phase-fair等) | 到着順や位相で交互に通す | 実装が複雑、ピーク並列性はやや下がる | 飢餓を避けつつ両者を均衡させたい |
両者の違いは「ライタが write_lock() を待っている最中に、後から来たリーダを通すか」の一点に集約されます。リーダ優先は後続リーダを無条件に通すため、リーダの流入が途切れないとカウンタが0に戻らず、ライタは永久に取得できません。これが ライタ飢餓(writer starvation) です。
逆に ライタ優先は、待機中のライタがいる間は新規リーダをブロックします。これでライタは確実に進めますが、ライタが1人現れるたびに読み取りの並列パイプラインが一時的に止まり、read-mostlyで得たいはずのスループットが削られます。
リーダ優先は一見「読みが速くて良い」ように見えますが、ライタの最悪待ち時間に上限を置けません。負荷が高くリーダが連続する系では、設定変更やテーブル更新がいつまでも反映されない事故が起こりえます。リアルタイム性や更新の即時反映が要る場面では、ライタ優先かフェア方式を選ぶべきです。Linuxカーネルの rwlock_t は歴史的に飢餓を避ける設計に寄せられてきました。
リーダが多数いても、read_lock() は共有カウンタへのアトミック書き込みです。各コアがそのキャッシュ行を奪い合い、MESIプロトコル上で行が無効化と再取得を繰り返すため、コア数に対してスケールしないことがあります。ここはミューテックスと同根の問題で、原理は メモリ階層とキャッシュコヒーレンシ(MESIプロトコル) が詳しいです。per-CPUカウンタやRCUがこの取り合いを根本から避けるのと対照的です。
seqlock――リーダがロックを取らない楽観的読み取り
rwlockのリーダはなおカウンタを書き換えます。seqlock(sequence lock) はそれすら捨て、リーダを完全に受動的にします。中心にあるのは シーケンスカウンタ(世代番号) ひとつです。
ライタ側:
write_lock():
spin_lock(&lock) // ライタ同士は通常のロックで排他
seq++ // 偶数 → 奇数(更新中の印)
smp_wmb() // 以降の書き込みがカウンタ更新の後に見えるよう順序付け
write_unlock():
smp_wmb()
seq++ // 奇数 → 偶数(更新完了の印)
spin_unlock(&lock)
リーダ側(ロックを一切取らない):
do {
s = seq // ① 開始時のカウンタを読む
smp_rmb()
... 保護対象データを読んでローカルにコピー ...
smp_rmb()
} while (s が奇数) または (seq != s) // ② 開始時と一致し、かつ偶数なら成立
設計の核心は2つです。第一に、カウンタの偶奇が状態を表すこと。偶数は「安定」、奇数は「ライタが書き換え中」を意味します。リーダは開始時に奇数を見たら、更新の真っ最中なので即座に再試行します。
第二に、リーダは読み取りの前後でカウンタを2回読み、値が一致するかを確認すること。途中でライタが入って seq を進めていれば、前後の値が食い違い、リーダは「読んだ値が引き裂かれている(torn read)かもしれない」と判断して、データを破棄しループの先頭からやり直します。リーダはライタを一切ブロックせず、ライタもリーダを待ちません。
リーダが読み取った区間中にライタが1回でも書き換えれば、seq は必ず2増えます(開始で奇数化、完了で偶数化)。よって「開始時の偶数値」と「終了時の値」が等しいなら、その区間にライタは1人も入らなかったと確定します。逆に1でも増えていれば再試行。これは値ではなく世代の不変性で一貫性を保証する手法で、リーダ側がアトミックなRMWを一切行わない点が効きます。リーダはカウンタとデータを「読むだけ」なので、コア間でキャッシュ行を奪い合いません。
seqlockのリーダは「引き裂かれた可能性のあるデータ」を平気で読んでしまう前提で動きます。整数のコピーなら破棄すれば済みますが、ポインタをたどる、配列を境界チェックなしで添字参照する、ロックを取る、ページフォルトしうるアクセスをするといった操作を保護区間で行うと、不整合な中間状態を踏んでクラッシュやメモリ破壊を起こします。だからseqlockで守るのは「短い時間でローカル変数へ丸ごとコピーできる、自己完結した値」に限られます。リンク構造を読みたいなら RCU(Read-Copy-Update)の原理 が適任です。
メモリバリアがseqlockの生命線
seqlockの正しさは、カウンタの更新とデータの読み書きの順序に決定的に依存します。もしコンパイラやCPUがリオーダリングして、リーダが「データを読む前に終了時カウンタを読む」あるいはライタが「データを書く前にカウンタを偶数へ戻す」ことが起きれば、不一致検出のロジックが丸ごと崩壊します。
そのため擬似コードの smp_wmb/smp_rmb は飾りではなく必須です。ライタは「データ書き込み」と「カウンタを偶数に戻す」の間に書き込みバリアを置き、リーダは「カウンタ読み」と「データ読み」の間に読み込みバリアを置きます。この順序保証がなければ楽観的読み取りは成立しません。なぜバリアが要るのか、アーキテクチャごとの強弱は メモリオーダリングとメモリバリア を参照してください。
読み書き比率からの選択――原理で決める
3つの手段は「読みと書きの比率」「クリティカルセクションの長さ」「守る対象がコピー可能か」で住み分けます。
| 手段 | リーダのコスト | ライタへの影響 | 最適な比率・条件 |
|---|---|---|---|
| ミューテックス | 排他(リーダ同士も直列化) | リーダと完全に排他 | 読み書きが拮抗、または区間が長い |
| rwlock | 共有カウンタへアトミック書き込み | リーダがいる間ブロック | 読みが多く、区間がやや長い/コピー不可なデータも可 |
| seqlock | ロックなし・読むだけ(稀に再試行) | リーダはライタを止めない | 読みが圧倒的多数、書きは稀かつ短い、値はコピー可能 |
| RCU | ほぼゼロ(参照のみ) | ライタは複製+猶予期間で更新 | 読みが極端に多く、ポインタ連結構造を守りたい |
原理から導けば、判断はこう流れます。書き込みが頻繁、または区間が長いなら、楽観的再試行のコストが嵩むためseqlockは不利で、rwlockかミューテックスが妥当です。読みが圧倒的に多く、書きが稀で短く、守る値が小さくコピー可能なら、リーダをブロックしないseqlockが最も速い。そして 同じ read-mostly でも、守る対象がリンクリストやツリーのようなポインタ連結構造なら、コピーで持ち出せないためseqlockは使えず、RCUへ進みます。
Linuxカーネルがシステム時刻の読み出し(gettimeofday 系が参照する内部のtimekeeperやvDSO経由のクロック読み出し)にseqlockを使うのは、この条件に完璧に当てはまるからです。時刻はナノ秒単位で全CPUから猛烈に読まれる一方、更新はタイマ割り込みでごく稀に・ごく短く行われ、しかも読み出す値は数ワードでコピー可能。リーダがロックを取らないので、時刻取得が他コアの時刻取得を一切妨げません。カーネルにおけるロック選択の全体像は カーネルのロック機構(spinlock・mutex・seqlock) が俯瞰しています。
- rwlock はリーダ同時実行可・ライタ完全排他。リーダ優先=ライタ飢餓、ライタ優先=読み取りスループット低下のトレードオフ。
- seqlock はリーダがロックを取らない。カウンタの偶奇(奇数=更新中)と前後2回読みの一致で一貫性を保証。リーダはライタをブロックしない。
- seqlockのリーダは引き裂かれた値を読みうるため、コピー可能な小さい値専用。ポインタ追跡やフォルトしうるアクセスは厳禁。
- seqlockの正しさは メモリバリア(書き込み・読み込みの順序付け)に依存する。
- 選択基準:読み書き拮抗→mutex、読み多め→rwlock、読み圧倒的・書き稀短・コピー可→seqlock、ポインタ構造の read-mostly→RCU。
まとめ――同じread-mostlyを別の角度から攻める
rwlockとseqlockは「読み取りが多い」という同じ前提に立ちながら、達成手段が正反対です。rwlock は複数リーダの同時読み取りを許す共有ロックで、ライタは完全排他されます。その核心は リーダ優先(ライタ飢餓のリスク) と ライタ優先(読み取りスループット低下) のトレードオフにあり、フェア方式はその折衷です。seqlock はさらに踏み込み、リーダにロックを取らせません。シーケンスカウンタの偶奇で更新中を示し、リーダは前後で2回読んだカウンタの一致で一貫性を検証し、不一致なら楽観的に再試行します。これによりリーダはライタを一切ブロックしませんが、引き裂かれた値を読みうるため コピー可能な小さい値専用 で、正しさは メモリバリア に支えられます。選択は読み書き比率で決まり、拮抗ならミューテックス、読み多めならrwlock、読み圧倒的・書き稀短・コピー可ならseqlock、ポインタ連結構造の read-mostly なら RCU へと進みます。スピンするか眠るかという土台は スピンロックの設計(ティケット・MCS・qspinlock) が、順序保証の原理は メモリオーダリングとメモリバリア が補強します。
OS Article
リーダ・ライタロックとシーケンスロックの内部を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
rwlock
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
seqlockはシーケンスカウンタで楽観的読み取りを実現。リーダはロックを取らずカウンタを前後で読み、奇数や不一致なら再試行する。リーダはライタを一切ブロックしない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「rwlock / seqlock」に近いか確認する。
- 強みである「rwlockは複数リーダの同時読み取りを許す共有ロック。だがリーダ優先設計はライタ飢餓を、ライタ優先設計はリーダのスループット低下を生み、どちらも一長一短。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。