条件変数とモニタ・スレッド間同期の原理
なぜ条件変数の待ちは必ずwhileで囲むのか。モニタの相互排他とwait/signalの意味論、Mesa対Hoareの違いから、再検査ループが「作法」ではなく「必然」である理由まで原理でつかめます。
- 1.モニタは相互排他(一度に1スレッドだけがクリティカル領域に入る)と条件待ち(条件が整うまでロックを手放して眠る)を1つにまとめた同期の枠組みで、条件変数はその「待ち行列」を担う部品。
- 2.waitはロック解放・眠り・再取得をアトミックに行い、signalは待ち手を1つ、broadcastは全員を起こす。起こされた側がロックを取り直すまでの間に条件が崩れうるのがすべての鍵。
- 3.現実の実装はほぼMesaセマンティクス。signalは「条件が整ったかもしれない」ヒントにすぎず、再取得後に条件が偽でありうるため、待ちは必ず while(条件が偽) wait() で再検査する。spurious wakeupもこのループが吸収する。
モニタとは何か──排他と待ちを1つに束ねる
複数スレッドが共有データを安全に扱うには、2つの能力が要ります。相互排他(同時に1スレッドしか触れない)と、条件待ち(必要なデータがまだ揃っていなければ、譲って待つ)です。ミューテックスは前者しか提供しません。後者を素朴に「ロックを持ったままループで条件を見張る」とやると、保持者が回り続けて誰も条件を整えられず、デッドロック同然になります。
モニタ(monitor) は、この2つを1つの抽象にまとめた同期の枠組みです。モニタは「共有データ+それを操作する手続き」を囲い、入口で自動的に相互排他をかけます。つまりモニタ内のコードを実行できるのは常に1スレッドだけ。そして条件待ちのために 条件変数(condition variable) を持ち、3つの操作を提供します。
| 操作 | 意味 | ロックの扱い |
|---|---|---|
| wait(cv) | 条件が整うまで眠る | モニタのロックを解放して眠り、起こされたら再取得する(この3つが原子的) |
| signal(cv) | この条件で待つスレッドを1つ起こす | 保持したまま呼ぶ。起こされた側はロックを取り直してから進む |
| broadcast(cv) | この条件で待つスレッドを全員起こす | 同上。全員が順にロックを取り直す |
ここで決定的に重要なのは、条件変数はそれ自身に値や状態を持たない点です。条件変数は単なる「待ち行列」であり、「キューが空でない」「バッファに空きがある」といった述語(条件)は、別の共有変数として読者自身が管理します。条件変数は「その述語が偽の間、効率よく眠って待つ」ための受け皿にすぎません。
waitの原子性──なぜ「解放・眠り・再取得」をまとめるのか
wait の意味論は一見3ステップですが、最初の2つは 不可分 でなければなりません。擬似コードで書くとこうです。
wait(cv, lock):
アトミックに {
lock を解放する // ① 他スレッドが条件を整えられるように
自分を cv の待ち行列に入れて眠る // ②
}
起床後:
lock を再取得する // ③ モニタ内に戻る(取れるまでブロック)
①と②を分離すると、致命的な隙間が開きます。スレッドAが「条件が偽だから待とう」とロックを解放した直後、まだ待ち行列に入る前に、スレッドBが条件を整えて signal を撃ったとします。Bのsignalは「まだ待ち行列にいないA」には届かず空振りし、その後Aが眠ると——起こす人がもういない状態で永久に眠り続けます。これは futexの内部動作 で扱う lost wakeup(wakeupの取りこぼし)と同根の問題で、だからこそ「解放と待ち行列登録」を原子的に行う必要があります。
pthread_cond_wait(cond, mutex) がミューテックスを引数に取るのは飾りではありません。「条件の評価」と「待ちに入る決定」が同じロックの保護下で行われ、かつ解放と登録が原子的であって初めて、取りこぼしのない待ちが成立します。条件を裸で読んでからロックなしで wait する設計は、必ずどこかで signal を取りこぼします。条件変数とミューテックスを常にペアで使う作法は、この原子性の前提そのものです。
③で 必ずロックを取り直してから戻る のも本質的です。wait を抜けた地点はモニタの内側、すなわち再び相互排他下にあります。起こされたスレッドは即座に走り出すのではなく、ロックを取れるまで待ちます。この「取り直し」の存在が、次のMesa/Hoareの違いを生みます。
Mesa対Hoare──signalで「誰が即座に走るか」
signal を撃った瞬間、待っていたスレッドと撃ったスレッドの2人がモニタに入りたがります。しかしモニタの不変条件は「同時に1人だけ」。どちらを先に走らせるか で、意味論が2つに分かれます。
| Hoareセマンティクス | Mesaセマンティクス | |
|---|---|---|
| signal後に走るのは | 起こされた待ち手(即座に・割り込んで実行) | signalした側がそのまま継続。待ち手はキューで順番待ち |
| ロックの受け渡し | signal時点でロックを待ち手へ直接手渡す | 手渡さない。待ち手は後で自力で再取得する |
| 起床時の条件保証 | 保証される(signalの瞬間の状態がそのまま見える) | 保証されない(再取得までに第三者が条件を崩しうる) |
| 待ち側の書き方 | 理屈上は if でも可 | 必ず while で条件を再検査 |
| 採用例 | 理論モデル中心(教科書のモニタ) | POSIX/Java/C++ など現実の実装ほぼすべて |
Hoareセマンティクス は、signal した瞬間に制御とロックを待ち手へ即座に渡します。起こされた側は「条件が成立した、まさにその瞬間の状態」を引き継ぐので、条件は確実に真です。理論的には美しいものの、実装には「signalした側を一時退避させ、待ち手が終わったら戻す」という特別なスケジューリングが要り、コストが高くなります。
Mesaセマンティクス(Xerox PARCのMesa言語に由来)は逆に、signal を「待ち行列にいる誰かを起床可能にする」だけの ヒント とします。signalした側はそのまま走り続け、起こされた待ち手は普通にロック獲得を競う一参加者になります。実装は単純で速い代わりに、起床してロックを取り直すまでの間に、第三者がモニタに入って条件を崩しうる。
典型例が、空でないキューからの取り出しです。スレッドAとBがどちらも「キューが空でない」を待っているとします。生産者が1要素を入れて signal を撃ち、Aが起床します。しかしAがロックを取り直す前に、たまたまロックを持っていたBがその1要素を取り去ってしまう。Aがようやくロックを得て進んだとき、キューはすでに空です。もし if (空) wait() と書いていたら、Aは空のキューから取り出して壊れます。だから while (空) wait() と書き、起床後にもう一度条件を見て、偽ならまた眠る。Mesaでは再検査ループは「念のための作法」ではなく、正しさのための必須要件 です。
spurious wakeupと再検査ループの必然性
Mesaの「条件が崩れうる」問題に加え、実装側の事情でもう1つ「理由なく起きる」現象があります。spurious wakeup(偽の起床) です。signal も broadcast も受けていないのに wait から戻ることが、仕様上起こりえます。
なぜ許容されているのか。理由は実装効率です。多くのプラットフォームで条件変数は futex などの低レベル機構の上に組まれますが、シグナル配送やプロセスの再開、複数の待ち行列の併合といった内部事情で、厳密に「signalされた1人だけ」を起こすより「余分に起こして取りこぼしを絶対に避ける」方が安全かつ高速な場面があります。POSIXは pthread_cond_wait が spurious に戻りうると明記し、移植性のあるコードはこれに備える義務を負います。
ここで重要なのは、spurious wakeupに対する防御策が、Mesaセマンティクスへの防御策とまったく同じ だという点です。どちらも「wait から戻っても条件が真とは限らない」状況であり、対処は唯一つ——起床後に条件を再検査し、偽ならまた待つ。
// 正しい形:必ず while で囲む
lock(mutex)
while (条件が偽) { // 起床のたびに条件を読み直す
wait(cv, mutex)
}
// ここに来たとき、条件は確実に真。かつロックを保持している
... 条件が真である前提の処理 ...
unlock(mutex)
// 壊れる形:if で書くと一度しか確認しない
lock(mutex)
if (条件が偽) { // ← Mesaでもspuriousでも破綻する
wait(cv, mutex)
}
... 条件が偽のまま進みうる ...
unlock(mutex)
再検査ループは、(1) Mesaセマンティクスで起床〜再取得の隙に条件が崩れるケース、(2) spurious wakeup、(3) broadcastで複数起こされ条件を1人が消費し残りには足りないケース——の3つをまとめて吸収します。条件変数を「条件が真になるまで眠る述語付きの待ち」として正しく使う限り、Mesa/Hoareのどちらで動いていてもこのコードは正しく動きます。逆に言えば、whileで書いておけば実装のセマンティクスを気にせずに済む、というのがこの作法の実利です。
signalかbroadcastか──取りこぼしを避ける使い分け
起こす操作の選択にも原理があります。signal は1つだけ、broadcast は全員を起こします。コストは broadcast の方が高い(サンダリングハード=群れの暴走を招きうる)ため signal を使いたくなりますが、安全に signal で済むのは条件が限られます。
| 状況 | signal(1つ)で安全か | 理由 |
|---|---|---|
| 待ち手全員が同一の条件を待ち、1回のsignalで進めるのは確実に1人だけ | 安全 | 起こした1人が条件を消費し、残りはまだ偽のまま待ち続けるのが正しい |
| 複数の異なる条件が同じ条件変数を共有している | 危険 | 起こすべき条件の待ち手とは別の待ち手を起こしてしまい、本来の待ち手が取りこぼされうる |
| 1回の状態変化で複数の待ち手が同時に進める(例:一括投入) | 危険 | 1人しか起きず、進めるはずの残りが眠ったまま取り残される |
迷ったら broadcast が安全側です。全員起こしても、whileループで再検査するため進めない者はまた眠るだけで、正しさは壊れません。性能のために signal へ絞るのは、「起こすべき相手が確実に1人」だと論理的に保証できるときに限ります。なお、複数の述語を1つの条件変数に相乗りさせる設計は signal の取りこぼしを生みやすいため、条件ごとに条件変数を分ける のが定石です。
- モニタ=相互排他+条件待ち を束ねた枠組み。条件変数はその待ち行列を担う部品で、条件変数自身は述語(状態)を持たない。述語は別の共有変数で管理する。
- wait は「ロック解放・眠り」を原子的に行い、起床後にロックを再取得して戻る。原子性がないと lost wakeup を起こす。signal=1人、broadcast=全員 を起こす。
- Hoare はsignal時に制御を待ち手へ即渡し条件成立を保証。Mesa はsignalをヒント扱いし、起床〜再取得の間に条件が崩れうる。現実の実装はほぼMesa。
- spurious wakeup(signalなしの起床)は仕様上起こりうる。Mesaの性質と合わせ、待ちは必ず
while(条件が偽) wait()で再検査する。ifは典型的バグ。 - signalで安全なのは「起こすべき相手が確実に1人」のときのみ。迷えばbroadcast、述語ごとに条件変数を分ける。
まとめ──条件変数は「述語が真になるまで眠る」部品
モニタは 相互排他 と 条件待ち を1つに束ねた同期の枠組みで、条件変数はその「待ち行列」を担います。条件変数は値を持たず、真偽を判定する述語は別の共有変数として読者が管理します。wait はロック解放と眠りを原子的に行い、起床後にロックを取り直して戻る——この原子性が lost wakeup を防ぎます。signal は1人、broadcast は全員を起こします。意味論には2系統あり、Hoare はsignal時に制御を待ち手へ即渡しして条件成立を保証する一方、現実の実装がほぼ採る Mesa はsignalを単なるヒントとし、起床からロック再取得までの間に第三者が条件を崩しうる。さらに spurious wakeup(理由なき起床)も仕様上起こります。これら3つ(Mesaの崩れ・spurious・broadcastでの過剰起床)を一括で吸収するのが while(条件が偽) wait() の再検査ループ であり、これは作法ではなく正しさの必然です。signalで安全なのは起こすべき相手が確実に1人のときに限られ、迷えばbroadcast、述語ごとに条件変数を分けるのが定石。土台となる排他の全体像は 排他制御とデッドロック、ユーザ空間での実装機構は futexの内部動作、カーネル側の待機機構は カーネルのロック機構 が補強します。
OS Article
条件変数とモニタ・スレッド間同期の原理を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
条件変数
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
waitはロック解放・眠り・再取得をアトミックに行い、signalは待ち手を1つ、broadcastは全員を起こす。起こされた側がロックを取り直すまでの間に条件が崩れうるのがすべての鍵。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「条件変数 / モニタ」に近いか確認する。
- 強みである「モニタは相互排他(一度に1スレッドだけがクリティカル領域に入る)と条件待ち(条件が整うまでロックを手放して眠る)を1つにまとめた同期の枠組みで、条件変数はその「待ち行列」を担う部品。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。