python基礎教程:非同步IO 之程式設計例子
我們講以Python 3.7 上的asyncio為例講解如何使用Python的非同步IO。

如果你的系統中還沒有 Python 3.7,你可以參考Python的虛擬環境一文,來建立你的 Python 3.7 的虛擬環境。
建立第一個協程
Python 3.7 推薦使用 async/await 語法來宣告協程,來編寫非同步應用程式。我們來建立第一個協程函式:首先列印一行“你好”,等待1秒鐘後再列印“猿人學”。

sayhi()函式通過 async 宣告為協程函式,較之前的修飾器宣告更簡潔明瞭。
在實踐過程中,什麼功能的函式要用async宣告為協程函式呢?就是那些能發揮非同步IO效能的函式,比如讀寫檔案、讀寫網路、讀寫資料庫,這些都是浪費時間的IO操作,把它們協程化、非同步化從而提高程式的整體效率(速度)。
sayhi()函式是通過 asyncio.run()
來執行的,而不是直接呼叫這個函式(協程)。因為,直接呼叫並不會把它加入排程日程,而只是簡單的返回一個協程物件:

那麼,如何真正執行一個協程呢?asyncio 提供了三種機制:
(1)asyncio.run() 函式,這是非同步程式的主入口,相當於C語言中的main函式。
(2)用await等待協程,比如上例中的 await asyncio.sleep(1)
。再看下面的例子,我們定義了協程 say_delay()
,在main()協程中呼叫兩次,第一次延遲1秒後列印“你好”,第二次延遲2秒後列印“猿人學”。這樣我們通過 await 運行了兩個協程。

從起止時間可以看出,兩個協程是順序執行的,總共耗時1+2=3秒。
(3)通過 asyncio.create_task()
函式併發執行作為 asyncio 任務(Task) 的多個協程。下面,我們用create_task()來修改上面的main()協程,從而讓兩個say_delay()協程併發執行:

從執行結果的起止時間可以看出,兩個協程是併發執行的了,總耗時等於最大耗時2秒。
asyncio.create_task()
是一個很有用的函式,在爬蟲中它可以幫助我們實現大量併發去下載網頁。在Python 3.6中與它對應的是 ensure_future()
。
可等待物件(awaitables)
可等待物件,就是可以在 await 表示式中使用的物件,前面我們已經接觸了兩種可等待物件的型別:協程和任務,還有一個是低層級的Future。
asyncio模組的許多API都需要傳入可等待物件,比如 run(), create_task() 等等。
(1)協程
協程是可等待物件,可以在其它協程中被等待。協程兩個緊密相關的概念是:
- 協程函式:通過 async def 定義的函式;
- 協程物件:呼叫協程函式返回的物件。
[圖片上傳失敗...(image-47fa5e-1557555679760)]

執行上面這段程式,結果為:
co is now is 1548512708.2026224 now is 1548512708.202648
可以看到,直接執行協程函式 whattime()得到的co是一個協程物件,因為協程物件是可等待的,所以通過 await 得到真正的當前時間。now2是直接await 協程函式,也得到了當前時間的返回值。
(2)任務
前面我們講到,任務是用來排程協程的,以便併發執行協程。當一個協程通過 asyncio.create_task()
被打包為一個 任務,該協程將自動加入程式排程日程準備立即執行。
create_task()的基本使用前面例子已經講過。它返回的task通過await來等待其執行完。如果,我們不等待,會發生什麼?“準備立即執行”又該如何理解呢?先看看下面這個例子:

執行這段程式碼的情況是這樣的:
首先,1秒鐘後列印一行,這是第13,14行程式碼執行的結果:
calling:0, now is 09:15:15
接著,停頓1秒後,連續列印4行:
calling:1, now is 09:15:16 calling:2, now is 09:15:16 calling:3, now is 09:15:16 calling:4, now is 09:15:16
從這個結果看, asyncio.create_task()
產生的4個任務,我們並沒有 await
,它們也執行了。關鍵在於第18行的 await
,如果把這一行去掉或是sleep的時間小於1秒(比whattime()裡面的sleep時間少即可),就會只看到第一行的輸出結果而看不到後面四行的輸出。這是因為,main()不sleep或sleep少於1秒鐘,main()就在whattime()還未來得及列印結果(因為,它要sleep 1秒)就退出了,從而整個程式也退出了,就沒有whattime()的輸出結果。
再來理解一下 “準備立即執行” 這個說法。它的意思就是,create_task()只是打包了協程並加入排程佇列還未執行,並準備立即執行,什麼時候執行呢?在“主協程”(呼叫create_task()的協程)掛起的時候,這裡的“掛起”有兩個方式:
一是,通過 await task 來執行這個任務;
另一個是,主協程通過 await sleep 掛起,事件迴圈就去執行task了。
我們知道,asyncio是通過事件迴圈實現非同步的。在主協程 main()裡面,沒有遇到 await 時,事件就是執行main()函式,遇到 await 時,事件迴圈就去執行別的協程,即create_task()生成的whattime()的4個任務,這些任務一開始就是 await sleep 1秒。這時候,主協程和4個任務協程都掛起了,CPU空閒,事件迴圈等待協程的訊息。
如果main()協程只sleep了0.1秒,它就先醒了,給事件迴圈發訊息,事件迴圈就來繼續執行main()協程,而main()後面已經沒有程式碼,就退出該協程,退出它也就意味著整個程式退出,4個任務就沒機會列印結果;
如果main()協程sleep時間多餘1秒,那麼4個任務先喚醒,就會得到全部的列印結果;
如果main()的18行sleep等於1秒時,和4個任務的sleep時間相同,也會得到全部列印結果。這是為什麼呢?
我猜想是這樣的:4個任務生成在前,第18行的sleep在後,事件迴圈的訊息響應可能有個先進先出的順序。後面深入asyncio的程式碼專門研究一下這個猜想正確與否。
(3)Future
它是一個低層級的可等待物件,表示一個非同步操作的最終結果。目前,我們寫應用程式還用不到它,暫不學習。
asyncio非同步IO協程總結
協程就是我們非同步操作的片段。通常,寫程式都會把全部功能分成很多不同功能的函式,目的是為了結構清晰;進一步,把那些涉及耗費時間的IO操作(讀寫檔案、資料庫、網路)的函式通過 async def 非同步化,就是非同步程式設計。
那些非同步函式(協程函式)都是通過訊息機制被事件迴圈管理排程著,整個程式的執行是單執行緒的,但是某個協程A進行IO時,事件迴圈就去執行其它協程非IO的程式碼。當事件迴圈收到協程A結束IO的訊息時,就又回來執行協程A,這樣事件迴圈不斷在協程之間轉換,充分利用了IO的閒置時間,從而併發的進行多個IO操作,這就是非同步IO。
寫非同步IO程式時記住一個準則:需要IO的地方非同步。其它地方即使用了協程函式也是沒用的。