排他制御
Mutual Exclusion ・ はいたせいぎょ
共有資源に同時に触れるのを1人に制限する仕組み。ミューテックスやセマフォで競合状態を防ぐ。
概要
排他制御(mutual exclusion)は、複数の処理が同じデータや資源に同時に触れるのを防ぎ、「この区間は一度に1人だけ」という約束を強制する仕組みです。銀行口座の残高を書き換える、在庫数を減らす、共有カウンタを更新する — こうした「みんなで一つのものをいじる」場面で、順番を守らせて結果が壊れないようにするのが役目です。
なぜ順番を守らせる必要があるのかは、並行と並列の世界を思い浮かべると分かります。複数のスレッドが同じ変数を「読んで・計算して・書き戻す」という一連の操作を、示し合わせずに同時に行うと、互いの更新を上書きし合って結果が壊れます。排他制御は、この危うい区間の入口に「一度に一人しか通れない扉」を置く道具立てだと考えると、全体像がつかめます。
代表的な道具がミューテックス(mutex=mutual exclusion の略)とセマフォ(semaphore=もとは鉄道の腕木信号機)です。どちらも「同時に触れる人数を制限する」点は同じですが、制限のかけ方が違います。まずは、そもそもなぜこんな仕組みが必要なのかを見ていきます。
なぜ生まれたか
排他制御が生まれた背景には、「読んで・計算して・書き戻す」という何気ない操作が、実は複数の段階に分かれているという事実があります。たとえば「カウンタに1を足す」という一行のコードも、内部では「現在値を読む」「1を足す」「書き戻す」の3ステップに分解されます。1スレッドで動く限りこの3ステップは一息に終わりますが、2つのスレッドが同時に走ると、片方が「読んで」から「書き戻す」までの隙間に、もう片方が割り込んで同じ古い値を読んでしまうことが起こります。
その結果、両者がそれぞれ「10を読んで11を書き戻す」ため、本来12になるべき値が11で止まる — 片方の加算がまるごと消えます。これがロストアップデート(更新の喪失)であり、実行のタイミング次第で結果が変わってしまうこの現象を競合状態(race condition)と呼びます。厄介なのは、たいていうまく動いてしまい、負荷が高いときにだけ稀に壊れる点で、再現もデバッグも難しい種類のバグです。
この問題を根本から断つには、「読んで・計算して・書き戻す」の一連を、他者に割り込ませない不可分な区間として扱うしかありません。つまり「この区間に入れるのは一度に一人」というルールを機械的に保証する仕組みが要る — それが排他制御であり、ミューテックスやセマフォという道具として結実しました。
詳細
クリティカルセクションとロストアップデート
排他制御が守るべき「一度に一人しか入ってはいけない区間」を、クリティカルセクション(危険区間)と呼びます。共有データを読み書きする、まさにその数行です。まず、保護しない場合に何が起きるかを時系列で見てみましょう。
Aが読んでから書き戻すまでの隙間にBが割り込み、同じ古い値10を読んでしまいました。二人がそれぞれ1を足したのに結果は11 — これがロストアップデートです。この「更新が消える瞬間」を、下の実験で自分の手でステップを進めながら起こしてみてください。
- ① counter を読む
- ② +1 する
- ③ 書き戻す
- ① counter を読む
- ② +1 する
- ③ 書き戻す
まず A→A→B→B… ではなく、A→B→A→B… と交互にステップを進めてみてください。両方が同じ値を読んでしまう瞬間が見られます。
ミューテックス — 所有者は常に一人
この問題への最も基本的な答えがミューテックスです。ミューテックスは「鍵のかかる部屋」のようなもので、クリティカルセクションに入る前にロック(施錠)し、出るときにアンロック(解錠)します。ロックを取れるのは一度に一人だけで、すでに誰かが握っていれば、後から来たスレッドは解放されるまで扉の前で待たされます。ロックした本人だけがアンロックできる — この「所有者が一人」という性質がミューテックスの核心です。先ほどの例なら、Aがロックを握っている間はBが読み書きに入れないため、Aが11を書き戻してアンロックしてからBが動き出し、正しく12になります。
ロックによって二人の操作が直列化され、割り込みの隙間が消えました。
セマフォ — N人まで通す
セマフォはミューテックスを一般化した仕組みで、内部にカウンタを持ち「同時にN人まで入ってよい」を表現します。入るたびにカウンタを1減らし、出るたびに1増やし、0になったら後続を待たせます。カウンタの上限を1にしたセマフォはミューテックスとほぼ同じ働きになりますが、両者には設計思想の違いがあります。ミューテックスは「所有権」の概念を持ち、ロックした本人しか解除できないのに対し、セマフォは所有者を問わず、あるスレッドが増やしたカウンタを別のスレッドが減らすといった使い方もできます。このため、セマフォは「同時アクセス数を3までに絞る」といった資源数の制限や、「片方が準備できたらもう片方を起こす」といったスレッド間の合図(シグナル)にも使われます。用途としては、一人に絞りたいならミューテックス、人数や合図を扱いたいならセマフォ、と覚えると迷いません。
原子操作とCAS — ロックを使わない道
ロックを取って待たせる方式のほかに、そもそも「読んで・計算して・書き戻す」を分割不能な一手にしてしまうアプローチもあります。CPUが提供する原子操作(アトミック操作)で、途中で割り込まれないことがハードウェアレベルで保証されます。その代表がCAS(Compare And Swap)で、「値が今もXならYに書き換えよ、違えば失敗を返せ」を一手で行います。失敗したら読み直してやり直す — この「ロックを取らずに、ぶつかったらやり直す」設計をロックフリーと呼びます。待ちが発生しないため高速になりうる一方、ぶつかりが多いとやり直しが増え、正しく組むのは非常に難しいため、通常はライブラリや言語ランタイムの内部で使われます。ここでは「ロックの外にもう一つの世界がある」ことだけ押さえておけば十分です。
ロックの粒度というトレードオフ
排他制御の設計で常に問われるのが、ロックの粒度です。粗いロック(たとえばデータ全体を一つのロックで守る)は、扱いが単純で安全ですが、本来は無関係な操作まで順番待ちさせるため並列性が死に、遅くなります。細かいロック(データの一部分ごとに別々のロックを持つ)は、無関係な操作を同時に走らせられて速い反面、複数のロックを扱うことになり、取り忘れやデッドロック — 互いに相手のロックを待ち続けて全員が止まる状態 — の危険が跳ね上がります。「安全だが遅い」と「速いが危険」の間のどこに線を引くかが、排他制御の腕の見せどころです。
実務での顔ぶれ
排他制御は低レベルの道具に見えて、実務のあちこちに顔を出します。データベースでは、同じ行を同時に更新させないための行ロックがトランザクションの一貫性を支えています。1台のマシンを越えて複数サーバで「一度に一人」を保証したいときは、分散ロックという分散版の排他制御が必要になります。
一方で、近年は「そもそも共有しなければ排他制御は要らない」という発想も力を持っています。Go言語はメモリを共有してロックで守る代わりに、チャネルを通じてデータの受け渡しで通信する設計を推奨し、Rustは所有権という言語仕様で「同じデータを複数の書き手が同時に持つこと」をコンパイル時に禁じます。共有するデータを最初から書き換え不能にするイミュータビリティ(不変性)も、同じ問題への別解です — 書き換えなければ、更新がぶつかることもないからです。ロックは強力な道具ですが、「ロックを賢く使う」のと同じくらい「ロックが要らない構造にする」ことも、並行プログラミングの重要な引き出しです。
