8.15. 落とし穴

asyncio を心地よくしている同じパターン(プリエンプションがないこと、明示的な await)が、痛い目を見る独自のパターンも生み出します。このページは、知っておく価値があるほど頻繁に現れるものを集めたカタログです。

8.15.1. await し忘れる

async def 関数を呼び出すと コルーチンオブジェクト が返されます。これは関数の本体を実行しません。実際に実行するには、コルーチンを await するか、タスクにラップする必要があります:

async def main():
    send_request()              # bug: returns the coroutine, does nothing
    await send_request()        # right: run it to completion
    asyncio.create_task(send_request())  # right: run it concurrently

このバグは静かです。コルーチンオブジェクトは作成され、破棄され、決して実行されません。アプリケーションはすべてうまくいったかのように進行します。MicroPython は、コルーチンが await されなかった という警告をログに出すこともあれば、出さないこともあります。関数呼び出しのように見えるすべての呼び出し箇所で、await の抜けを確認してください。

8.15.2. await のないタイトループ

ループ内で実行され、決して await しないコルーチンは、イベントループを独占します。ループが終了するか制御を譲るまで、ほかのタスクは一切進行しません:

async def counter():
    n = 0
    while True:
        n += 1               # bug: starves the loop

解決策は、ループ内に yield を入れること(通常は await asyncio.sleep_ms(0))で、実行可能なほかのタスクに動く機会を与えることです。計算負荷の高い処理もこの形に収まります。1 回の反復に数百ミリ秒かかる画像処理ループは、プログラムのほかの部分が停止しないよう、反復ごとに少なくとも 1 回は制御を譲るべきです。

8.15.3. CancelledError を握りつぶす

キャンセルのページ で、すでにこれを詳しく取り上げました。ここで繰り返すのは、これが「アプリケーションがシャットダウンしてくれない」という最も一般的な原因だからです。コルーチンがクリーンアップ目的で asyncio.CancelledError を捕捉し、再送出を忘れるのです。タスクは実行され続け、キャンセルを要求した呼び出し側は、それが完了するのを永遠に待ち続けます。クリーンアップ後は必ず再送出するか、明示的な except の代わりに try/finally ブロックを使ってください。

8.15.4. await をまたいで共有状態を変更する

協調スケジューリングは、コルーチンが await の は CPU を独占できることを保証しますが、await した瞬間に、別のコルーチンが実行されることがあります。2 つのコルーチンが、await を含むステップで同じデータ構造を変更すると、それらの操作が、構造を破壊するような形でインターリーブされる可能性があります:

# bug: two tasks running do_work simultaneously can
# interleave around the await and corrupt items
async def do_work():
    n = len(items)
    await asyncio.sleep_ms(0)
    items.append(some_work(n))

1 つのコルーチン内の await の間で のみ 変更される状態には、同期は不要です。await をまたいで変更され、複数のコルーチンからアクセスされる状態については、クリティカルセクションを Lock でラップしてください。

8.15.5. モジュールレベルの await

awaitasync def の本体内でのみ有効です。モジュールレベル(どのコルーチンの外側)で書くと、構文エラーになります:

# bug: not inside an async def
result = await fetch()

解決策は、その処理をコルーチンに入れて、プログラムの asyncio.run() エントリポイントから呼び出すことです。

8.15.6. 複数回の asyncio.run 呼び出し

MicroPython にはイベントループが 1 つ しかありません。asyncio.run() を続けて 2 回(一度はセットアップ用、一度はメイン処理用)呼び出しても、同じループを使います。それを実行中のコルーチンの 内側から 呼び出すのはエラーです。ループはすでに実行中だからです。どちらのケースも、スクリプトが自然に大きくなり、作者が新しい処理を既存の main に折り込むのではなく、さらに run() 呼び出しを追加して拡張しようとしたときに、最もよく起こります。

8.15.7. 割り込みからの Event の使用

asyncio.Event.set() は、イベントループの内側からのみ安全に呼び出せます。GPIO 割り込みハンドラから呼び出すのは破損の危険があります。割り込みからタスクを起こすには、代わりに ThreadSafeFlag を使ってください。それに関するページ でその形を取り上げています。

8.15.8. 長い同期呼び出し

コルーチンは asyncio 自身の待機プリミティブを await できますが、それ以外に呼び出すものはすべて同期的に実行され、戻るまでループをブロックします。200 ms のブロッキングする time.sleep()、フラッシュに 80 ms かかる SD カードへの書き込み、大きな JPEG 圧縮、csi.CSI.snapshot() 呼び出しなど、それぞれがその全期間にわたってイベントループを保持します。解決策は呼び出しによって異なります:

  • time.sleep の場合: await asyncio.sleep または await asyncio.sleep_ms に置き換えます。

  • csi.CSI.snapshot の場合: キャプチャのページで構築する 非同期スナップショットラッパー を使います。

  • 長い計算(画像処理、JPEG エンコード)の場合: そのコストを受け入れるか、処理を反復間で await するチャンクに分割します。

asyncio は同期呼び出しを非ブロッキングにすることはできません。何か別のものが await している に、ほかのコルーチンを実行させることができるだけです。