カーネルのロックフリー同期とCAS
ロックを使わずに並行データ構造を安全に更新する仕組みを、CASという1命令から原理で理解できます。ABA問題やウェイトフリーとの違い、楽観的同時実行の考え方まで一気につかめます。
- 1.CASは「今の値が期待どおりなら新値に書き換える、違えば失敗」を不可分に行うCPU命令。これを失敗したら読み直して再試行するループにするのがロックフリーの基本形。
- 2.ロックフリーは「どれかのスレッドは必ず前進する」保証。ウェイトフリーは「全スレッドが有限ステップで完了する」より強い保証で、両者は別物。
- 3.CASは値の一致しか見ないため、A→B→Aと戻ると変化を見逃すABA問題が起きる。世代カウンタやハザードポインタ、RCUで対策する。
なぜロックフリーなのか──ロックの構造的な弱点
ロック(ミューテックス)は正しく安全ですが、構造的な弱点を抱えています。鍵を握ったスレッドが、解放する前に スケジューラに横取り(プリエンプト) されると、その間ほかの全スレッドは何もできずに待たされます。優先度の高いスレッドが、鍵を持つ低優先度スレッドの再実行を待つ 優先度逆転 も起きえます。最悪、鍵を持つスレッドが落ちれば、その鍵は永久に返ってきません。
ロックフリー同期は、この「1スレッドの停止が全体を巻き込む」性質を断ち切る設計です。鍵という共有状態を持たず、CPUのアトミック命令だけで 共有データを更新します。土台となる排他制御の全体像は 排他制御とデッドロック を、横取りが起きる仕組みは コンテキストスイッチ を参照してください。
CAS:ロックフリーの土台となる1命令
ロックフリーの心臓部が CAS(Compare-And-Swap、比較交換) です。「指定アドレスの値が期待値と一致するなら新値に書き換え、一致しなければ何もしない」を、途中で割り込まれない1つの不可分な操作として実行します。
CAS(addr, expected, new) -> bool:
アトミックに {
if *addr == expected:
*addr = new
return true # 成功
else:
return false # 失敗(誰かが先に書き換えた)
}
x86 では LOCK CMPXCHG、ARM では LDXR/STXR(LL/SC)として実装されます。重要なのは、CAS が 失敗を返せる こと。失敗は「自分が値を読んでから書こうとするまでの間に、別スレッドが割り込んで更新した」という事実を意味します。この失敗を起点に 読み直して再試行 するのがロックフリーの基本パターンです。
do {
old = atomic_load(&counter) # 現在値を読む
new = old + 1 # 望む新値を計算
} while (!CAS(&counter, old, new)) # old のままなら成功、変わっていたらやり直し
ロックは「衝突するかもしれないから先に守る」悲観的 な戦略。CASは「たぶん衝突しないから、まず計算して最後に確かめる」楽観的 な戦略です。競合が稀ならロック取得・解放のコストがまるごと消え、競合が激しいと再試行が増える。この性質はDBの楽観的ロック(バージョン番号で衝突検知)と同じ発想で、根は1つです。
楽観的同時実行の原理:read-copy-update のループ
CASループの本質は 「読む→新しい状態を作る→一致を確認して差し替える」 の繰り返しです。共有データに直接手を入れず、自分のローカルで望む結果を組み立て、最後の一瞬だけCASで原子的に公開します。
連結リストの先頭に要素を挿入する例で見ます。
node->next = head # ① 今の先頭を新ノードの次に繋ぐ(ローカル操作)
while (!CAS(&head, node->next, node)) {
node->next = head # ② head が変わっていたら読み直してやり直す
}
ポイントは、CASが守る不変条件です。「自分が node->next に記録した先頭と、いま実際の head が同一なら、その間に誰も head を変えていない」とみなせる。だから安全に差し替えられます。誰かが先に挿入していれば head が変わってCASが失敗し、新しい先頭で組み直す。ロックを取らずに、衝突したスレッドだけがやり直す わけです。
CASが原子的に書き換えられるのは基本的に ポインタ1個ぶん(1ワード) です。だからロックフリー構造は「最後の公開を1個のポインタ差し替えに集約する」よう設計します。複数ワードを一括更新したい場合は、隣接2ワードを扱う DWCAS(double-width CAS、x86の CMPXCHG16B) や、後述の世代カウンタの埋め込み、あるいはトランザクショナルメモリ(HTM)に頼ることになります(離れた2アドレスを同時に扱うDCASはハードウェアでは一般に提供されません)。
ロックフリーとウェイトフリーは別物
「ロックを使わない=ロックフリー」ではありません。ノンブロッキングな進行保証は 段階 で定義され、混同は禁物です。
| 進行保証 | 保証の内容 | 代償・特徴 |
|---|---|---|
| ブロッキング(ロック) | 鍵を持つスレッドが止まると、待つ側は前進できない | 実装は素直だが、横取り・優先度逆転に弱い |
| オブストラクションフリー | 他スレッドが止まれば、単独実行なら有限ステップで完了 | 最弱。競合下ではライブロックしうる |
| ロックフリー | システム全体として、常にどれか1スレッドは前進する | 個々のスレッドは飢える可能性がある(再試行し続ける) |
| ウェイトフリー | 全スレッドが他に関係なく有限ステップで完了する | 最強。飢餓なし。実装は格段に難しく重いことが多い |
差は 「誰の前進を保証するか」 にあります。ロックフリーが保証するのは「システム全体の前進」だけ。CASループでは、ある不運なスレッドが毎回CASに失敗し続けて 永久に再試行する(飢餓に陥る) ことが原理上ありえます。それでも「誰か」は必ず成功して進むため、全体は止まりません。
ウェイトフリーはさらに踏み込み、「各スレッドが、他の挙動に関わらず決まった上限ステップで必ず完了する」を保証します。飢餓が起きません。実現には、止まりかけた他スレッドの作業を 手伝って肩代わりする(helping) などの工夫が要り、コストも複雑さも跳ね上がります。リアルタイム系で最悪実行時間の上限が要る場面以外では、実務上ロックフリーで十分なことが多いです。
ロックフリーは 進行保証 の概念であって、性能の保証ではありません。競合が激しいとCASの再試行が増え、キャッシュライン上の同じ行を全コアで奪い合う キャッシュコヒーレンスのトラフィック が膨らみ、かえってロックより遅くなることもあります。「正しさと進行性のための設計」であり、速度は計測して初めて言えるものです。
ABA問題:CASが見ているのは「値」だけ
ロックフリー実装で最も悪名高い罠が ABA問題 です。原因は単純で、CASは「値が一致するか」しか見ない こと。値がいったん別物に変わってから元に戻ると、CASは「変化していない」と誤認します。
ロックフリースタックの pop で起きる典型を追います。
スレッド1: top を読む → A(次は B)。CAS(&top, A, B) を出そうとした直前で横取りされる
スレッド2: A を pop(top は B に)→ B も pop(top は C に)→ A を再利用して再び push(top は A に、ただし A->next は今や C)
スレッド1: 再開。CAS(&top, A, B) は「top はまだ A だ」と見て成功してしまう
→ top を B に書き換える。しかし B はすでに解放済み。リストが壊れる
top の値はA→B→…→Aと 一周して戻った ため、CASは中身が入れ替わった事実に気づけません。A->next がもう B を指していないのに、古い前提のまま top=B を書き込み、解放済みメモリへの参照(use-after-free)を生みます。
ABAが成立するには「読んでからCASまでの間に、値がA→…→Aと戻る」絶妙なタイミングが要ります。低負荷のテストではまず踏まず、本番の高並行・特定スケジューリングでだけ稀に壊れる——並行バグの中でも追跡が極めて難しい部類です。さらにメモリアロケータの再利用が絡むと、解放したノードのアドレスがすぐ再利用されてABAを誘発しやすくなります。
ABA問題への対策
対策はいずれも「値の一致だけでは『変化なし』と断定しない」ための仕組みです。
| 手法 | 考え方 | 向き・コスト |
|---|---|---|
| 世代カウンタ(タグ付きポインタ) | ポインタに更新回数を同梱し、ポインタ+世代をまとめてCAS。世代が違えば一周しても検知 | DWCAS(CMPXCHG16B)等が要る。最も直接的。世代の周回(ラップ)には注意 |
| ハザードポインタ | 各スレッドが「今参照中」と公開。公開されたノードは誰も解放しない | 解放を遅延し安全に回収。スキャンのコストはかかる |
| RCU(Read-Copy-Update) | 読み手はロックなし。書き手はコピーを差し替え、全読み手が抜けてから旧版を解放 | 読み多寡が激しい場面に最適。カーネルで多用される |
| GC任せ | 解放しないので再利用されず、ABA自体が起きにくい | 管理環境向け。手動メモリ管理には使えない |
最も直接的なのが 世代カウンタ。ポインタの隣に「更新通し番号」を置き、ポインタと世代を一括してCAS します。A→…→Aと値が戻っても世代は必ず進んでいるため、CASが正しく失敗します。x86 の CMPXCHG16B で 16 バイトをまとめて比較交換するのが定石です。
カーネルで広く使われるのが RCU です。読み手はロックもCASも使わずほぼ素通りで読み、書き手は 旧データを書き換えず新しいコピーを作って、ポインタ差し替え1発で公開 します。旧バージョンは「いま読んでいる全スレッドが読み終える(猶予期間=grace period を抜ける)」のを待ってから解放するため、参照中のメモリが消えずABAやuse-after-freeを避けられます。Linux カーネルがルーティングテーブルや設定の参照で多用する、読み取り偏重に極めて強い手法です。
- CAS は「期待値と一致すれば書き換え、違えば失敗」を アトミック に行う。失敗時に読み直して再試行するのがロックフリーの基本形。
- ロックフリー=「全体として常にどれかが前進」(個々は飢餓しうる)。ウェイトフリー=「全スレッドが有限ステップで完了」(より強い)。
- ABA問題=CASが値の一致しか見ないため、A→B→Aの往復を見逃す。対策は 世代カウンタ / ハザードポインタ / RCU。
- ロックフリーは 進行保証 の話であり「速い」とは限らない。競合が激しいと再試行とキャッシュ競合で遅くなりうる。
まとめ──楽観的に進み、確認の瞬間だけ原子的に
ロックフリー同期は、鍵という共有状態を持たず CASだけ で共有データを更新する設計です。CAS は「期待値と一致すれば書き換え、違えば失敗」を不可分に行う命令で、失敗したら読み直して再試行する 楽観的同時実行 が基本形。これにより「1スレッドの停止が全体を止める」ロックの弱点を断てます。ただし保証されるのは進行性であり、ロックフリー(全体は必ず前進、個々は飢餓しうる)と ウェイトフリー(全スレッドが有限ステップで完了)は別物。そして ABA問題——CASが値しか見ないために A→B→A の往復を見逃す罠——を 世代カウンタ・ハザードポインタ・RCU で塞ぐ必要があります。なぜCASだけで順序が守れるのかは メモリオーダリングとメモリバリア が、スレッドそのものの基礎は プロセスとスレッド が土台になります。
OS Article
カーネルのロックフリー同期とCASを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ロックフリー
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
ロックフリーは「どれかのスレッドは必ず前進する」保証。ウェイトフリーは「全スレッドが有限ステップで完了する」より強い保証で、両者は別物。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「ロックフリー / CAS」に近いか確認する。
- 強みである「CASは「今の値が期待どおりなら新値に書き換える、違えば失敗」を不可分に行うCPU命令。これを失敗したら読み直して再試行するループにするのがロックフリーの基本形。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。