今夜の注文書
#12 · 十二日目の夜
ピンチ
窓際の客が名刺を差し出す。美食評論家。今夜、12皿のコースを正式に注文する。3・7・11皿目は、最初の1回だけやけに時間がかかる。
目標
12皿をすべて出す。ただしコンロは3つまで、3秒以内に終える。
深夜食堂 ドンシ
金曜の夜10時。約束どおり、窓際の客が入ってきます。いつもひとりで、いつも麺を一杯、いつも腕時計と小さな手帳。今夜は席に着くなり、名刺を1枚テーブルに置きます。美食評論家。
「ここ何週間か、この厨房を見てきました。今日はちゃんと注文します。12皿のコースで」 評論家は手帳を開き、腕時計を外します。
厨房が静まり返ります。ミンホは包丁を落とし、スアは伝票差しを整え直し、タクさんは黙ってコンロを磨きます。ルーが注文書を持って入ってきます。ユン・ソヨンシェフが首から下げたタイマーを0に戻します。「厨房はプログラムだ。今夜は、これまで作ってきたものを全部動かすよ」
11回の夜で、道具はずいぶんたまりました。お客さんが入ってきたとき、最初に問うべきことはひとつです。「この仕事は待つ仕事か、手がふさがる仕事か?」
| 状況 | 出す道具 | 厨房でいえば | 限目 |
|---|---|---|---|
| 待つ仕事がとても多い(接続が数百〜数万) | asyncio (gather, TaskGroup) | 一人でホールを回すルー | 9〜10 |
| 待つ仕事だが同期ライブラリしかない、数十件 | threading, ThreadPoolExecutor | 複数の料理人、出張料理人 | 2, 7 |
| 計算が多い(CPU) | ProcessPoolExecutor, multiprocessing(または3.14tのスレッド) | 支店の厨房 | 8 |
| asyncioの中の古いブロッキングコード | asyncio.to_thread | オーブンの前に別に立つ人 | 11 |
| asyncioの中の重い計算 | loop.run_in_executor(プロセスプール, ...) | 支店に生地を任せる | 11 |
| 複数人がひとつの値を書き換える | Lock、またはキューで持ち主をひとりに | 黒板の前のロック、伝票差し | 3, 6 |
| 同時にn個まで | Semaphore(n) | コンロ3つ | 4, 11 |
| 一緒に終わらなければならず、時間を超えてもいけない | TaskGroup, asyncio.timeout | 団体予約 | 10 |
選ぶ順番はこうです。
to_thread で包みます。評論家の注文書はこうです。皿は12、1皿に0.5秒。ところが3・7・11皿目は、最初の1回は材料がまだ戻りきっていなくて3倍(1.5秒)かかります。やり直せば0.5秒でできます。コンロは3つだけです。
必要な部品は3つです。コンロの数を守るセマフォ、1皿がだらだら長引かないようにする制限時間、途中で切られた皿をやり直す再試行。これをTaskGroupひとつに収めます。
import asyncio, time
SLOW = {3, 7, 11} # 最初の1回は3倍かかる皿
async def plate(n, attempt):
await asyncio.sleep(0.5 * (3 if n in SLOW and attempt == 0 else 1))
async def order(n, stoves, limit_s, retries):
for attempt in range(retries + 1):
async with stoves: # コンロを確保しているあいだだけ料理する
try:
async with asyncio.timeout(limit_s):
await plate(n, attempt)
return True
except TimeoutError:
pass # コンロを手放し、列の最後に並び直してもう一度
return False
async def service(stoves_n, limit_s, retries):
stoves = asyncio.Semaphore(stoves_n)
start = time.perf_counter()
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(order(n, stoves, limit_s, retries)) for n in range(1, 13)]
served = sum(t.result() for t in tasks)
print(f"stoves={stoves_n} limit={limit_s} retries={retries}: "
f"served {served}/12, {time.perf_counter() - start:.1f}s")
asyncio.run(service(3, None, 0)) # 制限時間なし
asyncio.run(service(3, 0.6, 0)) # 制限だけ
asyncio.run(service(3, 0.6, 1)) # 制限 + 再試行1回
# stoves=3 limit=None retries=0: served 12/12, 3.5s
# stoves=3 limit=0.6 retries=0: served 9/12, 2.2s
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7s
# (かかる時間は実行のたびに0.1秒ほど変わる。1行目は3.5sか3.6s)asyncio.timeout(None) は制限なしです。だから同じ関数で「制限なし」と「0.6秒制限」を両方回せます。設計で押さえておきたい点が2つあります。
async with stoves が for の中にあるので、切られた皿はコンロを解放してから並び直します。コンロを握ったまま再試行すると、そのあいだほかの皿が遊んでしまいます。False で返します。 TaskGroupで例外が起きると、残りがすべてキャンセルされます(10限目)。団体予約ならそれが正解ですが、コースは1皿失敗しても残りは出さなければなりません。同じ理由で、asyncio.gather で集めてもかまいません。実務では、再試行のあいだに await asyncio.sleep(0.05 * 2 ** attempt) のように少しずつ長く休むことがよくあります。全員が同時にまた押し寄せるのを防ぐためです(5限目のライブロックと同じ理由)。
下のタイムラインは、サーバーで同じ処理係を動かします。1行が1皿で、制限時間で切られた試行は灰色、再試行には r1、r2 が付きます。初期設定は、わざと目標を達成できないようにしてあります。
厨房タイムライン
注文
6
コンロ
2
制限時間(秒)
オフ
再試行
0
調理
待ち
キャンセル
失敗
シェフのひとこと
制限時間は「ふつうの皿よりは長く、しくじった皿よりは短く」。ふつうの皿まで切っちゃったら、再試行をいくら付けても無駄。数字を決める前に、まずふつうにかかる時間を測って。
評論家のコースには、生地(計算)、古いオーブンのパン(ブロッキング)、コンロ料理(待ち)が一緒に入っています。それぞれを向いている人に任せ、ホールのルーが全部を待ちます。プロセスを使うので、ファイルに保存して実行します。
import asyncio, threading, time
from concurrent.futures import ProcessPoolExecutor
def knead(n): # 生地:手がふさがる仕事 → 支店の厨房(プロセス)
return sum(i * i for i in range(n)) % 97
tally_lock = threading.Lock() # オーブンのスレッドとループが一緒に書き換える帳簿
tally = {"plates": 0}
def count_plate():
with tally_lock: # ロックの中にはawaitも長い仕事もない
tally["plates"] += 1
def old_oven(item): # 古いオーブン:ブロッキング → スレッド
time.sleep(0.4)
count_plate()
return f"{item} baked"
async def stove_dish(n, stoves): # コンロ料理:セマフォ
async with stoves:
await asyncio.sleep(0.2)
count_plate()
return f"#{n}"
async def main():
loop = asyncio.get_running_loop()
stoves = asyncio.Semaphore(3)
with ProcessPoolExecutor(max_workers=2) as dough_room:
async with asyncio.timeout(5): # コース全体の締め切り
results = await asyncio.gather(
loop.run_in_executor(dough_room, knead, 3_000_000),
asyncio.to_thread(old_oven, "bread"),
*(stove_dish(n, stoves) for n in range(1, 7)),
)
print(results)
print(tally)
if __name__ == "__main__":
asyncio.run(main())
# [56, 'bread baked', '#1', '#2', '#3', '#4', '#5', '#6']
# {'plates': 7}| 仕事 | 担当 | 道具 |
|---|---|---|
| ホール全体、待つこと | ルー | asyncio.run, gather |
| 生地(計算) | 支店の厨房 | run_in_executor + ProcessPoolExecutor |
| 古いオーブン(ブロッキング) | オーブンの前の料理人 | asyncio.to_thread |
| コンロ料理 | コンロ3つ | asyncio.Semaphore(3) |
| 帳簿(共有状態) | 黒板のロック | threading.Lock(スレッドとループが一緒に使うので) |
| 締め切り | シェフのタイマー | asyncio.timeout(5) |
帳簿はオーブンのスレッドとループの両方から書き換えるので、threading.Lock を使いました。ループ側でこのロックを取っても大丈夫なのは、ロックの中に await がなく、数字をひとつ書き換えてすぐ放すからです。11限目で見た事故は、ロックを握ったまま await で譲ったときに起きます。
評論家の前で厨房が止まらないように、営業前にこの6つを確認します。
| チェック | 症状 | 処方 | 限目 |
|---|---|---|---|
| 共有状態 | 数がときどき合わない | 読み込み〜書き込みを Lock でくくるか、キューで持ち主をひとりに | 3, 11 |
| ロックの順番 | ときどき永遠に止まる | 全員が同じ順番でロックする、acquire(timeout=) | 5 |
| ブロッキング呼び出し | asyncioがまるごと遅い | to_thread、debug=True で探す | 11 |
| 握りつぶしたキャンセル | 制限時間とTaskGroupが効かない | CancelledError は後片づけのあと raise し直す | 10 |
| 参照のないタスク | バックグラウンドの仕事がときどき消える | タスクを集めてつかんでおくか、TaskGroup | 9 |
| 終わらないキュー | join() が戻ってこない | 取り出した分だけ task_done()、終了の合図(None、shutdown()) | 6, 11 |
「参照のないタスク」の処方はこうです。タスクを集合に入れてつかんでおき、終わったら自分で抜けるようにします。
import asyncio
running = set() # タスクをつかんでおく場所
async def wipe(table):
await asyncio.sleep(0.05)
print("wiped", table)
async def main():
for t in ("T1", "T2"):
task = asyncio.create_task(wipe(t))
running.add(task)
task.add_done_callback(running.discard) # 終わったら自分で抜ける
await asyncio.sleep(0.1)
print(len(running))
asyncio.run(main())
# wiped T1
# wiped T2
# 0「終わらないキュー」は、task_done() をひとつでも忘れると起きます。怪しいときは、join() に制限時間をかけて確かめます。
import asyncio
async def cook(q):
while True:
await q.get()
await asyncio.sleep(0.01)
# q.task_done() ← 忘れた
async def main():
q = asyncio.Queue()
for n in range(3):
q.put_nowait(n)
worker = asyncio.create_task(cook(q))
try:
async with asyncio.timeout(0.3):
await q.join()
except TimeoutError:
print("join never finished:", q.qsize(), "in queue")
worker.cancel()
asyncio.run(main())
# join never finished: 0 in queueキューは空なのに(0 in queue)、join() は終わりません。取り出した伝票3枚が「終わった」という印をもらえていないからです。
これまでの夜の厨房を、ひとつの実験台に集めました。同じ8つの注文を、方式だけ変えて回してみてください。料理人とコンロのスライダーは、その値を使う方式でだけ効きます。
厨房タイムライン
注文
6
料理人
3
コンロ
2
調理
待ち
キャンセル
失敗
評論家のコースを達成した設定はコンロ3、制限時間0.6秒、再試行1です。サーバーの実験台のエンジンで実際に回して確かめた値です。
| コンロ | 制限時間 | 再試行 | 提供 · 失敗 | かかった時間 | 目標 |
|---|---|---|---|---|---|
| 2 | 0.4秒 | 0 | 0 · 12 | 約2.4秒 | 失敗 |
| 3 | オフ | 0 | 12 · 0 | 約3.5〜3.6秒 | 時間オーバー |
| 4 | オフ | 0 | 12 · 0 | 約3.0秒 | コンロ超過 |
| 3 | 0.4秒 | 2 | 0 · 12 | 約4.9秒 | 失敗 |
| 3 | 0.6秒 | 0 | 9 · 3 | 約2.2秒 | 3皿失敗 |
| 3 | 0.6秒 | 1 | 12 · 0 | 約2.7秒 | 成功 |
# 12-2のservice関数で
asyncio.run(service(3, 0.6, 1))
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7s12皿目が出て2.7秒。シェフのタイマーが止まります。評論家は手帳に何行か書きつけ、麺の器を空にして立ち上がります。レジの前で、手帳を1枚破ってシェフに手渡します。
「12皿、一度も冷めずに出てきました。待ち時間はあったけれど、ぼんやり待っている人は誰もいなかった。また来ます。今度はただのお客として」
評論家が帰り、厨房の明かりがひとつずつ消えていきます。シェフがエプロンのポケットから最後のレシピカードを取り出し、副料理長に手渡します。「12の夜だったね。明日の夜10時も店は開く。今度はあんたが注文書を書く番。1行目に何を書く?」
| 限目 | その夜 | 残った1行 |
|---|---|---|
| 1 | 初日の行列:並行と並列 | 待つ仕事と手がふさがる仕事を分ける。並行はかわるがわる、並列は同じ瞬間に。 |
| 2 | 料理人を増やす:threadingの基本 | Thread(target=)、start()、join()。結果は自分で持ち帰る。 |
| 3 | 黒板の数が合わない:競合状態とLock | 読み込み〜書き込みのあいだに割り込まれると数が狂う。with lock: は短く。 |
| 4 | コンロは3つだけ:同期の道具 | Semaphore、Event、Condition、Barrier。 |
| 5 | 包丁とまな板:デッドロック | 同じ順番でロックするか、取れなければ置く。 |
| 6 | 伝票差し:queue | 生産者と消費者をキューで切り離し、task_done() と join() で終わりを知る。 |
| 7 | 出張料理人:concurrent.futures | ThreadPoolExecutor、Future、as_completed と map。 |
| 8 | 生地はなぜ速くならない:GILと並列 | 計算はプロセスで、または3.14tで。 |
| 9 | 一人で回すホール係:asyncioの第一歩 | async def、await、gather、create_task。 |
| 10 | 団体予約のキャンセル:構造化された並行処理 | TaskGroup、except*、asyncio.timeout、キャンセルは送出し直す。 |
| 11 | 止まったホール:ブロッキングと組み合わせ | ブロッキングは to_thread へ、asyncio版の道具はasyncioの中だけで。 |
営業日誌 — 十二日目の夜。評論家の12皿を、コンロ3、制限時間0.6秒、再試行1で約2.7秒ですべて出した。ホールはasyncio、生地はプロセス、古いオーブンはスレッド、コンロはセマフォ。明日から、注文書は副料理長が書く。
await asyncio.sleep(0.05 * 2 ** attempt))を入れてみてください。コンロを確保する前に休むべきでしょうか、確保したあとに休むべきでしょうか? 両方回して、かかった時間を比べてみてください。シェフのレシピカード
まず問う:待つ仕事か(asyncio、スレッド)、手がふさがる仕事か(プロセス、3.14t)?
asyncioの中のブロッキングはto_thread、重い計算はrun_in_executor(プロセスプール)。
資源の上限はSemaphore、時間の上限はasyncio.timeout、一緒に終える仕事はTaskGroup。
再試行はコンロを手放して列の最後から。制限時間は「ふつうより長く、しくじったものより短く」。
チェックリスト:共有状態、ロックの順番、ブロッキング呼び出し、握りつぶしたキャンセル、参照のないタスク、終わらないキュー。
厨房はプログラムだ。棒を描いてみれば見えてくる。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。