スレッド同期プリミティブの内部(ミューテックス・セマフォ・条件変数)
ロックが速い理由と、待つときだけ寝かせる仕組みが分かれば、無駄なCPU消費も誤起床バグも避けられる。futexやpark/unparkでカーネルと連携する内部を原理から解説します。
- 1.現代のミューテックスは「競合がなければユーザー空間のCASだけで完結し、競合したときだけカーネルに寝かせてもらう」二段構え。Linuxではこの仲介をfutexが担う。
- 2.条件変数は必ずミューテックスと組で使い、待機は while ループで条件を再検査する。spurious wakeup(誤起床)とロスト・ウェイクアップを防ぐための鉄則。
- 3.セマフォはカウント付きの許可券、ミューテックスは所有権付きの排他、モニタはミューテックス+条件変数を言語が束ねた構文。役割と保証が異なる。
同期プリミティブは「待ち方」の設計である
複数スレッドが同じデータに触れるとき、競合状態(race condition)を防ぐには「ある区間は一度に一人だけ」「ある条件が満たされるまで待つ」という調整が要ります。同期プリミティブとは、この調整を提供する部品群です。設計上の核心は排他そのものより待ち方にあります。待っているスレッドをCPUを回したまま待たせる(スピン)のか、OSに頼んで寝かせる(ブロック)のか——この選択が性能とスケーラビリティを決めます。並行設計全体の地図は並行性モデル(CSP・アクター・STM)に整理してあります。
スピンロック:待つ間もCPUを回す
最も素朴な排他はスピンロックです。アトミックなフラグを test-and-set や CAS で奪い、奪えるまでループで回り続けて待ちます。
acquire:
while CAS(lock, 0, 1) == false: # 0(空き)を1(占有)にできるまで
pause # CPUにスピン中だとヒントを出す
release:
atomic_store(lock, 0)
スピンロックの利点は、ロック保持時間が極めて短いときに無敵な点です。寝かせて起こすにはカーネル遷移(数百〜数千サイクル)が要りますが、保持が数十サイクルなら、その場で回って待つ方が圧倒的に安い。逆に保持が長い、あるいはコア数より待ち手が多いと、スピンは純粋なCPUの浪費になります。pause 命令はスピン中であることをCPUに伝え、無駄な投機実行とパイプライン浪費を抑え、ハイパースレッディングの相方にリソースを譲ります。
ロック保持者がプリエンプト(横取り)されると、待ち手はスピンしながら、保持者が再スケジュールされるまで永久にCPUを焼き続けます。低優先度の保持者を高優先度のスピン手がCPUごと奪ってしまうと、保持者が永遠に走れない優先度逆転に陥ります。だからユーザー空間の汎用ロックは、純粋なスピンではなく後述の「少しスピンしてダメなら寝る」ハイブリッドが定石です。
ミューテックス:所有権付きの排他と二段構え
ミューテックス(mutual exclusion)は、ロックした者だけがアンロックできるという所有権を持つ排他装置です。ここがセマフォとの決定的な違いです。現代のミューテックスは競合の有無で経路を分ける二段構えで実装されます。
| 状況 | 経路 | コスト |
|---|---|---|
| 競合なし(fast path) | ユーザー空間のCASだけでロック取得 | 数十サイクル、カーネル遷移なし |
| 競合あり(slow path) | カーネルに依頼してスレッドを待機列へ寝かす | システムコール分の重いコスト |
肝は「競合がない普通のケースではカーネルに一切入らない」ことです。空いているロックを取るのはCAS一発で済み、システムコールは発生しません。競合して初めて、待ち手をスケジューラの管理下で寝かせるためにカーネルへ降ります。この「楽観的にユーザー空間で試し、ダメなときだけ降りる」発想はロックフリー・ウェイトフリーとCAS命令の楽観的更新と同根です。違いは、ロックフリーが再試行で粘るのに対し、ミューテックスは諦めて寝る点にあります。
futex:ユーザー空間とカーネルの橋渡し
Linux でこの二段構えを支えるのが futex(fast userspace mutex) です。futex の本質は、ロックの状態語そのものはユーザー空間のメモリに置き、カーネルは「寝る/起こす」の待機列管理だけを担うという分業にあります。カーネルが提供する操作は主に二つです。
FUTEX_WAIT(addr, expected):
# アトミックに「*addr == expected か」を確認し、
# 真ならこのスレッドをカーネルの待機列で寝かせる。
# 偽なら(その瞬間に値が変わった=起こす必要なし)即座に戻る。
FUTEX_WAKE(addr, n):
# addr で寝ている待ち手を最大 n 個起こす。
FUTEX_WAIT が「expected と一致するときだけ寝る」ことが決定的に重要です。値の確認とブロックがアトミックに行われるため、「寝ようと判断した直後に解放通知が来て取りこぼす」ロスト・ウェイクアップを構造的に防ぎます。ミューテックスの典型実装は、状態語を 0=空き / 1=占有 / 2=占有かつ待ち手あり の三値で持ち、競合がなければCASだけ、待ち手が出たときだけ futex を呼んで寝かせ、解放時は「待ち手あり」のときだけ FUTEX_WAKE を出します。これにより無競合時はカーネルに触れない設計が成立します。
JVM の LockSupport.park()/unpark() や Rust の thread::park も発想は同じです。スレッドを名指しで寝かせ(park)、名指しで起こす(unpark)。重要な性質として、unpark が park より先に来ても「許可(permit)」が1つ蓄えられ、次の park が即座に戻るため取りこぼしません。futex の expected 確認と同様、起こす側と寝る側の競合を吸収する仕掛けです。OSのfutexを言語ランタイムが抽象化したものと捉えると見通せます。
条件変数:状態の変化を待つ
ミューテックスは「区間の排他」を、条件変数(condition variable)は「ある条件が成立するまでの待機」を担います。両者は必ず組で使います。条件変数は単独では機能せず、付随するミューテックスと連動して初めて正しく動きます。
# 消費者:キューが空でない、を待つ
lock(m)
while queue.is_empty(): # ← if ではなく while で再検査するのが鉄則
wait(cv, m) # m をアトミックに解放して寝る/起きたら m を再取得
item = queue.pop()
unlock(m)
# 生産者:要素を入れて起こす
lock(m)
queue.push(x)
signal(cv) # 待ち手を1つ起こす(broadcast なら全員)
unlock(m)
wait(cv, m) の中核は「ミューテックスのアンロックとスレッドの待機を不可分に行う」点です。もしこれが分割可能だと、アンロックした直後・寝る前の隙に生産者が signal を出し、その通知を取りこぼすロスト・ウェイクアップが起きます。wait はこの隙間をアトミックに閉じ、起床時にはミューテックスを取り直してから戻ります。
条件変数は、誰も signal していないのに wait から戻ることがあります。これが**誤起床(spurious wakeup)**です。OSのシグナル配送やマルチプロセッサ上の実装最適化が原因で、POSIXもJavaも明示的に許容しています。さらに signal で起こされても、自分が再取得するまでに別スレッドが先に条件を消費してしまう(Mesa方式モニタの宿命)。だから待機は必ず while 条件: wait() の形で書き、起床後に条件を再検査する。if で書くと、条件が偽のまま処理を進めてキューが空なのに pop する等の破綻を招きます。これは試験でも実務でも最頻出の落とし穴です。
セマフォ:カウント付きの許可券
セマフォは整数カウンタを持つプリミティブで、二つの操作 wait(P操作・down)と signal(V操作・up)を提供します。wait はカウントを1減らし、0未満になるなら寝ます。signal はカウントを1増やし、待ち手がいれば起こします。
- カウンティングセマフォ:初期値 N で「同時に N 個まで」を表す。コネクションプールやレート制限など、有限個の資源の管理に向きます。
- バイナリセマフォ:初期値1で排他に使えますが、ミューテックスと同一ではありません。
セマフォとミューテックスの最大の違いは所有権です。ミューテックスは「ロックした者だけがアンロックできる」のに対し、セマフォの signal は誰が出してもよい。これが、片方のスレッドが wait で待ち別のスレッドが signal で通知する、生産者・消費者のシグナリングにセマフォが向く理由です。逆に排他目的でセマフォを使うと、所有権チェックがないぶん「アンロックし忘れ・二重アンロック」が静かに通ってしまいます。
| プリミティブ | 本質 | 所有権 | 主用途 |
|---|---|---|---|
| ミューテックス | 排他(1区間に1人) | あり(取った者が返す) | クリティカルセクション保護 |
| セマフォ | カウント付き許可 | なし(誰でもsignal可) | 資源数の制限・シグナリング |
| 条件変数 | 条件成立まで待機 | 付随mutexに従う | 状態変化の通知 |
モニタ:ミューテックスと条件変数を言語が束ねる
モニタ(monitor)は、ミューテックス1つと1つ以上の条件変数を、データと一緒にカプセル化した高水準構文です。Java の synchronized + wait/notify、C# の lock + Monitor.Wait がこれにあたります。モニタの狙いは、ロックの取得・解放を構文に埋め込んで取りこぼしを防ぎ、条件変数とミューテックスの結びつきを言語が保証することです。
モニタには起床時の意味論で二系統あります。Hoare方式は signal した瞬間に待ち手へ即座に制御を移すため、起床時に条件成立が保証されます。Mesa方式(実用上ほぼ全ての言語が採用)は signal してもシグナラが走り続け、起こされた側は後でロックを取り直すため、起きた頃には条件が崩れている可能性がある。前述の「while で再検査せよ」は、このMesa方式の帰結です。
複数の異なる条件を1つの条件変数で待たせている場合、notify(1つだけ起こす)だと「起こされたが自分の条件ではない」スレッドが、本来起きるべきスレッドの代わりに起きてそのまま二度寝し、起こすべき相手に通知が届かない事態が起きます。安全側に倒すなら notifyAll(broadcast)で全員起こし、各自に while で再検査させます。性能を詰めるなら、条件ごとに別の条件変数を用意するのが正解です。
ハイブリッドと公平性
実用のミューテックスはたいていアダプティブ(適応的)です。まず少しだけスピンしてロック保持者がすぐ手放すか様子を見て、一定回数で諦めて futex で寝ます。短時間競合はスピンで吸収し、長時間競合のCPU浪費は回避する両取りの戦略です。
もう一つの軸が公平性です。解放時に待ち列の先頭へ確実に渡すFIFO(公平)ロックは飢餓を防ぎますが、直前まで走っていたスレッドにそのまま渡すunfair ロックよりキャッシュ局所性とスループットで劣ることが多い。多くのランタイムは、ふだんは高速な unfair で動かしつつ、特定のスレッドが待たされ続けたら一時的に公平モードへ切り替える折衷を採ります。
まとめ
同期プリミティブの内部は、(1) 競合がなければユーザー空間のCASだけで完結し、競合時のみカーネルへ降りて寝る二段構え(Linuxでは futex、ランタイムでは park/unpark が橋渡し)、(2) 条件変数は必ずミューテックスと組で使い、while で条件を再検査して誤起床とMesa方式の取りこぼしを防ぐ、(3) ミューテックス(所有権付き排他)・セマフォ(カウント付き許可)・モニタ(両者の言語的束ね)は保証が異なる別物、という三点で整理できます。「待つ間にCPUを焼くか寝るか」「起こす側と寝る側の競合をどうアトミックに閉じるか」が一貫した設計テーマです。並びにまつわる可視性の保証はメモリモデルとhappens-before関係、これらの上に立つ高水準の待機はasync/awaitとコルーチンの内部実装と合わせて押さえると、同期の全体像が見通せます。
プログラミング Article
スレッド同期プリミティブの内部(ミューテックス・セマフォ・条件変数)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
並行処理
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
条件変数は必ずミューテックスと組で使い、待機は while ループで条件を再検査する。spurious wakeup(誤起床)とロスト・ウェイクアップを防ぐための鉄則。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「並行処理 / 同期プリミティブ」に近いか確認する。
- 強みである「現代のミューテックスは「競合がなければユーザー空間のCASだけで完結し、競合したときだけカーネルに寝かせてもらう」二段構え。Linuxではこの仲介をfutexが担う。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。