Python非同步IO程式設計之-asyncio協程應用例子

王平發表於2019-01-28

前面,我們從大的結構上認識了最新的Python 3.7裡面的asyncio標準庫。接下來,我們就開始一點一點的來學習asyncio的使用。

asyncio wizard

一、安裝 Python 3.7

我的系統是 Ubuntu 16.04,裡面有 Python 2.7 和 Python 3.6。2.7是系統自帶的,其它系統軟體對此有依賴,不能破壞。3.6 是透過ppa源apt install的,然後透過 virtualenvwrapper 管理虛擬環境。同樣的,安裝3.7也是用ppa源。

sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt update
sudo apt install python3.7 python3.7-dev
安裝python3.7-dev包的目的是編譯C/C++模組時需要Python.h等標頭檔案。安裝好後,建立一個Python 3.7 的虛擬環境,就叫 py3.7 吧:

mkvirtualenv -p python3.7 py3.7

建立好虛擬環境,透過命令:workon py3.7 就可以使用 3.7 了。

Windows 的安裝可以移步猿人學網站,閱讀《Python開發環境的安裝配置》這篇文章,裡面有詳細說明。

二、建立第一個協程

Python 3.7 推薦使用 async/await 語法來宣告協程,來編寫非同步應用程式。我們來建立第一個協程函式:首先列印一行“你好”,等待1秒鐘後再列印“猿人學”。

asyncio 協程例子

sayhi()函式透過 async 宣告為協程函式,較之前的修飾器宣告更簡潔明瞭。

在實踐過程中,什麼功能的函式要用async宣告為協程函式呢?就是那些能發揮非同步IO效能的函式,比如讀寫檔案、讀寫網路、讀寫資料庫,這些都是浪費時間的IO操作,把它們協程化、非同步化從而提高程式的整體效率(速度)。

sayhi()函式是透過 asyncio.run()來執行的,而不是直接呼叫這個函式(協程)。因為,直接呼叫並不會把它加入排程日程,而只是簡單的返回一個協程物件:

asyncio 協程例子1

那麼,如何真正執行一個協程呢?asyncio 提供了三種機制:

(1)asyncio.run() 函式,這是非同步程式的主入口,相當於C語言中的main函式。

(2)用await等待協程,比如上例中的 await asyncio.sleep(1) 。再看下面的例子,我們定義了協程 say_delay() ,在main()協程中呼叫兩次,第一次延遲1秒後列印“你好”,第二次延遲2秒後列印“猿人學”。這樣我們透過 await 執行了兩個協程。

asyncio協程例子2

從起止時間可以看出,兩個協程是順序執行的,總共耗時1+2=3秒。

(3)透過 asyncio.create_task() 函式併發執行作為 asyncio 任務(Task) 的多個協程。下面,我們用create_task()來修改上面的main()協程,從而讓兩個say_delay()協程併發執行:

asyncio協程例子3

從執行結果的起止時間可以看出,兩個協程是併發執行的了,總耗時等於最大耗時2秒。

asyncio.create_task() 是一個很有用的函式,在爬蟲中它可以幫助我們實現大量併發去下載網頁。在Python 3.6中與它對應的是 ensure_future()。

三、可等待物件(awaitables)

可等待物件,就是可以在 await 表示式中使用的物件,前面我們已經接觸了兩種可等待物件的型別:協程和任務,還有一個是低層級的Future。

asyncio模組的許多API都需要傳入可等待物件,比如 run(), create_task() 等等。

(1)協程
協程是可等待物件,可以在其它協程中被等待。協程兩個緊密相關的概念是:

協程函式:透過 async def 定義的函式;
協程物件:呼叫協程函式返回的物件。

asyncio協程例子4

執行上面這段程式,結果為:
co is
now is 1548512708.2026224
now is 1548512708.202648
可以看到,直接執行協程函式 whattime()得到的co是一個協程物件,因為協程物件是可等待的,所以透過 await 得到真正的當前時間。now2是直接await 協程函式,也得到了當前時間的返回值。

(2)任務

前面我們講到,任務是用來排程協程的,以便併發執行協程。當一個協程透過 asyncio.create_task() 被打包為一個 任務,該協程將自動加入程式排程日程準備立即執行。

create_task()的基本使用前面例子已經講過。它返回的task透過await來等待其執行完。如果,我們不等待,會發生什麼?“準備立即執行”又該如何理解呢?先看看下面這個例子:

asyncio協程例子5

執行這段程式碼的情況是這樣的:
首先,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()還未來得及列印結果(因為,它要sleep1秒)就退出了,從而整個程式也退出了,就沒有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的地方非同步。其它地方即使用了協程函式也是沒用的。

網路爬蟲就是非同步IO的用武之地,接下來的文章,我們就來實現一個非同步IO爬蟲,敬請期待。

非同步IO系列閱讀:
深入瞭解Python的非同步IO:概念和歷史

Python 非同步 IO系列:認識asyncio

猿人學banner宣傳圖

我的公眾號:猿人學 Python 上會分享更多心得體會,敬請關注。

***版權申明:若沒有特殊說明,文章皆是猿人學 yuanrenxue.com 原創,沒有猿人學授權,請勿以任何形式轉載。***

相關文章