從Python 3.4以後,開始支援asyncio,但龐大而複雜的API令人望之生畏。其實,就直接使用來說,asyncio.run就可以應付大半需求,必要時才需要逐一掌握事件迴圈,或者與執行緒合作等更複雜的API。

從asyncio.run開始

過去,Python的並行策略是以多執行緒為主,直到JavaScript盛行後,開發者越來越知道:單執行緒也可以實作非同步來達到並行,例如,包含Python在內的許多語言,都各憑本事實現非同步機制。不過,就歷史發展來說,這是個混亂的過程,就Python而言,如果想認識這段歷史,可以參考先前專欄〈漫談Python並行〉

Python是3.4以後,在標準上逐步加入了asyncio、async與await等支援,就多數(熟悉執行緒的)Python開發者而言,asyncio是個極度不熟悉的模型,加上充滿了協程(coroutine)、事件迴圈等術語(可參考先前專欄〈協程、微執行緒到asyncio〉),龐大而複雜的文件,一時之間各程式庫尚無完善支援等因素,asyncio的使用總令人覺得充滿神祕色彩。

其實,asyncio本身主要有兩個對象:直接使用(end-user)的開發者與框架設計者。龐大的API文件中,大部份都是給框架設計者看的,直接使用的開發者其實不用瞭解那麼多,在最簡單的情況下,甚至只要知道以下程式碼的運作方式,就可以了(假設已import asyncio):

async def main():
await asyncio.sleep(1.0)
print('done')

asyncio.run(main())

就這麼多,不用一開始就想操作事件迴圈,面對非同步的需求,首先要考量的是簡單事情簡單做,在可行的情況下,直接採用的開發者應該能夠使用的,就只有async、await與asyncio.run,範例中的asyncio.sleep(1.0)代表著非同步任務,實際上,可能是非同步下載、檔案開啟等任務。

那麼,事件迴圈呢?許多asyncio的文件不是都會直接操作事件迴圈嗎?但就簡單任務來說,不需要這麼做,因為asyncio.run背後會自動生成事件迴圈,處理一切的細節;這就像JavaScript開發者,雖然需要知道事件迴圈的存在與原理,然而不用直接操作事件迴圈。

使用事件迴圈?

不少文件在介紹asyncio時,會透過asyncio.get_event_loop取得事件迴圈,然而,通常只是為了能使用事件迴圈實例的create_task方法,目的是一次性地建立多個任務,並加入事件迴圈的處理,例如,若loop是事件迴圈,urls是網址清單,想要一次性地傳送請求,可以這麼做:

for url in urls:
loop.create_task(send_req(url))

從Python 3.7開始,新增了asyncio.create_task,因而要建立任務加入事件迴圈的需求,現在不用取得事件迴圈了,也就是上例只要使用asyncio.create_task(send_req(url)),最後透過asyncio.run驅動就可以了。

另一個文件中常見的狀況在於,必須取得事件迴圈的需求是為了執行阻斷式的函式,例如time.sleep,這類阻斷式的函式不能直接寫在async函式(也就是協程函式)中,因為協程物件是執行在單執行緒,直接撰寫time.sleep這類阻斷式函式,代表著整個事件迴圈也會被卡住了。

事件迴圈實例確實提供了個run_in_executor,可以指定或使用預設的執行器(Executor)來執行指定的函式,也就是函式會執行在另一個執行緒中,因而不會阻斷事件迴圈。

確實!在這個情境之下,我們必須要操作事件迴圈,然而,目的並不是單純只為了使用run_in_executor,因為,混用asyncio與執行緒本身就是個複雜議題,這時取得事件迴圈,後續是為了能控制它從啟動到關機清理的細節,只不過許多文件都特意忽略這部份,僅用些簡單的執行緒作業談談run_in_executor就沒了。

asyncio.run做了什麼?

可以執行JavaScript的環境,無法直接操作事件迴圈,然而asyncio的API卻揭露了事件迴圈,除了可以混用執行緒之外,另一個目的就是給予框架設計者彈性,讓框架設計者可以將必要的邏輯安插至事件迴圈之中。

除了可使用asyncio.get_event_loop取得事件迴圈,Python 3.7新增了asyncio.get_running_loop(),如果「想在協程函式中取得事件迴圈」,建議使用這個版本,因為get_event_loop只能在同一執行緒中使用,若想在另一執行緒呼叫,必須為該執行緒建立新的事件迴圈,否則會引發RuntimeError;get_running_loop顧名思意,就是取得當時執行協程的事件迴圈。

先前談到,取得事件迴圈,是為了能控制啟動到關機清理的細節,而想要理解這個過程最好的入門方式,就是從asyncio.run做了些什麼開始,run的原始碼可以在Lib\asyncio\runners.py中找到,加上註解也不過70幾行。

簡單來說,asyncio.run會取得事件迴圈、透過run_until_complete執行指定的協程,這會阻斷直到指定的(主)協程完成(也就是執行完該協程函式定義的流程),之後會收集尚未沒完成的任務(主協程中可能又建立了其他任務,然而沒有await這些任務),取消這些任務(會在各協程函式中引發CancelledError),然後,再次使用run_until_complete執行這些任務(等待CancelledError善後處理完成),最後關閉迴圈。

而且,想取得事件迴圈中全部的任務,我們可以透過asyncio.all_tasks,想從這群任務中取得尚未沒完成的任務,可以透過asyncio.gather。認識這兩個函式,對於控制事件迴圈來說,是很重要的,而且,要注意的是,它們收集的是一組Task,並不會包含Future。

這表示,若某個協程中透過事件迴圈的run_in_executor執行作業,並透過asyncio.run驅動非同步作業的話,因為run_in_executor建立的是Future,最後在事件迴圈關閉清理時,也就不會去取消執行器作業,等到事件迴圈關閉後,此時,若還有運作中的執行器,就會引發RuntimeError: Event loop is closed。

更多事件迴圈的啟動、清理與關閉

在Python 3.9當中,執行器作業未完成的錯誤,將會獲得改善(https://oreil.ly/ZrpRb);然而,藉此認識asyncio.run背後的原理,對於認識、控制事件迴圈是個不錯的起點。

對於直接使用asyncio的開發者而言,其實不需要瞭解到如此深入的程度,如果你是這類使用者,除了以認識asyncio.run、async、await作為起點外,下個階段是進一步認識非同步產生器、async for、async with等。

之後如果真的遇到要混用執行緒,或者是其他更複雜需求,才需要去瞭解事件迴圈。目前來說,《Using Asyncio in Python》是個不錯的參考資源,其中對於事件迴圈的啟動、清理與關閉,有非常詳細的說明。

作者簡介


Advertisement

更多 iThome相關內容