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 子句是最簡潔的模式:無論協程是正常退出、引發了無關的例外、還是被取消,清理工作都會執行。CancelledError 會穿過 finally 向上傳播,迴圈看到任務已完成,而 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 呼叫的任務被取消,gather 內部仍在執行的每個可等待物件也都會被取消 -- 在取消向上匯回到呼叫端之前,每一個都有機會透過自己的 finally 子句進行清理。

與逾時結合使用時,這是為一組操作設定期限的標準做法:

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

要麼每個子操作都在五秒內完成,要麼它們全部一起被取消。

8.5.4. 關閉應用程式

取消也是真實應用程式乾淨停止的方式。這個模式在各個指令碼中都是一致的:main 擷取它所啟動的長期背景任務的控制代碼,執行其頂層工作,然後在一個 finally 區塊中取消每個控制代碼並等待它:

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 是個訣竅,它能阻止 gather 重新引發每個子任務即將傳遞的 CancelledError,如此一來,應用程式自身的退出原因 -- 無論 snapshot_loop 引發了或未引發什麼 -- 才會是從 main 冒出的那個。