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.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.sleep或await asyncio.sleep_ms取代它。對於
csi.CSI.snapshot:使用擷取頁面所建構的 非同步快照包裝器。對於耗時運算(影像處理、JPEG 編碼):接受這個成本,或將工作切分成在各次迭代之間
await的區塊。
Asyncio 無法讓同步呼叫變成非阻塞的。它只能在某件事正在被 await 的同時讓其他協程執行。