排他制御とデッドロック
複数の処理が同じデータを同時にいじると壊れる。それを防ぐのが排他制御(ロック)で、ロックの掛け方を誤ると今度は全員が固まるデッドロックに陥る。
- 1.複数のスレッド/プロセスが共有データを同時に触ると競合状態(レースコンディション)が起き、結果が壊れる。
- 2.守りたい範囲(クリティカルセクション)をロック/ミューテックス/セマフォで「同時に1つだけ」に制限するのが排他制御。
- 3.ロックの掛け方を誤ると互いに待ち合って固まるデッドロックに。成立4条件のどれか1つを崩せば防げる。
なぜ排他制御が要る?──競合状態(レースコンディション)
複数の処理が同じメモリを共有していると、実行の順番(タイミング)次第で結果が変わってしまう ことがあります。これが競合状態(レースコンディション)です。
典型例が「カウンタを 1 増やす」だけの操作。コード上は1行でも、CPUの中では 読む → 足す → 書く の3ステップに分かれます。
counter++ は実際にはこの3手:
1. counter の値をレジスタに読む (read)
2. レジスタの値に 1 を足す (modify)
3. 結果を counter に書き戻す (write)
スレッドAとBが両方 counter++(初期値0)を実行すると、運が悪いとこう絡みます。
時刻 スレッドA スレッドB counter
t1 read(0) 0
t2 read(0) 0 ← Aの加算前を読んでしまう
t3 add → 1 0
t4 add → 1 0
t5 write(1) 1
t6 write(1) 1 ← 2回足したのに1!
2回足したのに結果は 1。これを 更新の喪失(lost update) と呼びます。怖いのは、たいてい正しく動いてしまい、ごく稀にしか壊れない こと。テストをすり抜け、本番の高負荷時だけ再現する——並行バグが「再現しないバグ」の代表格なのはこのためです。
ソース上で1行でも、機械語では複数命令に分かれていれば 途中で割り込まれる(別スレッドに切り替わる)余地があります。安全に「分割不能(アトミック)」と言えるのは、CPUやライブラリがそう保証している操作だけ。見た目の行数とアトミック性は無関係です。
クリティカルセクションとロック
競合が起きる「共有データを触る危険な区間」を クリティカルセクション と呼びます。ここを 同時に1つの処理しか実行できない(相互排他=mutual exclusion) ようにすれば、競合は防げます。
そのための最も基本的な道具が ロック です。クリティカルセクションに入る前に鍵を取得(lock / acquire)し、出るときに返す(unlock / release)。鍵が取られている間、他の処理は待たされます。
lock(mtx) // 鍵を取る(誰かが持っていたら待つ)
counter = counter + 1 // ← クリティカルセクション(1人だけ)
unlock(mtx) // 鍵を返す(待っている誰かが入れる)
こうすれば先ほどの「read → add → write」が 割り込まれずにひとまとまりで実行 され、更新の喪失が起きません。
クリティカルセクションの中で 例外/エラーで早期 return したり、解放を書き忘れたりすると、鍵を持ったまま消えて 他の全員が永久に待つ ことになります。try/finally での解放、C++ の RAII(lock_guard)、Go の defer mu.Unlock()、Rust の所有権ベースの Mutex(スコープを抜けると自動解放)など、取りこぼし防止の仕組み を使うのが鉄則です。
ミューテックスとセマフォ
排他制御の代表的な部品が ミューテックス(mutex) と セマフォ(semaphore) です。名前が似ていて混同されがちですが、役割が違います。
- ミューテックス:鍵は1本。「同時に1つだけ」を保証する(相互排他=mutual exclusion の略)。基本的に ロックした本人が解放する(所有者の概念がある)。
- セマフォ:内部に カウンタ を持ち、「同時にN個まで」を許す仕組み。
acquireで1減らし(0なら待つ)、releaseで1増やす。所有者の縛りはなく、ある処理がacquire、別の処理がreleaseしてもよい。
カウンタ上限を 1 にしたセマフォ(バイナリセマフォ)はミューテックスに似ますが、「所有者が解放する」という制約がない点が異なります。用途も、ミューテックスが 資源の保護(クリティカルセクションを守る)なのに対し、セマフォは 資源の個数管理 や 処理の順序づけ(シグナル通知) に向きます。
| 観点 | ミューテックス | セマフォ |
|---|---|---|
| 許す同時実行数 | 1つだけ(相互排他) | N個まで(カウンタで管理) |
| 所有者の概念 | ロックした本人が解放 | 誰が release してもよい |
| 主な用途 | 共有データの保護 | 資源数の制限・順序づけ・通知 |
| 典型例 | カウンタや構造体の更新 | コネクションプール(同時5接続まで等) |
| 上限1のとき | 本来の使い方 | バイナリセマフォ(ロック的にも使える) |
- スピンロック:待つときにスレッドを眠らせず、取れるまで ループで確認し続ける(spin)。切り替えコストが惜しい短時間の待ちに向くが、長く回すとCPUを無駄食いする。
- リーダー/ライター(RW)ロック:読むだけなら複数同時にOK、書くときだけ排他。読み取りが圧倒的に多い場面で並行性を上げられる。
- 条件変数:「ある条件が整うまで眠り、整ったら起こしてもらう」待ち合わせの仕組み。ミューテックスとセットで使う。
アトミック操作:ロックを使わない選択肢
ロックを取らずに競合を防ぐ手もあります。CPUが提供する アトミック操作 の利用です。アトミック(atomic=それ以上分割できない)とは、「読む→変える→書く」が他から割り込まれない1つの不可分な操作 として実行されること。
代表が CAS(Compare-And-Swap / 比較交換)。「今の値が期待どおりなら新しい値に書き換える。違っていたら何もしない」を1命令でやります。
CAS(addr, expected, new):
もし *addr == expected なら *addr = new として成功
そうでなければ失敗(誰かが先に書き換えた)
→ これら全体が割り込まれない(アトミック)
CAS を「成功するまで再試行」する形にすると、ロックなしで(lock-free)安全なカウンタやキュー が作れます。ロック待ちで眠らせる必要がなく、デッドロックも原理的に起きにくいのが利点。一方で正しく書くのは難しく、ABA問題(値がA→B→Aと戻ると「変わっていない」と誤認する)などの落とし穴もあります。
- 単純な値の更新(カウンタ、フラグ、参照の差し替え)→ アトミック変数 / CAS が軽くて速い。
- 複数の変数をまとめて整合的に更新したい → ロック が素直で安全(アトミック1発では守りきれない)。 迷ったらまずロックで正しく書き、ホットパスで詰まってから最適化として lock-free を検討する、で十分です。
デッドロック:守りすぎて全員が固まる
ロックで安全にしたつもりが、今度は 互いに相手の鍵を待ち合って、誰も先に進めなくなる ことがあります。これが デッドロック(deadlock) です。
古典的なのが「ロックを取る順番の食い違い」。送金処理で、口座AとBを両方ロックしたいとします。
スレッド1: lock(A) → (Bを取りたい)→ lock(B)
スレッド2: lock(B) → (Aを取りたい)→ lock(A)
t1: スレッド1が A を確保
t2: スレッド2が B を確保
t3: スレッド1は B を待つ(2が持っている)
t4: スレッド2は A を待つ(1が持っている)
→ 互いに永久に待つ=デッドロック
デッドロック成立の4条件(Coffman条件)
デッドロックは、次の 4つが同時に成り立つとき に発生します。逆に言えば、どれか1つでも崩せば防げます。
| 条件 | 意味 | 崩す対策の例 |
|---|---|---|
| 相互排他 | 資源は同時に1つしか使えない | 資源を共有可能にする(読み取り専用化など。崩しにくい) |
| 保持と待機 | 鍵を持ったまま別の鍵を待つ | 必要な鍵を最初に全部まとめて取る/取れなければ手放す |
| 横取り不可(非プリエンプション) | 他人が握る鍵を奪えない | 一定時間で諦めて持っている鍵を解放しリトライ |
| 循環待ち | 待ち関係が輪を描く(A→B→A) | 全ロックに順序を決め、必ずその順で取得する |
実務で一番効くのは 「循環待ち」を崩す こと。ロックに グローバルな取得順序(例:口座IDの小さい方から取る)を決め、全員がその順で取る ようにすれば、輪が閉じず先の送金例も解消します。
ルール: ID が小さい口座から先にロックする
スレッド1(A→B送金): lock(min(A,B)) → lock(max(A,B))
スレッド2(B→A送金): lock(min(A,B)) → lock(max(A,B))
→ 両者とも同じ順で取るので、循環待ちが起きない
デッドロックの典型原因は、ある関数が「Aの次にB」、別の関数が「Bの次にA」と バラバラの順序でロックを取る こと。複数ロックをまたぐ場合は、コードベース全体で取得順序を一本化 するのが最も確実な予防策です。ネストするロックは可能なら設計で減らしましょう。
デッドロックと紛らわしい状態
「進まない」系の障害はデッドロックだけではありません。区別できると原因の切り分けが速くなります。
| 状態 | 何が起きているか | 進行は? |
|---|---|---|
| デッドロック | 互いに資源を待ち合って輪になり、誰も動けない | 完全に停止(自力では解けない) |
| ライブロック | 譲り合い等で状態は変わるが前に進まない | 動いているが仕事が進まない |
| スターベーション(飢餓) | 優先度等で特定の処理だけ鍵を取れない | 他は進むが、その処理だけ進めない |
「とにかく全部ロックで囲めば安全」は半分正解で半分間違い。広すぎる範囲を1本の鍵で守ると、並行に動けるはずの処理まで直列化され、せっかくのマルチコアが活きません(スケールしない)。クリティカルセクションは 「本当に共有データを触る最小限」 に絞るのが原則。ここは プロセスとスレッド の理解とセットで考えると腑に落ちます。
まとめ
共有データを複数で同時にいじると壊れる(競合状態)。守りたい区間(クリティカルセクション)を ロック / ミューテックス / セマフォ で「同時に1つ(またはN個)」に制限するのが 排他制御。単純な更新なら アトミック操作(CAS) でロックなしにもできます。一方、ロックの取り方を誤ると デッドロック——でも 4条件のどれか1つ(実務では取得順序の統一で「循環待ち」)を崩せば防げる。「正しく守る」と「守りすぎない」の両立がコツです。スレッドそのものの基礎は プロセスとスレッド、CPUがどう切り替えるかは スケジューリング、待ち時間の扱い方は 同期処理と非同期処理 も合わせてどうぞ。
OS Article
排他制御とデッドロックを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
排他制御
比較で見る軸
難易度: intermediate / カテゴリ: OS / タグ数: 4
導入後に効く点
守りたい範囲(クリティカルセクション)をロック/ミューテックス/セマフォで「同時に1つだけ」に制限するのが排他制御。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- intermediate
- カテゴリ
- OS
- タグ数
- 4
判断チェックリスト
- 自社の用途が「排他制御 / ロック」に近いか確認する。
- 強みである「複数のスレッド/プロセスが共有データを同時に触ると競合状態(レースコンディション)が起き、結果が壊れる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。