スピンロックの設計(ティケット・MCS・qspinlock)
スピンロックがコア数に逆スケールする正体はキャッシュ行の奪い合い。ティケットで公平に、MCSでコア局所スピンに、qspinlockで両者の長所を統合するまで、設計の進化を原理から追えます。
- 1.素朴なtest-and-setスピンロックは全コアが1つの変数を奪い合い、解放のたび全待ち手のキャッシュ行が無効化される。待ち手の数に比例して帯域を食い、コア数に逆スケールし公平性もない。
- 2.ティケットロックは番号札方式でFIFO公平を達成するが、待ち手は共通のnow_servingを監視するためバウンシングは残る。MCSロックは待ち手を連結リストに並べ各コアが自分のノードだけを回し、書き込みを局所化してフラットにスケールする。
- 3.Linuxのqspinlockは未競合時は1バイトのfast-path、軽い競合はpendingビット1つ、本格的な競合でのみMCSキューへ移行する三段構えで、低競合の速さと高競合のスケーラビリティを両立する。
素朴なスピンロックがスケールしない理由
スピンロックは「取れるまでCPUを手放さずループで待つ」最小の排他制御です。最も素朴な実装は1つのフラグ変数に対する test-and-set(あるいは CAS)で、概念的には次のようになります。
acquire(lock):
while (atomic_xchg(&lock, 1) == 1) // 1を書き込み、元が1なら失敗→再試行
; // (実機では PAUSE を挟む)
release(lock):
store(&lock, 0)
正しく動きますが、多コアで深刻に劣化します。原因は 同じキャッシュ行の奪い合い です。atomic_xchg は書き込みを伴うため、実行するコアは MESI でそのキャッシュ行を Modified 状態に引き寄せ、他コアの同じ行を Invalid にします。待っている全コアが xchg を撃ち続けるので、ロック変数のキャッシュ行が コア間を行ったり来たり(キャッシュ行バウンシング)します。
決定的に悪いのは解放の瞬間です。保持者が 0 を書くと、待っていた N 個のコアが一斉に xchg を撃ち、1個だけが成功して残り N-1 個は再び全員のキャッシュを無効化 します。待ち手が増えるほど解放1回あたりのコヒーレンシトラフィックが増え、性能が コア数に対して逆スケール します。さらに次に誰が取れるかは完全に運任せで、公平性がなく 運の悪いコアが飢えます。
スピンループに x86 の PAUSE(ARM の WFE / YIELD)を挟むのは、ハイパースレッドの相方へ実行資源を譲り、メモリオーダ違反による無駄なパイプラインフラッシュを減らすための緩和策です。これは消費電力とパイプライン効率を改善しますが、複数コアが同じ行を奪い合う構造そのものは変えません。バウンシングの根治には、待ち手が触る変数を分散させるアルゴリズム上の工夫が要ります。
ティケットロック──公平性は解くが、バウンシングは残る
第一の改善は公平性です。ティケットロックは銀行の番号札方式で、2つのカウンタ next(次に配る札番号)と owner(いま処理中の札番号、now_serving とも)を使います。多くの実装はこの2つを1ワードの上位/下位16ビットに詰め、取得を 1回のアトミックなインクリメント(fetch_and_add)で済ませます。
acquire(lock):
my = atomic_fetch_add(&lock.next, 1) // 自分の札を不可分に確保
while (load(&lock.owner) != my) // 自分の番が来るまで待つ
cpu_relax();
release(lock):
store(&lock.owner, lock.owner + 1) // 次の札へ。アトミック不要
これで 取得順=FIFO が保証され、飢餓が消えます。next の確保が1命令なので、複数コアが同時に来ても札番号は重複せず一意に並びます。
しかしバウンシングは解けていません。全待ち手が 同一の owner を監視するため、保持者が owner を1進めるたびにその行が全待ち手のキャッシュで無効化され、次の保持者だけでなく 無関係な後続の待ち手も巻き込んで 再ロードが走ります。読み込みオンリーになった分は素朴版よりマシですが、競合の本質である「全員が1つの行を見る」構造は残ります。
ティケットロックの貢献は 公平性(FIFO)と飢餓の排除 であって、スケーラビリティそのものではありません。取得が1回の fetch_and_add で済む実装の軽さも利点です。ただし待ち手が増えると owner 監視のバウンシングが効いてきて、コア数が多い環境では依然として性能が頭打ちになります。
MCSロック──各コアが「自分のノード」だけを回す
スケーラビリティの本丸を解くのが MCS ロック(Mellor-Crummey と Scott の名による)です。発想は 待ち手を連結リストに並べ、各コアは自分専用のノードのフラグだけをスピンする こと。これにより、スピン対象の変数がコアごとに分かれ、バウンシングの源を断ちます。
各取得者は自分の node(locked フラグと next ポインタを持つ)をスタックやper-CPU領域に確保します。ロック本体は リストの末尾を指す tail ポインタ だけを持ちます。
struct mcs_node { bool locked; mcs_node *next; };
acquire(lock, node):
node.next = NULL; node.locked = true;
prev = atomic_xchg(&lock.tail, node) // 自分を末尾に繋ぎ、直前の待ち手を得る
if (prev == NULL) return; // ロックは空いていた→即取得
prev.next = node // 前任者のnextに自分を登録
while (node.locked) cpu_relax(); // 自分のlockedだけを回す(ローカルスピン)
release(lock, node):
if (node.next == NULL): // 後続がまだ見えない
if (atomic_cas(&lock.tail, node, NULL)) return; // 末尾が自分なら解放完了
while (node.next == NULL) cpu_relax(); // 後続のリンク確定を待つ
node.next.locked = false; // 後続のフラグだけを書いて起こす
肝は2点です。第一に、待ち手の while (node.locked) は自コアのキャッシュにある行だけを読む ので、他コアからの書き込みが来ない限りキャッシュミスが起きません。第二に、解放は 現保持者が「次のノードの locked を1回 false に書く」だけ で、書き込みが対象1コアの行に限定されます。結果として、保持者交代1回あたりのコヒーレンシトラフィックが 待ち手の数によらず一定 になり、コア数に対して フラットにスケール します。FIFO公平もリスト順で自然に保たれます。
素朴 spin : 全コアが同じ flag を spin → 解放のたび全員のキャッシュ無効化(逆スケール)
ティケット : 番号札でFIFO公平。だが owner は全員が監視 → バウンシング残る
MCS : 各コアは自分のノードだけ spin → 書き込みが局所化、ほぼ無効化なし(フラット)
[保持中:A] → [待ち:B] → [待ち:C] (B,Cは own.locked を回す)
A が解放 → A は B.locked=false を書くだけ → B が進む
MCS の理論上の弱点は 取得側がノードのための領域を引数で渡す必要がある ことです。スレッドのスタックに置くのが定石ですが、これがそのままだとカーネルの汎用 spinlock_t の API(追加引数を取らない)と相性が悪く、また構造体のサイズも膨らみます。さらに 未競合時でも xchg と分岐が要る ため、ロックがほぼ空いている一般的なケースでは素朴版より重くなりがちです。この「低競合の速さ」と「高競合のスケール」の両立が次の qspinlock の主題です。
Linux の qspinlock──三段構えの統合
Linux の spinlock_t は内部的に qspinlock(queued spinlock)で実装されています。これは「未競合は速く、高競合はMCS並みにスケール」を1つの32ビットワードで実現する設計で、状態を3つの領域にパックします。
| フィールド | ビット幅 | 役割 |
|---|---|---|
| locked | 8ビット | ロック保持中フラグ。未競合の取得/解放はこのバイトだけを叩く |
| pending | 1ビット | 「次の1人」を予約。待ち手が1人だけのときMCSキューを作らずに済ませる |
| tail (idx/cpu) | 残りビット | MCSキューの末尾を CPU番号+ネスト段で符号化(ポインタを持たない) |
動作は競合の度合いに応じて段階的にエスカレートします。
- 未競合(fast path):ワード全体が0なら、
lockedバイトに1を書くだけで取得完了。tailを読む必要すらなく、素朴なスピンロックと同等の最小コストで済みます。 - 軽い競合(pending):保持者が1人いるだけなら、2人目は
pendingビットを立ててlockedが下がるのを待つ。MCSノードもキューも作りません。保持者が解放すると、pending を立てた者がlockedを奪って前進します。これで「待ち手が常時1人」という頻出パターンを軽量に捌きます。 - 本格的な競合(MCSキュー):3人目以降が来て初めてMCSキューに入ります。各CPUは per-CPUのMCSノード配列 を持ち、
tailには ポインタではなくCPU番号+ネストインデックスを符号化 して格納します(割り込み等でロック取得がネストしうるため、コンテキスト段ごとにノードを分ける)。これにより32ビットに収めつつ、高競合時は MCSと同じローカルスピン でフラットにスケールします。キューの先頭の待ち手だけが pending/locked を監視し、後続は自分のノードを回す、という役割分担です。
MCSキューは高競合では強力ですが、ノード確保・リンク・解放のオーバーヘッドがあり、待ち手が1人 という最頻ケースには重すぎます。pending ビット1つを挟むことで、この「保持者1+待ち手1」を キュー操作なしの単純スピン で処理でき、MCSの起動コストを払うのを「3人目が来てから」に遅延できます。qspinlock の巧みさは、競合の軽重に応じてfast-path・pending・MCSを切り替える 適応的な三段構え にあります。
なぜ局所スピンがコヒーレンシを救うのか
各方式の本質的な差は「待ち手が監視するアドレスが共有されているか、局所か」に集約されます。共有変数を全員が監視すると、書き換え1回が全待ち手のキャッシュ行を無効化し、トラフィックが待ち手数に比例します。局所変数を各自が監視すれば、保持者交代の書き込みは「次の1コアの行」だけに当たり、トラフィックが一定になります。この一定化が「コア数に対してフラット」というスケーリング特性の正体です。
なぜ「next の locked を false に書く」だけで相手が確実に前進すると言えるか、には メモリオーダリング が絡みます。解放側のストアと取得側のロードの間に適切な acquire/release 順序がないと、クリティカルセクション内の更新が相手から見えないまま先にロック解放だけが見えてしまう恐れがあります。実装はロック取得を acquire、解放を release セマンティクスで発行し、この可視性を保証しています。
押さえる対応関係は 素朴spin →(公平化)→ ティケット →(局所化)→ MCS →(低競合最適化+32ビット化)→ qspinlock という進化と、各段が解く問題です。素朴版=逆スケール&不公平、ティケット=公平だがバウンシング残存、MCS=局所スピンでフラットだがノード領域と未競合コストが課題、qspinlock=fast-path/pending/MCSの三段で両立。Linux の標準 spinlock_t は qspinlock であることも頻出です。割り込み下での使い分けや irqsave の話は カーネルのロック機構 を併読すると全体像が締まります。
まとめ
スピンロックの設計史は キャッシュ行バウンシングと公平性をどう同時に解くか の歴史です。素朴な test-and-set は全コアが1変数を奪い合い、解放のたび全待ち手のキャッシュを無効化して 逆スケール・不公平。ティケットロックは番号札で FIFO公平 を得るが、共通の owner 監視で バウンシングは残る。MCSロックは待ち手を連結リストに並べ 各コアが自分のノードだけを局所スピン することで書き込みを局所化し フラットにスケール するが、ノード領域と未競合時のコストが課題。Linux の qspinlock は fast-path(1バイト)→ pending(1ビット)→ MCSキュー の三段構えで、低競合の速さと高競合のスケーラビリティを32ビットワードに統合しました。土台のキャッシュ機構は MESIプロトコル を押さえると、なぜ局所スピンが効くかまで腑に落ちます。
OS Article
スピンロックの設計(ティケット・MCS・qspinlock)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
spinlock
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
ティケットロックは番号札方式でFIFO公平を達成するが、待ち手は共通のnow_servingを監視するためバウンシングは残る。MCSロックは待ち手を連結リストに並べ各コアが自分のノードだけを回し、書き込みを局所化してフラットにスケールする。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「spinlock / MCS」に近いか確認する。
- 強みである「素朴なtest-and-setスピンロックは全コアが1つの変数を奪い合い、解放のたび全待ち手のキャッシュ行が無効化される。待ち手の数に比例して帯域を食い、コア数に逆スケールし公平性もない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。