今夜の注文書
#05 · 五日目の夜
ピンチ
ミンホは包丁を握ったまままな板を、スアはまな板を抱えたまま包丁を待っている。どちらも先に手放す気はない。厨房が止まった。
目標
包丁とまな板を一緒に使う料理人3人が、一度も止まらずにそれぞれ100回ずつ料理する。
深夜食堂 ドンシ
五日目の夜11時。厨房が妙に静かです。ミンホは包丁を握ってまな板のほうを見つめ、スアはまな板を抱えて包丁のほうを見つめています。二人とも「先に使ったら渡すから」と繰り返すばかりです。
ネギを刻んでいたタクさんが、手を止めてつぶやきます。「二人とも突っ立ってたら、誰も終わらんよ」
ユン・ソヨンシェフが首から下げたタイマーを止めます。「二人とも置いて。これは誰かが遅いせいじゃない。ルールがないせいだよ」
昨夜のコンロのルールと同じく、包丁とまな板も一度に一人しか使えないロックです。ミンホは包丁 → まな板の順に、スアはまな板 → 包丁の順に取ります。
import threading
import time
knife = threading.Lock()
board = threading.Lock()
def minho():
with knife: # 包丁を取る
time.sleep(0.1)
with board: # まな板を待つ
print("minho done")
def sua():
with board: # まな板を取る
time.sleep(0.1)
with knife: # 包丁を待つ
print("sua done")
a = threading.Thread(target=minho, daemon=True)
b = threading.Thread(target=sua, daemon=True)
a.start(); b.start()
a.join(timeout=1); b.join(timeout=1)
print("alive:", a.is_alive(), b.is_alive())
# alive: True Truejoin(timeout=1) は1秒までしか待たずに戻ってきます。スレッドを daemon=True で作ったので、プログラムはそのまま終了します。デッドロックを実際に試すときは、いつもこうして逃げ道を用意しておきましょう。time.sleep(0.1) は、二人が最初の道具をほぼ同時に手に取るようにするための仕掛けです。実際のプログラムではこのすき間がごくまれにしか生じないので、デッドロックは「たまにだけ」起きる、さらにたちの悪いバグになります。止まったことをプログラムの中で気づくには acquire(timeout=...) を使います。取れれば True、時間内に取れなければ False を返します。with lock: には時間制限を付けられないので、このときは acquire と release を直接呼びます。
knife = threading.Lock() # 新しい包丁と新しいまな板
board = threading.Lock()
def cook(name, first, second, patience):
with first:
time.sleep(0.1)
if second.acquire(timeout=patience): # patience秒までしか待たない
second.release()
print(name, "done")
else:
print(name, f"gave up after {patience}s")
a = threading.Thread(target=cook, args=("minho", knife, board, 1.0))
b = threading.Thread(target=cook, args=("sua", board, knife, 0.5))
a.start(); b.start()
a.join(); b.join()
# sua gave up after 0.5s
# minho doneスアが0.5秒であきらめてまな板を置くと、まだ待っていたミンホがまな板を取って料理を終えます。一人が譲るだけでデッドロックはほどけます。その理由は、次の節の4つの条件で説明できます。
デッドロックは、次の4つの条件がすべて同時にそろったときにだけ起きます。裏返せば、ひとつ崩すだけでデッドロックは起きません。
| 条件 | 意味 | 今夜の厨房では | 崩し方 |
|---|---|---|---|
| 相互排除 | 資源は一度に一人しか使えない | 包丁は一人の手にしかない | 包丁を買い足すか、共有が要らない設計にする |
| 保持して待機 | ひとつ握ったまま別のものを待つ | 包丁を持ったまままな板を待つ | 2つを一度に取るか、取れなければ置く |
| 横取りできない | 他人が握る資源を力ずくで奪えない | ミンホの手の包丁は奪えない | 時間制限で自分から手放させる(解法2) |
| 循環待ち | 待ちが輪になる | ミンホ → スア → ミンホ | 全員が同じ順番で取る(解法1) |
シェフのひとこと
デッドロックはCPUを使わない。エラーも出ない。ログも残らない。ただ静かに止まってるだけ。だから「遅くなった」って報告が来たら、私はまずデッドロックを疑う。
シェフが黒板にルールを書きます。「道具ごとに番号を付ける。包丁が1番、まな板が2番。いつも小さい番号から取る」
ミンホもスアも包丁から取るなら、まな板を待つのは包丁を握った人だけです。包丁を取れなかった人は何も持たずに待つので、輪ができません。ルールを人の記憶に頼らず、関数ひとつにまとめておきましょう。
from contextlib import contextmanager
KNIFE = (1, threading.Lock()) # (番号, ロック)
BOARD = (2, threading.Lock())
@contextmanager
def hold(*tools):
ordered = sorted(tools, key=lambda t: t[0]) # 番号の小さいものから
for _, lock in ordered:
lock.acquire()
try:
yield
finally:
for _, lock in reversed(ordered): # 放すときは逆順に
lock.release()
done = []
def minho():
for _ in range(100):
with hold(KNIFE, BOARD):
time.sleep(0)
done.append("minho")
def sua():
for _ in range(100):
with hold(BOARD, KNIFE): # 書く順番が違っても、取る順番は同じ
time.sleep(0)
done.append("sua")
ts = [threading.Thread(target=f, daemon=True) for f in (minho, sua)]
for t in ts: t.start()
for t in ts: t.join(timeout=2)
print(sorted(done), [t.is_alive() for t in ts])
# ['minho', 'sua'] [False, False]hold(BOARD, KNIFE) と書いても、hold の中で番号順に並べ替えるので、実際には包丁 → まな板の順に取ります。time.sleep(0) で順番を譲るすき間を与えても同じです。id(lock) のように重ならない値でもかまいません。大事なのはすべてのコードが同じ基準を使うことです。ロックが2つ以上必要な場所ができたら、このルールをコードの先頭のコメントやドキュメントに書いておきましょう。新しく来た人が順番を知らずに逆から取った瞬間、デッドロックはまたやってきます。
順番を決めにくい場合もあります。どのロックが必要か前もってわからないときです。そんなときは「一定時間待っても取れなければ、持っているものまで置いて最初からやり直す」というルールを使います。
import random
tries = {}
def polite_cook(name, first, second):
tries[name] = 0
while True:
tries[name] += 1
with first:
time.sleep(0.05)
if second.acquire(timeout=0.1): # 0.1秒以内に取れなければ
try:
return # 両方取れた:料理完了
finally:
second.release()
# withを抜けるときにfirstを置いた
time.sleep(random.uniform(0, 0.1)) # ランダムに休んでからもう一度
a = threading.Thread(target=polite_cook, args=("minho", knife, board))
b = threading.Thread(target=polite_cook, args=("sua", board, knife))
a.start(); b.start()
a.join(); b.join()
print(tries)
# 例:{'minho': 2, 'sua': 3} (実行するたびに変わる)何度かぶつかったあと、最後には二人とも終わります。ここで random.uniform(0, 0.1) を外すと、新しい問題が起きることがあります。ライブロック(livelock)です。
| 瞬間 | ミンホ | スア |
|---|---|---|
| 1 | 包丁を取る | まな板を取る |
| 2 | まな板がない → 包丁を置く | 包丁がない → まな板を置く |
| 3 | きっかり0.1秒休んで、また包丁を取る | きっかり0.1秒休んで、またまな板を取る |
| … | 1〜3を延々と繰り返す | 1〜3を延々と繰り返す |
廊下で向かい合った二人が、同じ側へよけ続けてしまう場面と同じです。デッドロックと違って二人とも忙しく動いているのに、誰も料理を終えられません。休む時間をランダムに混ぜれば、どちらかが先に動くことになり、繰り返しが崩れます。
本物のスレッドは実行のタイミングが少しずつぶれるので、短い例でライブロックを毎回再現するのは難しいものです。だからこそ危険です。負荷の高い日にだけ現れるのですから。解法2を使うときは、ランダムな休みを必ず一緒に入れてください。
コンピューターサイエンスには食事する哲学者の問題という有名な問題があります。円卓に5人が座り、あいだにフォークが1本ずつ置かれています。食べるには両隣のフォーク2本がどちらも必要です。厨房に置き換えると、丸い調理台に料理人5人と包丁5本です。
N = 5
def service(ordered):
knives = [threading.Lock() for _ in range(N)] # 包丁5本
meals = [0] * N
def cook(i):
left, right = i, (i + 1) % N
if ordered:
first, second = min(left, right), max(left, right)
else:
first, second = left, right
for _ in range(3):
with knives[first]:
time.sleep(0.05) # 最初の包丁を持ったまま少し
with knives[second]:
meals[i] += 1
ts = [threading.Thread(target=cook, args=(i,), daemon=True) for i in range(N)]
for t in ts: t.start()
for t in ts: t.join(timeout=1)
return sum(t.is_alive() for t in ts), meals
print("left first:", service(ordered=False))
print("small first:", service(ordered=True))
# left first: (5, [0, 0, 0, 0, 0])
# small first: (0, [3, 3, 3, 3, 3])下の実験台は、この状況を1ステップずつ見せてくれます。サーバーの本物のスレッドをデッドロックさせると二度とほどけないので、この実験台だけはシミュレーションで動きます。包丁の上の数字は、その包丁を握っている料理人の番号です。
包丁とまな板
1
2
3
4
5
·
·
·
·
·
ステップ 0
完成した料理: 1:0 2:0 3:0 4:0 5:0
プログラムが止まったのに、どこで止まったのかわからない。そんなときは標準ライブラリの faulthandler が助けてくれます。すべてのスレッドがいま何行目にいるかを出力します。下のコードを stuck.py として保存して実行してみてください。
import faulthandler
import threading
import time
knife = threading.Lock()
board = threading.Lock()
def minho():
with knife:
time.sleep(0.1)
with board:
pass
def sua():
with board:
time.sleep(0.1)
with knife:
pass
faulthandler.dump_traceback_later(1) # 1秒後もまだここにいたら、全スレッドを出力
crew = [threading.Thread(target=f, name=f.__name__, daemon=True) for f in (minho, sua)]
for t in crew: t.start()
for t in crew: t.join(timeout=2)
faulthandler.cancel_dump_traceback_later() # 時間内に終わったなら予約を取り消すTimeout (0:00:01)!
Thread 0x00009560 [sua] (most recent call first):
File "stuck.py", line 17 in sua
...
Thread 0x0000a670 [minho] (most recent call first):
File "stuck.py", line 11 in minho
...出力は短くしてあります(アドレスとパスは実行のたびに変わります)。スアは17行目の with knife: で、ミンホは11行目の with board: で止まっています。誰が何を待っているのか、ひと目でわかります。
faulthandler.dump_traceback_later(timeout):timeout秒後に、すべてのスレッドの位置を標準エラーに出力するよう予約する。repeat=True なら繰り返し出力し、exit=True なら出力後にプログラムを終了する。faulthandler.dump_traceback(all_threads=True):予約なしで今すぐ出力する。時間のかかる処理の途中で状態をのぞきたいときに使う。dump_traceback_later を仕掛けておきましょう。止まった日に犯人が残ります。シェフのひとこと
ロックを握ったまま別の関数を呼ぶときは気をつけて。その関数の中でどのロックをまた取るか、わからないでしょ。ロックの中では、黒板に数字を書くみたいに短くてわかりきったことだけやる。それが順番のルールより先。
今夜の目標は、包丁とまな板を一緒に使う料理人3人が、一度も止まらずにそれぞれ100回ずつ料理することでした。タクさんはフライパンまで3つ使います。道具ごとに番号を付けて hold で取らせれば、誰がどんな順番で書いても関係ありません。
PAN = (3, threading.Lock())
meals = {}
def cook(name, *tools):
for _ in range(100):
with hold(*tools): # いつも番号の小さい道具から
time.sleep(0)
meals[name] = meals.get(name, 0) + 1
crew = [threading.Thread(target=cook, args=a, daemon=True) for a in [
("minho", KNIFE, BOARD), ("sua", BOARD, KNIFE), ("tak", PAN, BOARD, KNIFE)]]
for t in crew: t.start()
for t in crew: t.join(timeout=3)
print([meals[n] for n in ("minho", "sua", "tak")], sum(t.is_alive() for t in crew))
# [100, 100, 100] 03人とも100回ずつ、止まった人は0人です。シェフが黒板のルールの横に1行書き足します。「包丁1、まな板2、フライパン3。小さい番号から」
「今日は道具のせいで止まった。明日は人のせいで止まるよ。注文が殺到したとき、誰がどの注文を作るか、誰が決める?」
営業日誌 — 五日目の夜。包丁とまな板を互いに逆の順番で取ったせいで厨房が停止。道具に番号を付け、小さい番号から取ることにしたら、3人が100回ずつ止まらずに料理できた。
time.sleep(0.1) を消して、何度か実行してみてください。デッドロックは毎回起きますか?「たまにだけ止まるバグ」がなぜより危険なのか、一文で書いてみてください。service で ordered=True のとき、4番の料理人だけが違う順番で取ることを print で確かめてみてください。料理人が一人だけ順番を変えてもデッドロックがほどける理由を、4つの条件で説明してみてください。シェフのレシピカード
デッドロックの4条件:相互排除、保持して待機、横取りできない、循環待ち。ひとつ崩せばほどける。
解法1:ロックに番号を付け、全員が小さい番号から取る。sorted(tools)で関数ひとつにまとめておく。
解法2:if not lock.acquire(timeout=0.1): 持っているものを置き、ランダムに休んでからやり直す。
ライブロック:全員が同じリズムで譲り合うばかりで、誰も終わらない。ランダムな休みで崩す。
実験するときは daemon=True + join(timeout=1) + is_alive() で逃げ道を作る。
止まった場所を探す:faulthandler.dump_traceback_later(1)、dump_traceback(all_threads=True)。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。