今夜の注文書
#09 · 九日目の夜
ピンチ
ホール改装の初日。ホール係はルー一人、テーブルは10卓。1卓の料理を厨房の前で待っていると、残りの9卓が止まってしまう。
目標
10卓の料理を、ホール係を増やさずに0.5秒以内に全部出す。
深夜食堂 ドンシ
今夜からホールが変わります。ホール係はルー一人。テーブルは10卓です。シェフは人を増やしませんでした。
ルーは休まずホールを1周ずつ回ります。注文を取って厨房に通し、料理を待つ代わりにすぐ次のテーブルへ向かいます。厨房でベルが鳴ればその皿を持って出ていきます。1周、また1周。ベル(イベント)に反応しながら同じ道を回り続けるその姿に、初日から料理人たちがつけたあだ名がイベントループです。
ルーがエプロンのひもを結びながら言います。「テーブルの前に突っ立ってぼーっと待つのが、いちばん嫌なんです。待っているあいだ、ほかのテーブルが私を待ってるじゃないですか」
シェフがうなずきます。「昨日までは人を増やしてきた。今日は、一人が待つ時間をどう使うか。Pythonではasyncioって呼ぶんだ」
関数の前に async をつけるとコルーチン関数になります。まず不思議な点から見ていきましょう。呼んでも実行されないのです。
import asyncio
async def take_order(table):
print("order", table)
return f"T{table}"
c = take_order(1)
print(type(c).__name__)
print(c)
# coroutine
# <coroutine object take_order at 0x...>
# <sys>:0: RuntimeWarning: coroutine 'take_order' was never awaitedtake_order(1) は "order 1" を出力しませんでした。関数の本体は1行も動いていません。was never awaited の警告を出します。この警告が見えたら、ほぼ間違いなく await を書き忘れています。メモを実行する方法は2つあります。いちばん外側では asyncio.run() に渡し、別のコルーチンの中では await します。
print(asyncio.run(take_order(1)))
# order 1
# T1
c = take_order(2)
asyncio.run(c)
asyncio.run(c)
# order 2
# RuntimeError: cannot reuse already awaited coroutineコルーチンオブジェクトは一度しか使えません。同じ注文をもう一度するなら、take_order(2) をもう一度呼んで新しいメモを作ります。
await は「この仕事が終わるまで、私はここで待つ。そのあいだループはほかの仕事をしていていい」という意味です。await は async def の中でしか使えません。普通の def の中に書くと SyntaxError: 'await' outside async function になります。
import asyncio
import time
async def table(n, wait):
print(f"T{n} order")
await asyncio.sleep(wait) # 厨房を待っているあいだ、ルーは自由
print(f"T{n} served")
return f"T{n}"
async def one_by_one():
a = await table(1, 0.3)
b = await table(2, 0.3)
return [a, b]
start = time.perf_counter()
print(asyncio.run(one_by_one()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T1 served
# T2 order
# T2 served
# ['T1', 'T2'] 0.62sasyncio.sleep(0.3) は time.sleep(0.3) のasyncio版です。待っているあいだ、ループに順番を譲ります。await table(1, ...) が終わらないと次の行に進まないからです。ルーが1番テーブルの前で、料理が出てくるまで立っているのと同じです。await は「同時に」ではなく「待つ」です。同時に待つには、次の節の道具が必要です。asyncio.run(main()) はプログラムの入口です。
main() コルーチンを終わるまで回す。プログラムの中で普通は一度だけ、いちばん外側でだけ呼びます。すでにループが動いている場所でもう一度呼ぶと RuntimeError: asyncio.run() cannot be called from a running event loop になります。
イベントループは、ひとつのスレッドで回るループです。していることは、ルーがホールを回るのと同じです。
await まで実行する。3卓を同時に任せると(次の節の gather)、ルーの動きはこうなります。1番は0.3秒、2番は0.2秒、3番は0.1秒かかる料理です。
| 時刻 | ルーがすること | 出力 |
|---|---|---|
| 0.0秒 | T1の注文を通し、await で席を離れる | T1 order |
| 0.0秒 | T2の注文を通して席を離れる | T2 order |
| 0.0秒 | T3の注文を通して席を離れる | T3 order |
| 0.0〜0.1秒 | やることがないので、いちばん早いベルを待つ | |
| 0.1秒 | T3のベル。皿を持って出ていく | T3 served |
| 0.2秒 | T2のベル | T2 served |
| 0.3秒 | T1のベル | T1 served |
2つのことを覚えておいてください。
await でしか替わりません。スレッドはOSがいつでも割り込みますが(3限目)、コルーチンは自分で await したときにしか席を外しません。だから await と await のあいだのコードは、途中で切られることがありません。asyncio.gather(コルーチン, コルーチン, ...) は、複数のコルーチンをいっぺんにループに載せ、全部が終わるまで待ちます。
async def together():
return await asyncio.gather(table(1, 0.3), table(2, 0.2), table(3, 0.1))
start = time.perf_counter()
print(asyncio.run(together()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T2 order
# T3 order
# T3 served
# T2 served
# T1 served
# ['T1', 'T2', 'T3'] 0.31sexecutor.map と同じです。gather(*coros) のようにアスタリスクで展開します。下の実習台で「1卓ずつawait」と「gatherでまとめて」を切り替えながら回してみてください。1列が1卓で、バーはすべて「待ち」の色です。ルーが実際に手を動かす時間はほとんどなく、大半は厨房を待っている時間だからです。
厨房タイムライン
注文
6
調理
待ち
キャンセル
失敗
gather に渡したコルーチンのひとつが例外を出すと、gather はその例外をそのまま投げます。結果を全部受け取りたいなら return_exceptions=True を渡します。
async def table(n):
await asyncio.sleep(0.1 * n)
if n == 2:
raise ValueError(f"T{n} wrong dish")
return f"T{n}"
async def main():
results = await asyncio.gather(table(1), table(2), table(3), return_exceptions=True)
print(results)
try:
await asyncio.gather(table(1), table(2), table(3))
except ValueError as e:
print("ValueError:", e)
asyncio.run(main())
# ['T1', ValueError('T2 wrong dish'), 'T3']
# ValueError: T2 wrong dishreturn_exceptions=True なら、例外も結果リストの1枠に入ります。結果をひとつずつ isinstance(r, Exception) で確かめてください。False)のとき、例外が上がってきても残りのテーブルはキャンセルされず動き続けます。「ひとつが失敗したら残りを止めろ」は、10限目の TaskGroup がやってくれます。シェフのひとこと
was never awaited の警告は、注文書を書いたのに厨房に通してないってこと。コルーチン関数を呼んだら、必ず await するかタスクにして。じゃないと、その料理は永遠に出てこないよ。
asyncio.create_task(コルーチン) は、コルーチンをタスクで包んですぐループに予約します。タスクはルーの手が空いたとき(いまのコルーチンが await したとき)に始まります。終わるのを待たずに次の行へ進むので、注文を先に通しておいて別の仕事ができます。
async def table(n, wait):
print(f"T{n} order")
await asyncio.sleep(wait)
print(f"T{n} served")
return f"T{n}"
async def main():
start = time.perf_counter()
t1 = asyncio.create_task(table(1, 0.3))
t2 = asyncio.create_task(table(2, 0.2))
print("Lou: 2 tasks created")
await asyncio.sleep(0.1) # ルーがちょっと別の仕事をする
print("Lou: back", t1.done(), t2.done())
print(await t1, await t2, f"{time.perf_counter() - start:.2f}s")
asyncio.run(main())
# Lou: 2 tasks created
# T1 order
# T2 order
# Lou: back False False
# T2 served
# T1 served
# T1 T2 0.31s"Lou: 2 tasks created" が T1 order より先に出ます。タスクは作った瞬間に予約されますが、初めて実行されるのは main が await で席を外したときです。await t1 は、すでに動いているタスクが終わるのを待ちます。2つのタスクが一緒に待ったので、0.5秒ではなく約0.3秒です。gather は受け取ったコルーチンを内部でタスクに包んでくれます。結果をまとめて受け取れば十分なら gather、個別に持っておいて状態を見たり後で待ったりするなら create_task を使います。イベントループはタスクを弱参照でしか持っていません。変数やコレクションに入れていないタスクは、終わる前にガベージコレクションで消えてしまうことがあります。水のおかわりのように結果がいらない仕事を任せるときも、参照は握っておく必要があります。
background = set()
async def refill_water(table):
await asyncio.sleep(0.1)
print(f"T{table} water")
async def main():
for n in (1, 2, 3):
task = asyncio.create_task(refill_water(n))
background.add(task) # 参照を握っておく
task.add_done_callback(background.discard) # 終わったら自分で抜ける
print("pending", len(background))
await asyncio.sleep(0.2)
print("pending", len(background))
asyncio.run(main())
# pending 3
# T1 water
# T2 water
# T3 water
# pending 0asyncio.create_task(refill_water(n)) のように結果を捨てる1行は、たいていうまく動きます。でも「たいてい」が問題です。ときどき消えるタスクは、いちばん見つけにくいバグです。
どちらも「ちょっと待つ」ですが、ルーにとってはまったく別のことです。
async def polite(n):
await asyncio.sleep(0.3) # 待っているあいだループに順番を譲る
async def stubborn(n):
time.sleep(0.3) # ループごと止まる
async def main(fn):
start = time.perf_counter()
await asyncio.gather(*(fn(n) for n in range(3)))
print(fn.__name__, f"{time.perf_counter() - start:.2f}s")
asyncio.run(main(polite))
asyncio.run(main(stubborn))
# polite 0.32s
# stubborn 0.90sasyncio.sleep は、await した瞬間にループへ「0.3秒後に起こして」と頼んで席を外します。time.sleep はスレッドそのものを止めます。ループが回っているスレッドはひとつだけなので、ルーがテーブルの前に突っ立ってぼーっと待っているのと同じです。そのあいだ、ほかのテーブルは誰も見てくれません。gather を使っても意味がありません。time.sleep だけでなく、時間のかかる計算や、待っているあいだに順番を譲らない古いライブラリも、同じようにループを止めます。この問題と解決策は11限目のテーマです。
asyncioには似た名前が3つ出てきます。
| 名前 | 厨房でいえば | すること |
|---|---|---|
| コルーチン(coroutine) | 注文のメモ | async def を呼ぶとできる。ひとりでは動かない |
| タスク(Task) | 厨房に通した注文 | コルーチンを包んでループに載せる。done()、result()、cancel() |
| フューチャー(Future) | あとで鳴るベル | 「いつか値が入る場所」。タスクはフューチャーの一種 |
print(issubclass(asyncio.Task, asyncio.Future)) # True
async def main():
coro = take_order(1)
task = asyncio.create_task(coro)
print(type(coro).__name__, type(task).__name__) # coroutine Task7限目の concurrent.futures.Future と考え方は同じです。ただしasyncioのフューチャーは await でき、同じループの中でしか使いません。自分でフューチャーを作ることはまれで、普通はコルーチンとタスクで十分です。
スレッドはOSが作るものなので、1本ごとにスタックメモリとOSの管理コストがかかります。タスクはPythonのオブジェクトひとつにすぎません。
async def table(n):
await asyncio.sleep(0.5)
return n
async def main():
return await asyncio.gather(*(table(n) for n in range(10_000)))
# 10,000 tasks: 約0.57秒 (0.5秒待つテーブル1万卓)
# 1,000 threads: 約0.62秒 (0.5秒待つスレッド1000本)
# このPCで測った値タスク1万個を立ち上げるコストが、スレッド1000本より小さいのです。待つ仕事が何千、何万とあるなら(チャットサーバー、大量のURLへのリクエスト)、asyncioがよく合う理由です。
今夜の目標は、10卓の料理をホール係を増やさずに0.5秒以内に全部出すことでした。ルー一人、イベントループひとつ、gather ひとつで足ります。
import asyncio
import time
async def table(n):
await asyncio.sleep(0.3) # 厨房から料理が出てくるのを待つ
return f"T{n}"
async def hall():
return await asyncio.gather(*(table(n) for n in range(1, 11)))
start = time.perf_counter()
served = asyncio.run(hall())
print(served)
print(f"{time.perf_counter() - start:.2f}s")
# ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', 'T10']
# 0.30sスレッドはひとつも作っていません。0.3秒のテーブル10卓が、約0.3秒で終わりました。
| スレッド(2〜7限目) | asyncio(今日から) | |
|---|---|---|
| 働く人 | 料理人が何人も | ルー一人 |
| 順番が替わるとき | OSがいつでも | await でだけ |
| 1つあたりのコスト | 大きい(OSのスレッド) | 小さい(Pythonのオブジェクト) |
| 気をつけること | 競合状態、デッドロック | ブロッキング呼び出し、await の書き忘れ |
ルーが空のトレーを置きます。「10卓、1周で終わりました」シェフがタイマーを見ながら尋ねます。「いいね。でも明日は団体予約だよ。6卓のうち1卓がキャンセルになったら、残り5卓の料理はどうやって止める?」
営業日誌 — 九日目の夜。ルーが一人で10卓を担当。待っているあいだに席を離れるやり方(await)と、まとめて待つやり方(gather)で、0.3秒で配膳。明日は団体予約。
take_order を await なしで呼ぶ行と、asyncio.run() で呼ぶ行を順番に実行し、出力と警告を比べてみましょう。main から await asyncio.sleep(0.1) を消すと、出力の順番はどう変わるでしょう? 先に予想を書いてから、実行して確かめてみましょう。シェフのレシピカード
async def はコルーチンを作る。呼ぶだけでは実行されない(was never awaited の警告)。
asyncio.run(main()):ループを開き、mainを最後まで回して閉じる。いちばん外側で一度だけ。
await:終わるまで待ちながらループに順番を譲る。順番は await でしか替わらない。
await asyncio.gather(*coros):同時に待ち、結果は渡した順。return_exceptions=True。
task = asyncio.create_task(coro):先に任せておく。参照は握っておくこと(弱参照)。
asyncio.sleep は順番を譲り、time.sleep はループを止める。
コルーチン(メモ)→ タスク(通した注文)⊂ フューチャー(あとで鳴るベル)。タスク1万個でも軽い。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。