Kodokon kodokon.com

asyncioによる非同期:コルーチン、await、gather

asyncioの協調的並行性を理解しましょう。遅延評価されるコルーチン、中断ポイント、gather、TaskGroup、そしてブロッキング呼び出しの落とし穴です。

10 分 · 3 問

このレッスンを Kodokon で開く

asyncioは単一のスレッド上で協調的並行性を実現します。イベントループが、awaitのたびに自発的に制御を手放すコルーチンたちを統率するのです。よく誤解される基本的な点があります。async def関数を呼び出しても、何も実行されません。生み出されるのは動かないコルーチンオブジェクトで、その本体はawaitで待たれるか、ループ上のタスクとしてスケジュールされたときにはじめて進みます。

PYTHON
import asyncio

async def greet() -> str:
    return "hello"

coro = greet()
print(type(coro).__name__)

async def main() -> None:
    print(await coro)

asyncio.run(main())
greet()の呼び出しはコルーチンオブジェクトを作るだけ。awaitの前には何も実行されない

await中断ポイントを示します。コルーチンはそこで凍りつき、ループが制御を取り戻して、実行可能な別のタスクを進めます。二つのawaitのあいだ、あなたのコードは中断されずに走ります - ほとんどのデータ構造にロックは不要です。複数のコルーチンを(I/Oの意味で)並行に走らせるには、asyncio.gatherで包みましょう。1秒の待機が三つなら、合計3秒ではなく1秒で終わります。

PYTHON
import asyncio
import time

async def fetch(name: str, delay: float) -> str:
    await asyncio.sleep(delay)
    return f"{name} done"

async def main() -> None:
    start = time.perf_counter()
    results = await asyncio.gather(
        fetch("a", 1.0),
        fetch("b", 1.0),
        fetch("c", 1.0),
    )
    elapsed = time.perf_counter() - start
    print(results)
    print(f"total: {elapsed:.1f} s")

asyncio.run(main())
三つの並行した待機:合計でおよそ1秒

デフォルトでは、gatherは最初に送出された例外を伝播させますが、ほかのタスクはバックグラウンドで走り続けます - これが望みどおりであることはめったにありません。return_exceptions=Trueを指定すると、例外は結果として返され、あとで仕分けることになります。Python 3.11以降はasyncio.TaskGroupを選びましょう。最初のエラーの時点で、兄弟のタスクはすべてきれいにキャンセルされ、複数の例外があればExceptionGroupにまとめられて浮かび上がり、except*で捕まえられます。

PYTHON
import asyncio
import time

def blocking_io() -> str:
    time.sleep(1)
    return "report written"

async def main() -> None:
    results = await asyncio.gather(
        asyncio.to_thread(blocking_io),
        asyncio.sleep(1, result="tick"),
    )
    print(results)

asyncio.run(main())
to_threadはブロッキングコードを隔離する。ループは応答し続ける

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. awaitcreate_taskもなく、単独で書かれたfetch('a', 1.0)という呼び出しは何を生み出しますか?
    • 動かないコルーチンオブジェクト。関数の本体は一行も実行されない
    • コルーチンのただちに同期的な実行
    • イベントループに自動でスケジュールされたタスク
    • awaitが必須なのでSyntaxError
  2. asyncio.gatherでまとめて起動した三つのasyncio.sleep(1)。合計の所要時間はどれくらいですか?
    • スレッド一つがコードを実行するので、およそ3秒
    • コア数がわからなければ予測できない
    • およそ1秒。待機が重なり合い、ループがタスクからタスクへ切り替わる
  3. なぜコルーチンの中でtime.sleep(2)は禁じられているのですか?
    • 非同期の文脈では例外を送出するから
    • イベントループのスレッドをブロックするから。2秒のあいだ、ほかのどのタスクも進まない
    • 単にasyncio.sleepより精度が低いから
    • メモリを二倍使うから