🎓 レベル:標準 | 重要度:B(推奨)
📎 前提:排他制御(ロック・セマフォ・ミューテックス) | 関連:デッドロック・プロセス間通信(IPC)
要点(BLUF)
- 並行処理の現場には繰り返し現れる型がある。代表が生産者消費者(作る側と使う側を有界バッファで繋ぐ)と読者書き手(読みは同時可、書きは排他)。
- これらは「相互排他(排他制御(ロック・セマフォ・ミューテックス))」だけでなく「条件が整うまで待つ」仕組み=条件変数/セマフォを必要とする。
- 古典問題を型として知っておくと、自前でゼロから危ういロックを書かずに、検証済みの構造を再利用できる。
概念 ── 同期には「排他」と「待ち合わせ」の2種類がある
競合状態とクリティカルセクション で見た相互排他は「同時に触らせない」仕組みでした。しかし実務では、もう1つ「条件が整うまで眠って待つ」仕組みが要ります。例えば「キューが空なら、何か入るまで消費側は待つ」。
この「待ち合わせ」を担うのが条件変数(condition variable)やセマフォ。ロックだけでビジーに条件をチェックし続けると(スピン)CPUを浪費するので、「条件が変わるまで眠り、変わったら起こす」が要ります。
仕組み① ── 生産者消費者(有界バッファ)
データを作る生産者と使う消費者を、固定容量の有界バッファで繋ぎます。パイプ(プロセス間通信(IPC))の本質もこれです。
要件は2つの待ち合わせ:
- バッファが満杯なら、生産者は空きができるまで待つ。
- バッファが空なら、消費者は何か入るまで待つ。
flowchart LR
P["生産者(put:満杯なら待つ)"] -->|"アイテム"| BUF["有界バッファ(容量N)"]
BUF -->|"アイテム"| C["消費者(get:空なら待つ)"]
セマフォを2つ使う古典解:empty(空き枠数、初期N)と full(中身数、初期0)。生産者は empty を1減らして入れ full を1増やす、消費者は逆。バッファ本体の同時操作はミューテックスで守ります。これで「枠の数」をセマフォが自然に管理します(排他制御(ロック・セマフォ・ミューテックス) のセマフォがカウンタを持つ理由)。
実機で確かめます([[#対応ラボ]] の 04-04_producer_consumer.py。Pythonの queue.Queue が満杯/空のブロックを内蔵)。
BUF = queue.Queue(maxsize=3) # 有界バッファ
def producer():
for i in range(N_ITEMS): BUF.put(i) # 満杯なら自動でブロック
BUF.put(None) # 終了の番兵
def consumer():
while True:
item = BUF.get() # 空なら自動でブロック
if item is None: break
実行結果(実機):
生産: [0, 1, 2, 3, 4, 5, 6, 7]
消費: [0, 1, 2, 3, 4, 5, 6, 7]
全アイテムが順序通り過不足なく渡った: True
容量3のバッファなのに8個を過不足なく順序通り受け渡せたのは、満杯で生産者が、空で消費者が自動的に待ったから。これが速度差のある2者を繋ぐフロー制御です。
仕組み② ── 読者書き手問題
共有データに対し、読むだけの読者は何人いても同時OK(互いに干渉しない)だが、書き手は排他(読者とも他の書き手とも同時不可)。単純な1本のミューテックスだと読者まで直列化して並列性を捨ててしまうので、専用の**読み書きロック(RWロック)**を使います。
flowchart TB
subgraph OK["同時に許される"]
r1["読者"]; r2["読者"]; r3["読者"]
end
subgraph NG["排他が必要"]
w["書き手(単独・読者もブロック)"]
end
設計の肝は公平性。読者を無制限に通すと、読者が途切れず書き手が永久に入れない(書き手の飢餓)。逆に書き手優先にすると読者が飢える。実装はどちらを優先するか方針を決めて偏りを防ぎます(飢餓は デッドロック と並ぶ並行バグ)。
仕組み③ ── 条件変数の使い方の定石
条件変数は「ロックを保持しつつ、条件が満たされるまで眠る」道具。定石はwhileで条件を再確認すること。
with cond: # ロックを取る
while not 条件(): # if でなく while(起こされても条件が崩れていることがある)
cond.wait() # ロックを手放して眠り、起こされたら取り直す
# ここに来たら条件成立。共有データを操作
if でなく while なのは、**偽の起床(spurious wakeup)**や、起こされた後に別スレッドが先に条件を崩す可能性があるため。起きたら必ず条件を確かめ直す――これを怠ると稀に壊れます。
仕組みの直観 ── なぜこの設計か
- 古典問題を型にする理由:並行バグは再現困難(競合状態とクリティカルセクション)。毎回ゼロから書くより、正しさが検証された型(生産者消費者・RWロック)を再利用する方が安全。標準ライブラリの
queue.Queue等はこの型の実装。 - セマフォで枠を数える理由:有界バッファの「空き枠」「中身数」は本質的にカウント。セマフォがそのまま対応する。相互排他(N=1)の一般化が効く好例。
- whileで待つ理由:起こされた=条件成立、ではない。複数の待機者と非決定性の世界では、起床は「確認のきっかけ」に過ぎない。
⚠️ よくある誤解・落とし穴
- 「条件変数は
ifで待てばよい」→whileで再確認。偽の起床と横取りで条件が崩れうる。 - 「読者は同期不要」→ 書き手と同時なら中途半端な状態を読む。RWロックで読者同士だけ並列にする。
- 「自前のロックで生産者消費者を書けば十分」→ 通知漏れ・順序ミスでデッドロックや取りこぼしが出やすい。既存の安全な構造を使う。
- 「バッファを無限にすれば待ちが消える」→ メモリを食い、フロー制御(速い生産者を抑える)が効かなくなる。有界であることに意味がある。
対応ラボ
cs-foundations-study/labs/04-04_producer_consumer.py(実行して有界バッファの受け渡しとブロックを確認済み)。
関連
- 土台の道具は 排他制御(ロック・セマフォ・ミューテックス)
- 飢餓・デッドロックの回避は デッドロック
- パイプという生産者消費者は プロセス間通信(IPC)