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

修正方法是在迴圈內加入一次讓出 -- 通常是 await asyncio.sleep_ms(0) -- 好讓其他就緒的任務有機會執行。耗運算量大的工作也屬於這種形態:每次迭代執行數百毫秒的影像處理迴圈,每次迭代都應至少讓出一次,這樣程式的其餘部分才不會停滯。

8.15.3. 吞掉 CancelledError

取消頁面 已詳細介紹過這點。此處重申,是因為它是「我的應用程式無法關閉」最常見的原因:協程為了清理目的而捕捉 asyncio.CancelledError,卻忘了重新引發它。任務會繼續執行;而要求取消的呼叫端則會永遠掛著等它結束。請務必在清理之後重新引發,或改用 try/finally 區塊而非明確的 except

8.15.4. 跨越 await 變更共享狀態

協作式排程保證協程在多次 await 之間獨佔 CPU,但只要它一 await,另一個協程就可以執行。如果兩個協程在包含 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))

對於只在單一協程內、各次 await 之間才變更的狀態,不需要任何同步。對於會跨越 await 變更、且由多個協程存取的狀態,請以 Lock 包住臨界區段。

8.15.5. 模組層級的 await

await 只有在 async def 主體內才有效。在模組層級 -- 任何協程之外 -- 寫它是一個語法錯誤:

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

修正方法是把該工作放進一個協程中,並從程式的 asyncio.run() 進入點呼叫它。

8.15.6. 多次呼叫 asyncio.run

MicroPython 只有一個事件迴圈。連續呼叫 asyncio.run() 兩次 -- 一次用於設定、一次用於主要工作 -- 仍然使用同一個迴圈。從正在執行的協程內部呼叫它則是一個錯誤:迴圈已經在執行中了。這兩種情況最常出現在指令碼有機地成長、作者試圖透過新增更多 run() 呼叫來擴充它,而非將新工作摺入既有的 main 之中時。

8.15.7. 從中斷使用 Event

asyncio.Event.set() 只有在事件迴圈內部呼叫才是安全的。從 GPIO 中斷處理器呼叫它是一個損毀的隱患。若要從中斷喚醒任務,請改用 ThreadSafeFlag -- 關於它的頁面 涵蓋了這種形態。

8.15.8. 冗長的同步呼叫

協程可以 await asyncio 自己的等待原語;它呼叫的任何其他東西都會同步執行,並阻塞迴圈直到該呼叫回傳為止。一個 200 毫秒的阻塞式 time.sleep()、一次需要 80 毫秒才能寫入的 SD 卡寫入、一次大型 JPEG 壓縮、一次 csi.CSI.snapshot() 呼叫 -- 這些都會在其整段持續時間內佔住事件迴圈。修正方法取決於該呼叫:

  • 對於 time.sleep:以 await asyncio.sleepawait asyncio.sleep_ms 取代它。

  • 對於 csi.CSI.snapshot:使用擷取頁面所建構的 非同步快照包裝器

  • 對於耗時運算(影像處理、JPEG 編碼):接受這個成本,或將工作切分成在各次迭代之間 await 的區塊。

Asyncio 無法讓同步呼叫變成非阻塞的。它只能在某件事正在被 await 的同時讓其他協程執行。