8.2. 協程與任務

協程是 asyncio 程式賴以建構的工作單元;任務則是應用程式並行運行數個協程的方式。

8.2.1. 協程

協程是以 async def 宣告的函式:

import asyncio

async def heartbeat(interval_ms):
    while True:
        print("tick")
        await asyncio.sleep_ms(interval_ms)

其主體看起來像一個普通函式,但多了一項要素:await。每當協程必須等待某事物時 — 一次睡眠、一次網路讀取、一個事件被設定 — 它會 await一個知道如何將協程暫停直到所等待的事物就緒的運算式。在每一個 await 處,協程都會將控制權交還給 asyncio;一旦所等待的操作完成,asyncio 便從同一個位置恢復它。

asyncio 模組隨附兩種睡眠:

  • asyncio.sleep() — 引數以秒為單位,接受浮點數。

  • asyncio.sleep_ms() — 引數以毫秒為單位,接受整數。這是 MicroPython 的擴充;在相機上通常是正確的選擇,因為韌體中的時序調節項都是以毫秒為單位的。

單純的 async def 本身什麼也不做。呼叫 heartbeat(500) 並不會執行其主體;它會回傳一個協程物件,必須由 asyncio 來排程。最簡單的排程方式是 asyncio.run():

asyncio.run(heartbeat(500))

asyncio.run() 啟動事件迴圈,將交給它的協程排程為頂層進入點,驅動迴圈直到該協程回傳,然後將迴圈拆除。對於單一協程,這就是整個程式。對於數個協程,應用程式則會運用任務。

8.2.2. 任務

任務是 asyncio 包覆在協程外的封裝,表示將其與當前協程並行排程,並讓我繼續執行asyncio.create_task() 會建立一個任務,並回傳一個代表所排程工作的 Task 物件:

task = asyncio.create_task(heartbeat("fast", 100))

現在該協程已在迴圈的排程上了;呼叫端並未等待它。回傳的 Task 是呼叫端之後用來與那個正在運行的工作互動的控制代碼。

一旦應用程式取得了控制代碼,便可對它做三件事:

  • 等待任務完成。 Task 本身就是可等待的。result = await task 會暫停當前協程,直到 task 的協程回傳,然後以該協程所回傳的任何值恢復執行(或重新引發它所引發的任何例外)。

  • 取消任務。 task.cancel() 會排程在任務協程的下一個 await 處於其內部引發 asyncio.CancelledError,讓它有機會在 finally 區塊中執行清理程式碼。逾時與取消 一頁涵蓋了相關細節。

  • 稍後識別它。 asyncio.current_task() 會回傳當前正在執行的協程所對應的 Task。大多數指令碼從不呼叫它;它出現在偵測工具與例外處理常式中。

指令碼不必每次都擷取控制代碼。應用程式啟動後便放任其運行的拋棄式背景任務,可以丟棄回傳值 — 迴圈仍會排程它們:

import asyncio

async def heartbeat(name, interval_ms):
    while True:
        print(name)
        await asyncio.sleep_ms(interval_ms)

async def main():
    asyncio.create_task(heartbeat("fast", 100))
    asyncio.create_task(heartbeat("slow", 500))
    await asyncio.sleep(5)

asyncio.run(main())

這兩個 create_task 呼叫會排程兩個 heartbeat,而不等待其中任何一個。控制權立即返回 main,後者接著 await一個五秒的睡眠。在它睡眠期間,那兩個 heartbeat 任務會持續推進;迴圈會輪流執行任何已準備好的任務。五秒後 main 回傳,迴圈拆除所有仍存活的任務,然後 asyncio.run() 返回呼叫端。

每當應用程式確實需要上述三項操作之一時,就擷取控制代碼。實務上這意味著幾乎總是如此,因為要乾淨地關閉應用程式,就代表要取消它所衍生的背景任務 — 取消那一頁涵蓋了此模式。

8.2.3. 兩行規則

最精簡的 asyncio 程式就是上述範例最後結尾的那兩行:

async def main():
    ...

asyncio.run(main())

其他一切 — 應用程式建立的任務、用來協調它們的原語、它開啟的串流 — 都發生在 main 內部(以及 main 衍生的協程內部)。當指令碼超出了相機經典的 while True: csi0.snapshot() 迴圈所能負荷時,答案不是在數個地方呼叫 asyncio.run();而是將新的工作作為更多任務折疊進 main 中。