Python協程之asyncio

Assassin007發表於2020-08-31

asyncio 是 Python 中的非同步IO庫,用來編寫併發協程,適用於IO阻塞且需要大量併發的場景,例如爬蟲、檔案讀寫。

asyncio 在 Python3.4 被引入,經過幾個版本的迭代,特性、語法糖均有了不同程度的改進,這也使得不同版本的 Python 在 asyncio 的用法上各不相同,顯得有些雜亂,以前使用的時候也是本著能用就行的原則,在寫法上走了一些彎路,現在對 Python3.7+ 和 Python3.6 中 asyncio 的用法做一個梳理,以便以後能更好的使用。

協程與asyncio

協程,又稱微執行緒,它不被作業系統核心所管理,而完全是有程式控制,協程切換花銷小,因而有更高的效能。

協程可以比作子程式,不同的是,執行過程中協程可以掛起當前狀態,轉而執行其他協程,在適當的時候返回來接著執行,協程間的切換不需要涉及任何系統呼叫或任何阻塞呼叫,完全由協程排程器進行排程。

Python 中以 asyncio 為依賴,使用 async/await 語法進行協程的建立和使用,如下 async 語法建立一個協程函式:

async def work():
    pass

在協程中除了普通函式的功能外最主要的作用就是:使用 await 語法等待另一個協程結束,這將掛起當前協程,直到另一個協程產生結果再繼續執行:

async def work():
    await asyncio.sleep(1)
    print('continue')

asyncio.sleep() 是 asyncio 包內建的協程函式,這裡模擬耗時的IO操作,上面這個協程執行到這一句會掛起當前協程而去執行其他協程,直到sleep結束,當有多個協程任務是,這種切換會讓它們的IO操作並行處理。

注意,執行一個協程函式並不會真正的執行它,而是會返回一個協程物件,要使協程真正的執行,需要將它們加入到事件迴圈中執行,官方建議 asyncio 程式應當有一個主入口協程,用來管理所有其他的協程任務:

async def main():
    await work()

在 Python3.7+ 中,執行這個 asyncio 程式只需要一句:asyncio.run(main()) ,而在 Python3.6 中,需要手動獲取事件迴圈並加入協程任務:

loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

事件迴圈就是一個迴圈佇列,對其中的協程進行排程執行,當把一個協程加入迴圈,這個協程建立的其他協程都會自動加入到當前事件迴圈中。

其實協程物件也不是直接執行,而是被封裝成一個個待執行的 Task ,大多數情況下 asyncio 會幫我們進行封裝,我們也可以提前自行封裝 Task 來獲得對協程更多的控制權,注意,封裝 Task 需要當前執行緒有正在執行的事件迴圈,否則將引 RuntimeError,這也就是官方建議使用主入口協程的原因,如果在主入口協程之外建立任務就需要先手動獲取事件迴圈然後使用底層的方法 loop.create_task(),任務建立後便有了狀態,可以檢視執行情況,檢視結果,取消任務等:

async def main():
    task = asyncio.create_task(work())
    print(task)
    await task
    print(task)

#----執行結果----#
<Task pending name='Task-2' coro=<work() running at d:\tmp\code\asy.py:5>>
<Task finished name='Task-2' coro=<work() done, defined at d:\tmp\code\asy.py:5> result=None>

asyncio.create_task() 是 Python3.7 加入的高層級API,在 Python3.6,需要使用低層級API asyncio.ensure_future() 來建立 Future,Future 也是一個管理協程執行狀態的物件,與 Task 沒有本質上的區別。

併發協程

通常,一個含有一系列併發協程的程式寫法如下(Python3.7+):

import asyncio
import time


async def work(num: int):
    '''
    一個工作協程,接收一個數字,將它 +1 後返回
    '''
    print(f'working {num} ...')
    await asyncio.sleep(1)    # 模擬耗時的IO操作
    print(f'{num} -> {num+1} done')
    return num + 1


async def main():
    '''
    主協程,建立一系列併發協程並執行它們
    '''
    # 任務佇列
    tasks = [work(num) for num in range(0, 5)]
    # 併發執行佇列中的協程並等待結果返回
    results = await asyncio.gather(*tasks)
    print(results)


if __name__ == "__main__":
    asyncio.run(main())

併發執行多個協程任務的關鍵就是 asyncio.gather(*tasks),它接受多個協程任務並將它們加入到事件迴圈,所有任務都執行完成後會返回結果列表,這裡我們也沒有手動封裝 Task,因為 gather 函式會自動封裝。

併發執行還有另一個方法 asyncio.wait(tasks),它們的區別是:

  • gather 比 wait 更加高層,gather 可以將任務分組,一般優先使用 gather:
tasks1 = [work(num) for num in range(0, 5)]
tasks2 = [work(num) for num in range(5, 10)]
group1 = asyncio.gather(*tasks1)
group2 = asyncio.gather(*tasks2)
results1, results2 = await asyncio.gather(group1, group2)
print(results1, results2)
  • 在某些定製化任務需求的時候,可以使用 wait:
# Python3.8 版本後,直接向 wait() 傳入協程物件已棄用,必須手動建立 Task
tasks = [asyncio.create_task(work(num)) for num in range(0, 5)]
done, pending = await asyncio.wait(tasks)
for task in tasks:
    if task in done:
        print(task.result())
for p in pending:
    p.cancel()

Tips

  • await 語句後必須是一個 可等待物件 ,可等待物件主要有三種:Python協程,Task,Future。通常情況下沒有必要在應用層級的程式碼中建立 Future 物件。
  • 在 asyncio 程式中使用同步程式碼雖然並不會報錯,但是也失去了併發的意義,例如網路請求,如果使用僅支援同步的 requests,在發起一次請求後在收到響應結果之前不能發起其他請求,這樣要併發訪問多個網頁時,即使使用了 asyncio,在傳送一次請求後切換到其他協程還是會因為同步問題而阻塞,並不能有速度上的提升,這時候就需要其他支援非同步請求庫如 aiohttp
  • 關於 asyncio 的更多更詳細的操作見 官方文件

相關文章