初探 Python 3 的非同步 IO 程式設計

發表於2016-01-07
上週終於把知乎日報的新版本做完了,於是趁著這幾天的休息,有精力折騰一些感興趣的玩意了。
雖然工作時並不會接觸到 Python 3,但還是對它抱有不少好奇心,於是把 Python 版本更新到了 3.4,開始了折騰之旅。在各種更新中,我最感興趣的當屬 asyncio 模組了,所以就從非同步 IO 開始探索吧。 

探索之前,先簡單介紹下各種 IO 模型:
最容易做的是阻塞 IO,即讀寫資料時,需要等待操作完成,才能繼續執行。進階的做法就是用多執行緒來處理需要 IO 的部分,缺點是開銷會有些大。
接著是非阻塞 IO,即讀寫資料時,如果暫時不可讀寫,則立刻返回,而不等待。因為不知道什麼時候是可讀寫的,所以輪詢時可能會浪費 CPU 時間。
然後是 IO 複用,即在讀寫資料前,先檢查哪些描述符是可讀寫的,再去讀寫。select 和 poll 就是這樣做的,它們會遍歷所有被監視的描述符,檢視是否滿足,這個檢查的過程是阻塞的。而 epoll、kqueue 和 /dev/poll 則做了些改進,事先註冊需要檢查哪些描述符的哪些事件,當狀態發生變化時,核心會呼叫對應的回撥函式,將這些描述符儲存下來;下次獲取可用的描述符時,直接返回這些發生變化的描述符即可。
再之後是訊號驅動,即描述符就緒時,核心傳送 SIGIO 訊號,再由訊號處理程式去處理這些訊號即可。不過訊號處理的時機是從核心態返回使用者態時,感覺也得把這些事件收集起來才好處理,有點像模擬 IO 複用了。
最後是非同步 IO,即讀寫資料時,只註冊事件,核心完成讀寫後(讀取的資料會複製到使用者態),再呼叫事件處理函式。這整個過程都不會阻塞呼叫執行緒,不過實現它的作業系統比較少,Windows 上有比較成熟的 IOCP,Linux 上的 AIO 則有不少缺點。
雖然真正的非同步 IO 需要中間任何步驟都沒有阻塞,這對於某些只是偶爾需要處理 IO 請求的情況確實有用(比如文字編輯器偶爾儲存一下檔案);但對於伺服器端程式設計的大多數情況而言,它的主執行緒就是用來處理 IO 請求的,如果在空閒時不阻塞在 IO 等待上,也沒有別的事情能做,所以本文就不糾結這個非同步是否名副其實了。

在 Python 2 的時代,高效能的網路程式設計主要是使用 Twisted、Tornado 和 gevent 這三個庫。
我對 Twisted 不熟,只知道它的缺點是比較重,效能相對而言並不算好。
Tornado 平時用得比較多,缺點是寫非同步呼叫時特別麻煩。
gevent 我只能算接觸過,缺點是不太乾淨。
由於它們都各自有一個 IO loop,不好混用,而 Tornado 的 web 框架相對而言比較完善,因此成了我的首選。

而從 Python 3.4 開始,標準庫裡又新增了 asyncio 這個模組。
從原理上來說,它和 Tornado 其實差不多,都是註冊 IO 事件,然後在 IO loop 中等待事件發生,然後呼叫相應的處理函式。
不同之處在於 Python 3 增加了一些新的特性,而 Tornado 需要相容 Python 2,所以寫起來會比較麻煩。
舉例來說,Python 3.3 可以在 generator 中 return 返回值(相當於 raise StopIteration),而 Tornado 中需要 raise 一個 Return 物件。此外,Python 3.3 還增加了 yield from 語法,減輕了在 generator 中處理另一個 generator 的工作量(省去了迴圈和 try … except …)。

不過,雖然 asyncio 有那麼多得天獨厚的優勢,卻不一定比 Tornado 的效能更好,所以我寫個簡單的例子測試一下。
比較方法就是寫個最簡單的 HTTP 伺服器,不做任何檢查,讀取到任何內容都輸出一個 hello world,並斷開連線。
測試的客戶端就懶得寫了,直接用 ab 即可:

Tornado 版是這樣:

在我的電腦上大概 4000 QPS。

asyncio 版是這樣:

在我的電腦上大概 3000 QPS,比 Tornado 版慢了一些。此外,asyncio 的 transport 在 write 時不用 yield from,這點可能有些不一致。

asyncio 還有個高階版的 API:

在我的電腦上大概 2200 QPS。這下讀寫都要 yield from 了,一致性上來說會好些。

以框架的效能而言,其實都夠用,開銷都不超過 1 毫秒,而 web 請求一般都需要 10 毫秒的以上的處理時間。
於是順便再測一下和 MySQL 的搭配,即在每個請求內呼叫一下 SELECT 1,然後輸出返回值。
因為自己懶得寫客戶端了,於是就用現成的 tornado_mysql 和 aiomysql 來測試了。原理應該都差不多,傳送寫請求後就返回,等收到可讀事件時再獲取內容。

Tornado 版是這樣:

在我的電腦上大概 680 QPS。

asyncio 版是這樣:

在我的電腦上大概 1250 QPS,比 Tornado 版快了不少。不過寫起來比較蛋疼,因為 data_received 方法裡不能直接用 yield from。

用 cProfile 看了下,Tornado 版在 tornado.gen 和 functools 模組裡花了不少時間,可能是非同步呼叫過多了吧。
但如果不做非同步庫的開發者,而只就使用者的體驗而言,Tornado 會顯得更加靈活和易用。不過 asyncio 的高階 API 應該也能提供類似的體驗。

順便再用底層 socket 模組寫個伺服器試試。
先用 poll 看看,錯誤處理什麼的就先不做了:

在我的電腦上大概 7700 QPS,優勢巨大。

再用 kqueue 試試(我用的是 OS X):

在我的電腦上大概 7200 QPS,比 poll 版稍慢。不過因為只有 10 個併發連線,而且沒有慢速網路的影響,所以 poll 的效能好並不奇怪。

再試試 Python 3.4 新增的 selectors 模組,它的 DefaultSelector 會自動選擇所在平臺最高效的實現,asyncio 就用到了這個模組。

在我的電腦上大概 6100 QPS,成績也還不錯。

從這些測試來看,如果想自己實現一個捨棄了一些功能和相容性的 Tornado,應該能比它稍快一點,不過似乎沒多大必要。
所以暫時不糾結效能了,還是從使用的便利性上來考慮。Tornado 可以用 yield 取代 callback,我們也來實現這個 feature。

實現前先得了解下 yield。
當一個函式內部出現了 yield 語句時,它就不再是一個單純的函式了,而是一個生成器函式,呼叫它並不會執行它的程式碼,而是返回一個生成器。
呼叫這個生成器的 send 方法時,才會執行內部的程式碼。當執行到 yield 時,這個 send 方法就返回了,呼叫者可以得到其返回值。
send 方法在第一次呼叫時,引數必須為 None。Python 2 中可以用它的 next 方法,Python 3 中改成了 __next__ 方法,還可以用內建的 next 函式來呼叫。
send 方法可以被多次呼叫,引數會作為 yield 的返回值,回到生成器內上一次執行的地方,並繼續執行下去。
當生成器的程式碼執行完時,會丟擲一個 StopIteration 的異常。Python 3.3 開始可以在生成器裡使用 return,返回值可以從 StopIteration 異常的 value 屬性獲取。
for … in … 迴圈會自動捕獲 StopIteration 異常,並作為迴圈停止的條件。

由此可見,yield 可以用於跳轉。而我們要做的,則是在遇到 IO 請求時,用 yield 返回 IO loop;當事件發生時,找到對應的生成器,用 send 方法繼續執行即可。
為了簡單起見,我就在 poll 版的基礎上進行改造了:

在我的電腦上大概 5300 QPS。

雖然成績比較尷尬,但畢竟用起來比前一個版本好多了。至於慢的原因,我估計是自己維護了一個堆疊的原因(也可能是有什麼 bug,畢竟寫這個感覺太跳躍了,能執行起來就謝天謝地了)。
實現時做了兩點假設:

  1. handler 為 generator 時,視為非同步方法。
  2. 在非同步方法中 yield None 時,視為等待 IO;yield / yield from 非同步方法時,則是等待方法返回。

實現細節也沒什麼好說的了,只是覺得在實現 Stream 的 read / write 方法時,呼叫 IOLoop.add_handler 方法不太優雅。其實可以直接 yield 一個 fd 和 event,在 IOLoop.start 方法中再去註冊。不過這個重構其實蠻小的,我就不再貼一次程式碼了,感興趣的可以自己試試。

於是這次初探就到此為止了,有空我也許會繼續完善它。至少這次探索,讓我覺得 Python 3 還是蠻有意思的。

相關文章