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

王平發表於2019-01-21

前面發表了《簡說Python Web非同步框架》一文後,很多朋友希望能更多瞭解Python關於asyncio的知識。正好,我也想深入理解理解這方面的知識,於是就促成了這篇文章。

asyncio概念介紹

一、軟體系統的併發

使用非同步IO,無非是提高我們寫的軟體系統的併發。這個軟體系統,可以是網路爬蟲,也可以是Web服務等等。

併發的方式有多種,多執行緒,多程式,非同步IO等。多執行緒和多程式更多應用於CPU密集型的場景,比如科學計算的時間都耗費在CPU上,利用多核CPU來分擔計算任務。多執行緒和多程式之間的場景切換和通訊代價很高,不適合IO密集型的場景(關於多執行緒和多程式的特點已經超出本文討論的範疇,有興趣的同學可以自行搜尋深入理解)。而非同步IO就是非常適合IO密集型的場景,比如網路爬蟲和Web服務。

IO就是讀寫磁碟、讀寫網路的操作,這種讀寫速度比讀寫記憶體、CPU快取慢得多,前者的耗時是後者的成千上萬倍甚至更多。這就導致,IO密集型的場景99%以上的時間都花費在IO等待的時間上。非同步IO就是把CPU從漫長的等待中解放出來的方法。

二、同步、非同步與阻塞、非阻塞的區別

這是一個經久不衰的“問題”,各種解釋和各種形象的比喻也很多,有興趣的同學可以去知乎圍觀《怎樣理解阻塞非阻塞與同步非同步的區別?》這個問題。

我試著從另外的角度去解釋一下這四個概念。這是兩組不同維度的概念。

同步和非同步:是方法論;
阻塞和非阻塞:是現象(結果)論。

處理IO,一種現象(結果)就是等IO處理完才去做別的,這就是阻塞;另一種現象是先發一個處理IO的命令就去做別的事情,等IO處理結束了再來對IO處理進行處理,這就是非阻塞。

阻塞是浪費時間的,所以我們要想辦法解決阻塞使整個流程順暢變成非阻塞。那麼怎麼辦呢?

最一開始,IO只有最基本的同步方法,即阻塞的方法。阻塞不好,我們要非阻塞,於是,人們在同步IO上加了些東西(select/poll, epoll, kqueue)就得到了非阻塞的方法,這種方法就是IO複用(I/O Multiplexing),通常人們把這種IO複用也叫做非同步IO。後來又實現了非同步的API(aio_read等)。這就是作業系統IO發展的過程。參考下面這個表格,它來自Wikipedia的“非同步IO”詞條,說的是作業系統提供的IO操作的API。

posix io

我對這個表格的解釋就是方法和現象。橫向看是IO的兩種方法:同步和非同步。縱向看是IO的兩種現象:阻塞和非阻塞。其中,同步的方法可以得到阻塞和非阻塞兩種結果,而非同步的方法只能得到非阻塞的結果。

舉個例子,我們要做一頓午飯:淘米1分鐘,蒸米飯20分鐘,洗菜5分鐘,切菜1分鐘,炒菜20分鐘。那麼,做完這頓飯需要幾分鐘?不同的人需要的時間不一樣。

笨的人,淘米、蒸米飯、洗菜、切菜、炒菜一步一步來,需要的時間最長。他的過程是阻塞的,方法是同步的,當然也是笨的。

聰明的人發現,用電飯煲蒸飯的同時,就可以去做菜了,這樣完全節省了等待蒸米飯的那20分鐘。他的過程變成了非阻塞的,方法變成了非同步的。這個過程有個關鍵的東西:電飯煲,它只需要一個命令開關就去蒸飯了,蒸完飯會“滴”一聲告訴你好了,其間不需要你操心,可以去洗菜、炒菜了。這就是非同步實現的機制。

這裡,“蒸飯”看做是耗時的IO過程,它的非同步化就帶來了整體效率的提高。

如果炒菜也有電器可以自動實現,把菜放進去,它洗、切、炒自動完成,熟了後也“滴”一聲告訴你。那麼你做飯的流程更加非阻塞化了,你做飯的方法也更加非同步化。把材料放進電飯煲和炒菜機就可以去看電視或乾點兒別的了。

三、Python的非同步IO

非同步IO的優勢顯而易見,各種語言都通過實現這個機制來提高自身的效率,Python也不例外。

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

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

(2)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。 我們先看看下面的程式碼,發現與上面的有什麼不同?

asyncio python api介面

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

需要注意的是, async.run() 是3.7版新增加的,處於暫定API狀態。 暫定API,是指被有意排除在標準庫的向後相容性保證之外的應用程式設計介面。雖然此類介面通常不會再有重大改變,但只要其被標記為暫定,就可能在核心開發者確定有必要的情況下進行向後不相容的更改(甚至包括移除該介面)。此種更改並不會隨意進行 — 僅在 API 被加入之前未考慮到的嚴重基礎性缺陷被發現時才可能會這樣做。即便是對暫定 API 來說,向後不相容的更改也會被視為“最後的解決方案” —— 任何問題被確認時都會盡可能先嚐試找到一種向後相容的解決方案。這種處理過程允許標準庫持續不斷地演進,不至於被有問題的長期性設計缺陷所困。

從上面關於 asyncio 的發展來看它一直在變化,3.4,3.5,3.6, 3.7 都有很多細節上的變化。當我看到3.7的run()函式時,也發現一年前基於3.6的asnycio寫的爬蟲不那麼優雅了。

這種變化,一方面改善了asyncio本身的效能和使用方便程度,但另一方面也增加了我們使用者的學習成本、Python升級帶來的改造的成本。如果你以消極的態度抵制這種變化,可以去學習golang,C++來實現你的程式;如果你以積極的態度迎接這種變化,可以更快的掌握這種變化,並優雅 高效的實現你的程式。

只要你喜歡用Python寫程式解決問題,那麼就接受並掌握這種變化吧。其實,那種語言不在變,那種技術不在前進。作為程式設計師,你只有不斷地學習和前進。

(3)uvloop
uvloop是用Cython寫的,基於libuv這個C語言實現的高效能非同步I/O庫。asyncio自己的事件迴圈是用Python寫的,用uvloop替換asyncio自己的事件迴圈可以是asyncio的速度更快。並且使用相當簡潔:

uvloop

總結
(1)非同步IO用在費時的IO操作上以提高程式整體效率。
(2)同步和非同步,阻塞和非阻塞就是方法和現象。
(3)Python的非同步歷史很複雜,然而目前給我們用的已經很優雅,記住以下三點:
(a) Python 3.7
(b) await,async
(c) IO的時候用

PS:寫這種總結性的文章很累人,覆蓋的知識面廣,回溯的歷史長,各種查資料和思考,耗費一整個週日的時間,難免有錯誤和遺漏,大家積極留言討論挑毛病哦。下一篇繼續asyncio的話題,結合3.7的版本探討具體使用非同步IO。

猿人學banner宣傳圖

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

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

相關文章