カーネルのロック機構(spinlock・mutex・seqlock)
カーネルのロックは種類ごとに「眠れる場所・眠れない場所」が決まっている。spinlock・mutex・rwlock・seqlock・RCUの使い分けと、割り込み下での制約、スケールするロックの仕組みまでつかめます。
- 1.待つときに眠るか回すかでロックは二分される。割り込みコンテキストやクリティカルセクション内ではスケジュールできないため、眠るmutexは使えずspinlock一択になる。
- 2.読み多寡で道具を変える。読み手だけ複数許すrwlock、書き手を待たせず読み手を一切ブロックしないseqlock、読み手側のコストをほぼ0にするRCUと段階がある。
- 3.素朴なspinlockはキャッシュ行の奪い合いでコア数に逆スケールする。ティケットロックで公平に、MCSロックで各コア別の領域を回すことでスケーラビリティを回復する。
待ち方の二択──眠るか、回すか
カーネルのロックを理解する第一の軸は、ロックが取れなかったとき何をするか です。選択肢は2つしかありません。
- 眠る(sleep / block):自スレッドを実行可能列から外し、CPUを別タスクに譲る。再びロックが空いたら起こしてもらう。
- 回す(spin / busy-wait):CPUを手放さず、取れるまでループで確認し続ける。
眠るほうはCPUを無駄にしませんが、コンテキストスイッチ 2回ぶん(眠るとき・起きるとき)のコストがかかります。回すほうは切り替えコストがゼロな代わり、待っている間ずっとCPUを焼き続けます。
この差から原則が決まります。ロックを保持する時間が「コンテキストスイッチ2回より短い」と見込めるなら回す(spinlock)、長くなりうるなら眠る(mutex)。クリティカルセクションが数命令で終わるなら spinlock、I/O待ちやメモリ確保を含むなら mutex、が大づかみの基準です。
眠る/回すは速さの選択に見えて、文脈によっては 選択の余地がない ことがあります。割り込みハンドラの中やスケジューラを止めた区間では、そもそも眠れません。眠ろうとした瞬間にカーネルがハングします。次節がその核心です。
割り込みコンテキストでは眠れない
カーネルのコードが走る文脈は大きく2つ。プロセスコンテキスト(システムコールなどタスクの延長で走る)と 割り込みコンテキスト(ハードウェア割り込みで叩き起こされて走る)です。決定的な違いは次の点にあります。
割り込みコンテキストには「眠って後で戻ってくる先」のタスクが存在しません。 割り込みは特定のタスクに紐づかず割り込んできただけなので、ここでスケジュールを呼んで眠ると、何を起こせばいいか分からなくなり、システムが壊れます。だから割り込みコンテキストでは 眠る系のロック(mutex / semaphore)は禁止。使えるのは spinlock だけです。
さらに厄介なのがデッドロックの一種です。あるコアがプロセスコンテキストで spinlock を保持したまま、同じコアに割り込みが入り、割り込みハンドラが同じロックを取りに行く と、ハンドラはロックが空くのを spin で待ち、しかしロックを持つ本体は割り込みが終わるまで再開できない——自分自身を永久に待つ 状態に陥ります。
コアX: spin_lock(L) ← プロセス文脈でLを保持
┊(クリティカルセクション中)
割り込み発生 ───► ハンドラ: spin_lock(L) ← 同じLを取りに来る
本体は割り込み終了まで進めない
ハンドラは本体がLを離すまで spin
→ 同一コアで自己デッドロック
これを防ぐため、割り込みハンドラと共有する spinlock は、ローカル割り込みを無効化してから取得 します。Linux の spin_lock_irqsave() が典型で、ロック取得前に当該コアの割り込みを止め、解放時に元へ戻します。
| 状況 | 使うべきロック | 理由 |
|---|---|---|
| 割り込みハンドラ内 | spinlock のみ | 眠れない。mutex/semaphore は使用不可 |
| 割り込みと共有する区間 | spin_lock_irqsave | 同一コアの割り込み再入による自己デッドロックを防ぐ |
| プロセス文脈・短時間保持 | spinlock | 切り替えコストより spin が安い |
| プロセス文脈・長時間/眠る処理を含む | mutex / semaphore | CPU を他へ譲れる。spin だと無駄焼き |
spinlock を握っている間は そのコアはプリエンプションも無効 にされます。この区間で眠りうる関数(kmalloc(GFP_KERNEL) のようなメモリ確保、copy_from_user、mutex_lock など)を呼ぶのは重大なバグです。眠った瞬間、ロックを持ったまま別タスクへ切り替わり、最悪システム全体が固まります。spinlock のクリティカルセクションは「絶対に眠らない最小限のコード」に保つのが鉄則です。
読み多寡で選ぶ──rwlock・seqlock・RCU
共有データが 圧倒的に読まれ、たまにしか書かれない 場合、読み手どうしまで排他するのは無駄です。そこで読みの並行度を上げる道具が段階的に用意されています。
- rwlock(リーダー/ライターロック):読み手は複数同時に入れ、書き手だけが排他。ただし読み手も内部カウンタの更新でキャッシュ行を共有するため、コア数が増えると読み手間でも競合します。書き手が来ると読み手は締め出されます。
- seqlock(シーケンスロック):書き手を最優先にする発想。書き手は書き込みの開始時にシーケンス番号を偶数→奇数へ、終了時に奇数→偶数へと2回進めるだけで(=書き込み中は番号が奇数)、読み手を一切ブロックしません。読み手はロックを取らず、読む前後でシーケンス番号を確認し、途中で書き手が入った(番号が変わった/奇数だった)ら読み直す 楽観的な方式です。
- RCU(Read-Copy-Update):読み手側のコストをほぼゼロにする究極形。読み手はロックもアトミック操作もせず、ただポインタを辿るだけ。更新側は 既存データを書き換えず、コピーを作って差し替え、旧データは「全ての既存読み手が読み終えた」ことを保証してから解放します。
seqlock の読み手(リトライする楽観読み):
do {
s = read_seqbegin(&sl) // 開始時のシーケンス番号
... 共有データを読む ...
} while (read_seqretry(&sl, s)) // 途中で書かれていたら最初からやり直し
| 機構 | 読み手のコスト | 読み手は書き手を待つ? | 向く場面 |
|---|---|---|---|
| rwlock | ロック取得あり(中) | 待つ(書き手が排他) | 読みが多いが書きもそれなりにある |
| seqlock | ロックなし・リトライあり | 待たない(書き手優先) | 小さく整合性の要る値(時刻・統計) |
| RCU | ほぼ0(ポインタ参照のみ) | 待たない | 読みが極端に多いリスト/木構造 |
seqlock の読み手は「整合性が崩れた状態を一瞬読みうる」前提でリトライします。よって 読んだ途中値で副作用を起こしてはならず、読みは「コピーして取り出すだけ」に徹します。また書き手が頻繁だと読み手が延々とリトライしてスターベーション気味になるため、seqlock は 読みが多く書きがまれ な前提でこそ活きます。時刻情報(jiffies/gettimeofday 系)の保護が代表例です。
RCU の難所は更新ではなく 回収(reclaim) です。差し替え直後に旧データを解放すると、まだ旧ポインタを辿っている読み手が壊れます。そこで「全ての既存読み手がクリティカルセクションを抜けた」時点=猶予期間(grace period) の経過を待ってから解放します。読み手が一切のコストを払わない代償を、更新側がこの待ちで引き受ける——これがRCUが読み偏重で劇的に速い理由です。排他の基礎は 排他制御とデッドロック も合わせて。
spinlock はなぜスケールしないか──ティケットとMCS
最後に、spinlock そのものの性能問題です。最も素朴な spinlock は、1つのフラグ変数に対し全コアが CAS を撃ち続けます。これが多コアで深刻に劣化します。
原因は キャッシュ行の奪い合い です。複数コアが同じフラグに書き込もうとすると、そのキャッシュ行が コア間を行ったり来たり(キャッシュコヒーレンシのバウンシング)します。待ち手が増えるほどこのトラフィックが増え、コア数に対して逆スケール し、しかも誰が次に取れるか不定で 公平性もありません(運の悪いコアが飢える)。
これを2段階で改善します。
- ティケットロック:銀行の番号札方式。取得時に
nextを atomic に1増やして自分の番号(札)を受け取り、now_servingが自分の番号になるまで待ちます。FIFO順で公平 になり、飢餓が消えます。ただし全コアが同じnow_servingを読むため、解放のたびに全待ち手のキャッシュが無効化 され、バウンシング自体は残ります。 - MCSロック:待ち手を 連結リスト に並べ、各コアは 自分専用のローカル変数だけを spin します。解放時は、現保持者がリスト上の次のコアのローカル変数だけを1つ書き換えて起こします。書き込みが1コアの行に限定されるため コア間バウンシングがほぼ消え、FIFO公平も保ったまま コア数に対してフラットにスケール します。
素朴な spinlock : 全コアが同じ flag を spin → 解放のたび全員のキャッシュ無効化
ティケットロック : 番号札でFIFO公平。だが now_serving は全員が見る → 無効化は残る
MCSロック : 各コアは自分のノードだけ spin → 書き込みが局所化、ほぼ無効化なし
MCS のイメージ:
[保持中:A] → [待ち:B] → [待ち:C] (各々が own.locked を spin)
A が解放 → A は B.locked を false に書くだけ → B が進む
Linux の標準 spinlock は内部的に MCS系のキューロック(qspinlock) で実装され、素朴な spin の逆スケール問題を解消しています。覚えるべき対応関係は、素朴 spin →(公平化)→ ティケット →(局所化)→ MCS という改善の流れと、それぞれが解く問題(公平性/キャッシュ行バウンシング)です。並べ替えとキャッシュの土台は メモリオーダリングとメモリバリア を押さえると、なぜ局所spinが効くかまで腑に落ちます。
まとめ
カーネルのロックは 待ち方(眠る mutex/回す spinlock) が第一の軸。割り込みコンテキストやプリエンプション無効区間では眠れない ため spinlock 一択で、割り込みと共有するなら ローカル割り込みを無効化(irqsave)して自己デッドロックを防ぎます。読み偏重なら rwlock → seqlock(書き手優先・読み手リトライ)→ RCU(読み手ほぼ0コスト・猶予期間で回収) と並行度を段階的に上げられます。spinlock 自体は素朴な実装が キャッシュ行バウンシングで逆スケール するため、ティケットロックで公平化、MCSロックで各コア局所spin化 してスケーラビリティを回復します。土台となるスレッドの基礎は プロセスとスレッド も合わせてどうぞ。
OS Article
カーネルのロック機構(spinlock・mutex・seqlock)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
カーネル
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 5
導入後に効く点
読み多寡で道具を変える。読み手だけ複数許すrwlock、書き手を待たせず読み手を一切ブロックしないseqlock、読み手側のコストをほぼ0にするRCUと段階がある。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 5
判断チェックリスト
- 自社の用途が「カーネル / spinlock」に近いか確認する。
- 強みである「待つときに眠るか回すかでロックは二分される。割り込みコンテキストやクリティカルセクション内ではスケジュールできないため、眠るmutexは使えずspinlock一択になる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。