8.5. タイムアウトとキャンセル

キャンセルとは、「このコルーチンの実行を止め、その内部で例外を送出してクリーンアップの機会を与え、スケジューラから取り除く」という処理に asyncio が付けた名前です。タイムアウトは何かをキャンセルする最も一般的な理由であり、手動の task.cancel() がもう一つの理由です。

8.5.1. タスクのキャンセル

Task.cancel を呼び出すと、実行中のコルーチンの次の await の箇所で asyncio.CancelledError が送出されるようスケジュールされます。コルーチンはキャンセルを無視する(例外を捕捉して実行を続ける)ことも、尊重することも自由です -- 通常の選択は、クリーンアップコードを実行した後にそれを尊重することです:

async def network_worker(stream):
    try:
        while True:
            data = await stream.read(64)
            # ...
    finally:
        stream.close()

むき出しの finally 節が最もすっきりしたパターンです。クリーンアップは、コルーチンが正常に終了したか、無関係な例外を送出したか、キャンセルされたかにかかわらず実行されます。CancelledErrorfinally を通って上へと伝播し、ループはタスクが完了したことを認識し、await task の呼び出し元はキャンセルを見ます。

CancelledError を明示的に捕捉することも、アプリケーションがそれに対して特定の処理 -- ログに記録する、リソースをきれいに引き渡すなど -- をしたい場合には問題ありません。ルールは、クリーンアップが実行された後にそれを再送出する ことです。CancelledError を握りつぶすと、呼び出し元が停止を要求しているのにコルーチンが生き続けることになり、これはほぼ常にバグです:

async def worker():
    try:
        await long_running_thing()
    except asyncio.CancelledError:
        log("worker cancelled, cleaning up")
        close_resources()
        raise          # << this line is the important one

8.5.2. wait_for によるタイムアウト

asyncio.wait_for() は、アウェイタブルを期限でラップします。アウェイタブルがタイムアウト内に終了すれば、その結果が返されます。終了しなければ、アウェイタブルはキャンセルされ、呼び出し元は asyncio.TimeoutError を受け取ります:

try:
    frame = await asyncio.wait_for(grab_frame(), timeout=2)
except asyncio.TimeoutError:
    print("camera took too long")

秒の引数は浮動小数点数を受け付けます。ミリ秒単位の期限には asyncio.wait_for_ms() が整数のミリ秒数を取ります -- これは、ファームウェアのミリ秒粒度のタイミング調整項目と揃えた MicroPython の拡張です。

内部的には、wait_for は手動のキャンセルとまったく同じことを行います。期限が切れると、ラップされたタスクに対して cancel() を呼び出し、コルーチン内で CancelledError が送出され、finally 節が実行され、クリーンアップが完了すると、その例外は呼び出し元向けに TimeoutError へ変換されます。

つまり、CancelledError を捕捉して無視するコルーチンはタイムアウトを 無効化 します -- 期限は切れたのに、コルーチンは停止を拒み、wait_for はそれを強制できないからです。先ほどのルールがここでも当てはまります。CancelledError はクリーンアップを実行するためだけに捕捉し、それから再送出するのです。

8.5.3. gather を通したキャンセル

キャンセルは gather() を通して下方向へ伝播します。gather 呼び出しを await しているタスクがキャンセルされると、その gather 内でまだ実行中のすべてのアウェイタブルもキャンセルされます -- それぞれが、キャンセルが呼び出し元へとさかのぼる前に、自身の finally 節を通してクリーンアップする機会を得ます。

タイムアウトと組み合わせれば、これは操作の グループ に期限を設ける標準的な方法になります:

await asyncio.wait_for(
    asyncio.gather(a(), b(), c()),
    timeout=5,
)

すべてのサブ操作が5秒以内に終了するか、さもなければそれらすべてが一緒にキャンセルされます。

8.5.4. アプリケーションのシャットダウン

キャンセルは、実際のアプリケーションがきれいに停止する方法でもあります。このパターンはスクリプト全体で一貫しています。main は、自身が起動した長寿命のバックグラウンドタスクのハンドルを保持し、トップレベルの作業を実行し、それから各ハンドルをキャンセルして finally ブロックの中で await します:

async def main():
    sender = asyncio.create_task(uplink())
    watcher = asyncio.create_task(button_watcher())
    try:
        await snapshot_loop()
    finally:
        sender.cancel()
        watcher.cancel()
        await asyncio.gather(sender, watcher,
                             return_exceptions=True)

return_exceptions=True は、各子タスクが送出しようとしている CancelledError を gather が再送出しないようにするための仕掛けです。これにより、アプリケーション自身の終了理由 -- snapshot_loop が送出したか、しなかったかにかかわらず -- が main から表に出てくるものになります。