【Python】asyncioの時間切れとキャンセルで後始末を漏らさない

PythonのTopに戻る

結論:TaskGroupで待ち合わせ、finallyで資源を片付ける

非同期の仕事が時間切れや失敗で止まる場合は、タスクを作る範囲と後始末の範囲をそろえる。TaskGroupで関連する仕事をまとめ、使用中の資源は各仕事のfinallyや非同期コンテキストマネージャーで片付ける。タイムアウトを検出しただけで終了したつもりにならず、キャンセルされた仕事の後始末が完了したことまで待つのが基本である。

キャンセルは、別の処理がタスクへ停止を要求する仕組みであり、任意のPython処理を即時に強制終了する操作ではない。通常はawaitで処理を中断できる場所にCancelledErrorが届けられる。長いCPUループやブロッキング関数を呼んでいると、期限を過ぎてもすぐ応答できないことがある。非同期で使う処理自体が協調的に動くかを確認したい。

そのまま動かせる例

例では二つの異常系を分ける。前半は2タスクの待機中に全体が時間切れとなる場合、後半は1タスクがValueErrorを起こして残りがキャンセルされる場合である。どちらでもfinallyが実行されることを名前の一覧で確認する。標準ライブラリのみを使い、TaskGroup・timeout・except*が利用できるPython 3.11以降で動かす。

ファイルとして実行でき、外部通信や実ファイルは操作しない。待機は最大0.5秒、実験全体にも3秒の期限を付けている。cleanedへの追記は資源解放の代わりの観察用記録である。実処理では接続やファイルの終了処理へ置き換えるが、単にフラグを書くだけで資源が解放されたとみなさないようにする。

import asyncio

async def worker(name, cleaned, fail=False):
    try:
        await asyncio.sleep(0.01 if fail else 0.5)
        if fail:
            raise ValueError("sample failed")
        return name
    finally:
        cleaned.append(name)

async def timeout_case():
    cleaned = []
    try:
        async with asyncio.timeout(0.03):
            async with asyncio.TaskGroup() as group:
                group.create_task(worker("A", cleaned))
                group.create_task(worker("B", cleaned))
    except TimeoutError:
        print("timeout observed: True")
    else:
        raise AssertionError("expected timeout")
    assert sorted(cleaned) == ["A", "B"]
    print("timeout cleanup:", sorted(cleaned))

async def failure_case():
    cleaned = []
    caught = False
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(worker("slow", cleaned))
            group.create_task(worker("bad", cleaned, fail=True))
    except* ValueError as errors:
        caught = True
        assert len(errors.exceptions) == 1
        print("failure type:", type(errors.exceptions[0]).__name__)
    assert caught
    assert sorted(cleaned) == ["bad", "slow"]
    print("failure cleanup:", sorted(cleaned))

async def main():
    async with asyncio.timeout(3):
        await timeout_case()
        await failure_case()
    leftovers = [t for t in asyncio.all_tasks()
                 if t is not asyncio.current_task() and not t.done()]
    assert leftovers == []
    print("unfinished child tasks:", len(leftovers))

if __name__ == "__main__":
    asyncio.run(main())

実行結果

timeout observed: True
timeout cleanup: ['A', 'B']
failure type: ValueError
failure cleanup: ['bad', 'slow']
unfinished child tasks: 0

TimeoutErrorを捕まえる位置

asyncio.timeoutは対象のタスクをキャンセルし、そのキャンセルをコンテキストの外でTimeoutErrorとして扱えるようにする。したがって例ではasync withを囲む外側で捕まえている。ブロックの内側へ同じexceptを書いて期待どおり動かない場合は、例外の変換がどの境界で起こるかを確認しよう。

時間切れになるとTaskGroup内の子もキャンセルされ、グループはそれらの終了を待つ。出力のAとBは、どちらもfinallyへ到達したという意味である。キャンセルされた仕事が返り値を返して成功したという意味ではない。期限に達した事実と、後始末が済んだ事実を分けて記録しておくと、処理の状態が曖昧になりにくい。

通常の失敗はExceptionGroupとして届く

後半ではbadがValueErrorを起こすと、TaskGroupが残りのslowへキャンセルを要求する。通常の例外はグループ化されて外側へ伝わるため、ここではexcept* ValueErrorで該当部分を扱っている。複数の仕事が失敗すれば複数の例外が含まれる可能性もある。先頭の一件だけを見て他の原因を捨てないようにしたい。

except*で処理したもの以外の例外まで消えるわけではない。この例はValueErrorが1件であることを確認しているが、実システムでは入力不正、接続障害、内部不具合を区別する方針が必要になる。全てを「時間切れ」としてまとめると本当の不具合を見落とすので、例外の種類と仕事の識別情報を残しておこう。

キャンセルを飲み込まない

CancelledErrorはBaseExceptionの派生で、通常のexcept Exceptionとは扱いが異なる。特別に捕まえてログを残す場合でも、片付けた後は通常そのまま再送出する。キャンセルを正常終了へ変えたり無限に再試行したりすると、TaskGroupやtimeoutが期待する終了の流れを壊すことがある。中断を受け付けることも仕事の契約の一部と考えたい。

後始末が外部サービスへ依存して長くなるなら、その処理自体の期限や失敗時の記録も設計する。shieldを付ければ安全になると一律に考えず、何を最後まで終える必要があるのかを限定しよう。この例の最後は未終了の子Taskがないことも確認している。異常系を少数の仕事で再現し、後始末の記録と未完了タスクの両方を点検してから実処理へ組み込むとよい。

確認環境と参考資料

例はLinux・CPython 3.12.14で実行した。掲載した出力はこの環境での結果である。公式資料のstable版や最新版は更新されるため、手元のバージョンと対応する仕様も確認してほしい。

関連項目:asyncioで同時実行数を制限する:Semaphoreと待ち合わせ / Requestsで通信の失敗を扱う:timeout・HTTPエラー・JSON解析

PythonのTopに戻る