理解Python asyncio原理和簡潔使用方式

王平發表於2019-10-29

非同步IO是個好東西,在網路讀寫場景中可以大大提高程式的併發能力,比如爬蟲、web服務等。這樣的好東西自然也要在Python中可以使用。不過,在漫長的Python2時代,官方並沒有推出一個自己的非同步IO庫,到了Python 3.4 才推出。我們先來看看非同步IO在Python中的發展歷史。

理解python asyncio的原理和簡潔使用方式

Python 非同步IO的歷史

Python 2的非同步IO庫

Python 2 時代官方並沒有非同步IO的支援,但是有幾個第三方庫通過事件或事件迴圈(Event Loop)實現了非同步IO,它們是:

twisted: 是事件驅動的網路庫。
gevent: greenlet + libevent(後來是libev或libuv)。通過協程(greenlet)和事件迴圈庫(libev,libuv)實現的gevent使用很廣泛。
tornado: 支援非同步IO的web框架。自己實現了IOLOOP。

Python 3 官方的非同步IO

Python 3.4 加入了asyncio 庫,使得Python有了支援非同步IO的官方庫。這個庫,底層是事件迴圈(EventLoop),上層是協程和任務。asyncio自從3.4 版本加入到最新的 3.7版一直在改進中。
Python 3.4 剛開始的asyncio的協程還是基於生成器的,通過 yield from 語法實現,可以通過裝飾器 @asyncio.coroutine (已過時)裝飾一個函式來定義一個協程。比如:

asyncio用法

Python 3.5 引入了兩個新的關鍵字 await 和 async 用來替換 @asyncio.coroutine 和 yield from ,從語言本身來支援非同步IO。從而使得非同步程式設計更加簡潔,並和普通的生成器區別開來。

注意: 對基於生成器的協程的支援已棄用,並計劃在 Python 3.10 中移除。所以,寫非同步IO程式時只需使用 async 和 await 即可。
Python 3.7 又進行了優化,把API分組為高層級API和低層級API。我們先看看下面的程式碼,發現與上面的有什麼不同?

python37 asyncio用法

除了用 async 替換 @asyncio.coroutine 和用 await 替換 yield from 外,最大的變化就是關於eventloop的程式碼不見了,只有一個 async.run()。這就是 3.7 的改進,把eventloop相關的API歸入到低層級API,新引進run()作為高層級API讓寫應用程式的開發者呼叫,而不用再關心eventloop。除非你要寫非同步庫(比如MySQL非同步庫)才會和eventloop打交道。

理解asyncio

理解asyncio並不能,關鍵是要動起手來,接下來我們以下面程式碼為例動手實踐一番,通過實踐來理解它。

asyncio程式碼示例

這段程式碼很簡單,我們定義了兩個協程函式(在def前面加async),其中 hi() 我們把它叫做功能函式,通過一個 aysncio.sleep() 來模擬一個耗時的非同步IO操作(比如下載網頁), main() 叫做入口函式。其實就是在main() 裡面呼叫 hi() 函式,通過不斷改變 main() 的行為來理解非同步IO(協程函式的呼叫)的執行過程。

1. 協程函式如何執行?

首先,我們要明確一個道理,hi() 是一個協程函式,直接呼叫它返回的是一個協程物件,並沒有真正執行它。把main函式改成如下,我們來仔細看看協程函式 hi() 的執行。

asyncio函式

下面是執行結果:

asyncio函式執行結果

程式碼第19行,我們像執行普通函式一樣執行 hi() ,得到的a只是一個協程物件,見結果第二行:

a is: <coroutine object hi at 0x7fbf037f7050>

這個協程物件 a 雖然生成了,但是還沒有執行,它需要一個時機。也就是asyncio的事件迴圈正在執行main,還沒有空去執行它。
程式碼第21行,通過 await 告訴 event_loop(事件迴圈) ,main協程停在這裡,你去執行其它協程吧。這時候 event_loop 去執行a協程,也就是去執行 hi() 函式裡面的程式碼。等 hi() 執行完,event_loop 再回到main協程繼續從21行開始執行,把 hi() 的返回值賦值給b,這時候 b 的值是1。

event_loop 在整個非同步IO過程中扮演一個管家的角色,在不同的協程之間切換執行程式碼,切換是通過事件來進行的,通過 await 離開當前協程,await 的協程完成後又回到之前的協程對應的地方繼續執行。

2. 協程函式如何併發?

非同步IO的好處就是併發,但如何實現呢?我們先來看一個不是併發的例子:

不是asyncio

這次,我們把main修改成一個for迴圈執行4次 hi() ,看看它執行的結果:

不是asyncio結果

整個過程從21:48:30 到 21:48:40 結束,用了10秒。而hi()的執行時間分別是1秒,2秒,3秒,4秒總共10秒。也就是4個hi() 雖然是非同步的但是順序執行的,沒有併發。

接下來,就到了併發的實現了,通過 asyncio.creat_task() 即可:

建立協程任務

通過 create_task() 我們在for迴圈裡面生成了4個task(也是協程物件),但是這4個協程任務並沒有被執行,它們需要等待一個時機:當前協程(main)遇到 await。

第二個for迴圈開始逐一 await 協程,此時 event_loop 就可以空出手來去執行那4個協程,過程大致如下:

先執行hi(1, 1) ,列印“enter hi(), 1 @21:58:35”,遇到await asyncio.sleep(1),當前協程掛起;

接著執行 hi(2, 2),執行列印命令,遇到await asyncio.sleep(2) ,當前協程掛起;

接著執行 hi(3, 3),執行列印命令,遇到await asyncio.sleep(3) ,當前協程掛起;

接著執行 hi(4, 4),執行列印命令,遇到await asyncio.sleep(4) ,當前協程掛起;

以上4步只是協程的切換和列印語句,執行非常快,我們可以任務它們是同時執行起來的。

1秒後,hi(1,1)的sleep結束它會發出事件告訴 event_loop 我await結束了,過來執行我,event_loop 此時空閒就來執行它,繼續執行sleep後面的列印語句;

2秒後,hi(2,2)的sleep結束它會發出事件告訴 event_loop 我await結束了,過來執行我,event_loop 此時空閒就來執行它,繼續執行sleep後面的列印語句;

3秒後,hi(3,3)的sleep結束它會發出事件告訴 event_loop 我await結束了,過來執行我,event_loop 此時空閒就來執行它,繼續執行sleep後面的列印語句;

4秒後,hi(4,4)的sleep結束它會發出事件告訴 event_loop 我await結束了,過來執行我,event_loop 此時空閒就來執行它,繼續執行sleep後面的列印語句;

4秒後,生成的4個協程任務就都執行完畢。總耗時4秒,也就是我們的4個任務併發完成了。

所以,上面的程式碼執行的結果如下:

建立協程執行結果

根據上面講述的執行流程,可以看到結果對應起來了。4個任務都是在35秒時開始執行,以後每個1秒完成一個。main函式從35執行到39介紹,共耗時4秒。

3. 錯誤的執行

上面的併發很完美,但有時候你可能會犯錯。比如下面的main(), 你可能只是併發 hi() 函式,但不需要它的返回結果,於是有了下面的 main():

建立協程任務錯誤

先猜猜會有什麼樣的結果!!

你猜對了嗎?下面是執行結果:

建立協程任務失敗執行結果

main()的for迴圈只是生成了4個task協程,然後就退出了。event_loop 收到main退出的事件就空出來去執行了那4個協程,進去了但都碰到了sleep。然後event_loop就空閒了。這時候run() 就收到了main() 執行完畢的事件,run() 就執行完了,最後執行print,整個程式就退出了。從main退出到整個程式退出就是一瞬間的事情,那4個協程還在傻傻的睡著,不,是在睡夢中死去了。

在main()中加一個sleep會出現什麼結果:

完美建立協程任務

在main()退出前,我們要先sleep 2秒,再來猜猜它的執行結果是什麼?
如果你對上面沒有sleep的過程搞清楚了,不難猜到正確的結果:

完美建立協程執行結果

注意:main() 的退出和 hi(2, 2) 的退出順序。簡單講,main() 先sleep 2秒,hi(2, 2) 後sleep兩秒,所以main先退出。

理解了sleep(2) 的執行過程,那麼你就可以知道 sleep(4) 和 sleep(5) 的結果了。如果沒有自信的話,就自己改一下時間,執行看看結果。

4. 如何判斷是否要把函式定義為協程函式?

定義一個協程函式很簡單,在def前面加async即可。那麼如何判斷一個函式該不該定義為協程函式呢?
記住這一個原則:如果該函式是要進行IO操作(讀寫網路、讀寫檔案、讀寫資料庫等),就把它定義為協程函式,否則就是普通函式。

以上就是如何理解asyncio的方法,也就是如何使用async和await這兩個關鍵字。如果你還不明白,那就把上面的程式碼都跑一遍,如果還不行,那就跑兩遍,哈哈哈,你一定行的。

猿人學banner宣傳圖

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

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

相關文章