深入理解python3.4中Asyncio庫與Node.js的非同步IO機制

發表於2017-03-04

譯者前言

  • 如何用yield以及多路複用機制實現一個基於協程的非同步事件框架?
  • 現有的元件中yield from是如何工作的,值又是如何被傳入yield from表示式的?
  • 在這個yield from之上,是如何在一個執行緒內實現一個排程機制去排程協程的?
  • 協程中呼叫協程的呼叫棧是如何管理的?
  • gevent和tornado是基於greenlet協程庫實現的非同步事件框架,greenlet和asyncio在協程實現的原理又有什麼區別?

去年稍微深入地瞭解了下nodejs,啃完了 樸靈《深入淺出Node.js》,自己也稍微看了看nodejs的原始碼,對於它的非同步事件機制還是有一個大致的輪廓的。雖然說讓自己寫一個類似的機制去實現非同步事件比較麻煩,但也並不是完全沒有思路。

 

而對於python中併發我還僅僅停留在使用框架的水平,對於裡面是怎麼實現的一點想法也沒有,出於這部分實現原理的好奇,嘗試讀了一個晚上asyncio庫的原始碼,感覺還是一頭霧水。像這樣一個較為成熟的庫內容太多了,有好多冗餘的模組擋在核心細節之前,確實會對學習有比較大的阻礙。

我也搜了很多國內的關於asyncio以及python中coroutine的文章,但都感覺還沒到那個意思,不解渴~在網上找到了這篇文章並閱讀之後,我頓時有種醍醐灌頂的感覺,因此決定把這篇長文翻譯出來,獻給國內同樣想了解這部分的朋友們。這篇文章能很好的解答我最前的4個問題,對於第5個問題,還有待去研究greenlet的實現原理。

前言

我花了一個夏天的時間在Node.js的web框架上,那是我第一次全職用Node.js工作。在使用了幾周後,有一件事變得很清晰,那就是我們組的工程師包括我都對Node.js中非同步事件機制缺乏瞭解,也不清楚它底層是怎麼實現的。我深信,對一個框架使用非常熟練高效,一定是基於對它的實現原理了解非常深刻之上的。所以我決定去深挖它。這份好奇和執著最後不僅停留在Node.js上,同時也延伸到了對其它語言中非同步事件機制的實現,尤其是python。我也是拿python來開刀,去學習和實踐的。於是我接觸到了python 3.4的非同步IO庫 asyncio,它同時也和我對協程(coroutine)的興趣不謀而合,可以參考我的那篇關於生成器和協程的部落格(譯者注:因為asyncio的非同步IO是用協程實現的)。這篇部落格是為了回答我在研究那篇部落格時產生的問題,同時也希望能解答朋友們的一些疑惑。

這篇部落格中所有的程式碼都是基於Python 3.4的。這是因為Python 3.4同時引入了 selectorsasyncio 模組。對於Python以前的版本,Twisted, geventtornado 都提供了類似的功能。

對於本文中剛開始的一些示例程式碼,出於簡單易懂的原因,我並沒有引入錯誤處理和異常的機制。在實際編碼中,適當的異常處理是一個非常重要的編碼習慣。在本文的最後,我將用幾個例子來展示Python 3.4中的 asyncio 庫是如何處理異常的。

開始:重溫Hello World

我們來寫一個程式解決一個簡單的問題。本文後面篇幅的多個程式,都是在這題的基礎之上稍作改動,來闡述協程的思想。

寫一個程式每隔3秒列印“Hello World”,同時等待使用者命令列的輸入。使用者每輸入一個自然數n,就計算並列印斐波那契函式的值F(n),之後繼續等待下一個輸入

有這樣一個情況:在使用者輸入到一半的時候有可能就列印了“Hello World!”,不過這個case並不重要,不考慮它。

對於熟悉Node.js和JavaScript的同學可能很快能寫出類似下面的程式:

跟你所看到的一樣,這題使用Node.js很容易就可以做出來。我們所要做的只是設定一個週期性定時器去輸出“Hello World!”,並且在 process.stdindata 事件上註冊一個回撥函式。非常容易,它就是這麼工作了,但是原理如何呢?讓我們先來看看Python中是如何做這樣的事情的,再來回答這個問題。

在這裡也使用了一個 log_execution_time 裝飾器來統計斐波那契函式的計算時間。

程式中採用的 斐波那契演算法 是故意使用最慢的一種的(指數複雜度)。這是因為這篇文章的主題不是關於斐波那契的(可以參考我的這篇文章,這是一個關於斐波那契對數複雜度的演算法),同時因為比較慢,我可以更容易地展示一些概念。下面是Python的做法,它將使用數倍的時間。

回到最初的問題,我們如何開始去寫這樣一個程式呢?Python內部並沒有類似於 setInterval 或者 setTimeOut 這樣的函式。
所以第一個可能的做法就是採用系統層的併發——多執行緒的方式:

同樣也不麻煩。但是它和Node.js版本的做法是否在效率上也是差不多的呢?來做個試驗。這個斐波那契計算地比較慢,我們嘗試一個較為大的數字就可以看到比較客觀的效果:Python中用37,Node.js中用45(JavaScript在數字計算上本身就比Python快一些)。

它花了將近9秒來計算,在計算的同時“Hello World!”的輸出並沒有被掛起。下面嘗試下Node.js:

不過Node.js在計算斐波那契的時候,“Hello World!”的輸出卻被掛起了。我們來研究下這是為什麼。

事件迴圈和執行緒

對於執行緒和事件迴圈我們需要有一個簡單的認識,來理解上面兩種解答的區別。先從執行緒說起,可以把執行緒理解成指令的序列以及CPU執行的上下文(CPU上下文就是暫存器的值,也就是下一條指令的暫存器)。

一個同步的程式總是在一個執行緒中執行的,這也是為什麼在等待,比如說等待IO或者定時器的時候,整個程式會被阻塞。最簡單的掛起操作是 sleep ,它會把當前執行的執行緒掛起一段給定的時間。一個程式可以有多個執行緒,同一個程式中的執行緒共享了程式的一些資源,比如說記憶體、地址空間、檔案描述符等。

執行緒是由作業系統的排程器來排程的,排程器統一負責管理排程程式中的執行緒(當然也包括不同程式中的執行緒,不過對於這部分我將不作過多描述,因為它超過了本文的範疇。),它來決定什麼時候該把當前的執行緒掛起,並把CPU的控制權交給另一個執行緒來處理。這稱為上下文切換,包括對於當前執行緒上下文的儲存、對目標執行緒上下文的載入。上下文切換會對效能造成一定的影響,因為它本身也需要CPU週期來執行。

作業系統切換執行緒有很多種原因:
1.另一個優先順序更高的執行緒需要馬上被執行(比如處理硬體中斷的程式碼)
2.執行緒自己想要被掛起一段時間(比如 sleep)
3.執行緒已經用完了自己時間片,這個時候執行緒就不得不再次進入佇列,供排程器排程

回到我們之前的程式碼,Python的解答是多執行緒的。這也解釋了兩個任務可以並行的原因,也就是在計算斐波那契這樣的CPU密集型任務的時候,沒有把其它的執行緒阻塞住。

再來看Node.js的解答,從計算斐波那契把定時執行緒阻塞住可以看出它是單執行緒的,這也是Node.js實現的方式。從作業系統的角度,你的Node.js程式是在單執行緒上執行的(事實上,根據作業系統的不同,libuv 庫在處理一些IO事件的時候可能會使用執行緒池的方式,但這並不影響你的JavaScript程式碼是跑在單執行緒上的事實)。

基於一些原因,你可能會考慮避免多執行緒的方式:
1.執行緒在計算和資源消耗的角度是較為昂貴的
2.執行緒併發所帶來的問題,比如因為共享的記憶體空間而帶來的死鎖和競態條件。這些又會導致更加複雜的程式碼,在編寫程式碼的時候需要時不時地注意一些執行緒安全的問題
當然以上這些都是相對的,執行緒也是有執行緒的好處的。但討論那些又與本文的主題偏離了,所以就此打住。

來嘗試一下不使用多執行緒的方式處理最初的問題。為了做到這個,我們需要模仿一下Node.js是怎麼做的:事件迴圈。我們需要一種方式去poll(譯者注:沒想到對這個詞的比較合適的翻譯,輪訓?不合適。) stdin 看看它是否已經準備好輸入了。基於不同的作業系統,有很多不同的系統呼叫,比如 poll, select, kqueue 等。在Python 3.4中,select 模組在以上這些系統呼叫之上提供了一層封裝,所以你可以在不同的作業系統上很放心地使用而不用擔心跨平臺的問題。

有了這樣一個polling的機制,事件迴圈的實現就很簡單了:每個迴圈去看看 stdin 是否準備好,如果已經準備好了就嘗試去讀取。之後去判斷上次輸出“Hello world!”是否3秒種已過,如果是那就再輸出一遍。
下面是程式碼:

然後輸出:

跟預計的一樣,因為使用了單執行緒,該程式和Node.js的程式一樣,計算斐波那契的時候阻塞了“Hello World!”輸出。
Nice!但是這個解答還是有點hard code的感覺。下一部分,我們將使用兩種方式對這個event loop的程式碼作一些優化,讓它功能更加強大、更容易編碼,分別是 回撥協程

事件迴圈——回撥

對於上面的事件迴圈的寫法一個比較好的抽象是加入事件的handler。這個用回撥的方式很容易實現。對於每一種事件的型別(這個例子中只有兩種,分別是stdin的事件和定時器事件),允許使用者新增任意數量的事件處理函式。程式碼不難,就直接貼出來了。這裡有一點比較巧妙的地方是使用了 bisect.insort 來幫助處理時間的事件。演算法描述如下:維護一個按時間排序的事件列表,最近需要執行的定時器在最前面。這樣的話每次只需要從頭檢查是否有超時的事件並執行它們。bisect.insort 使得維護這個列表更加容易,它會幫你在合適的位置插入新的定時器事件回撥函式。誠然,有多種其它的方式實現這樣的列表,只是我採用了這種而已。

程式碼很簡單,實際上Node.js底層也是採用這種方式實現的。然而在更復雜的應用中,以這種方式來編寫非同步程式碼,尤其是又加入了異常處理機制,很快程式碼就會變成所謂的回撥地獄(callback hell )。引用 Guido van Rossum 關於回撥方式的一段話:

要以回撥的方式編寫可讀的程式碼,你需要異於常人的編碼習慣。如果你不相信,去看看JavaScript的程式碼就知道了——Guido van Rossum

寫非同步回撥程式碼還有其它的方式,比如 promisecoroutine(協程) 。我最喜歡的方式(協程非常酷,我的部落格中這篇文章就是關於它的)就是採用協程的方式。下一部分我們將展示使用協程封裝任務來實現事件迴圈的。

事件迴圈——協程

協程 也是一個函式,它在返回的同時,還可以儲存返回前的執行上下文(本地變數,以及下一條指令),需要的時候可以重新載入上下文從上次離開的下一條命令繼續執行。這種方式的 return 一般叫做 yielding。在這篇文章中我介紹了更多關於協程以及在Python中的如何使用的內容。在我們的例子中使用之前,我將對協程做一個更簡單的介紹:

Python中 yield 是一個關鍵詞,它可以用來建立協程。
1.當呼叫 yield value 的時候,這個 value 就被返回出去了,CPU控制權就交給了協程的呼叫方。呼叫 yield 之後,如果想要重新返回協程,需要呼叫Python中內建的 next 方法。
2.當呼叫 y = yield x 的時候,x被返回給呼叫方。要繼續返回協程上下文,呼叫方需要再執行協程的 send 方法。在這個列子中,給send方法的引數會被傳入協程作為這個表示式的值(本例中,這個值會被y接收到)。

這意味著我們可以用協程來寫非同步程式碼,當程式等待非同步操作的時候,只需要使用yield把控制權交出去就行了,當非同步操作完成了再進入協程繼續執行。這種方式的程式碼看起來像同步的方式編寫的,非常流暢。下面是一個採用yield計算斐波那契的簡單例子:

僅僅這樣還不夠,我們需要一個能處理協程的事件迴圈。在下面的程式碼中,我們維護了一個列表,列表裡面儲存了,事件迴圈要執行的 task。當輸入事件或者定時器事件發生(或者是其它事件),有一些協程需要繼續執行(有可能也要往協程中傳入一些值)。每一個 task 裡面都有一個 stack 變數儲存了協程的呼叫棧,棧裡面的每一個協程都依賴著後一個協程的完成。這個基於PEP 342中 “Trampoline”的例子實現的。程式碼中我也使用了 functools.partial,對應於JavaScript中的 Function.prototype.bind,即把引數繫結(curry)在函式上,呼叫的時候不需要再傳參了。
下面是程式碼:

程式碼中我們也實現了一個 do_on_next_tick 的函式,可以在下次事件迴圈的時候註冊想要執行的函式,這個跟Node.js中的process.nextTick多少有點像。我使用它來實現了一個簡單的 exit 特性(即便我可以直接呼叫 loop.stop())。

我們也可以使用協程來重構斐波那契演算法代替原有的遞迴方式。這麼做的好處在於,協程間可以併發執行,包括輸出“Hello World!”的協程。
斐波那契演算法重構如下:

程式的輸出:

不重複造車輪

前面兩個部分,我們分別使用了回撥函式和協程實現了事件迴圈來寫非同步的邏輯,對於實踐學習來說確實是一種不錯的方式,但是Python中已經有了非常成熟的庫提供事件迴圈。Python3.4中的 asyncio 模組提供了事件迴圈和協程來處理IO操作、網路操作等。在看更多有趣的例子前,針對上面的程式碼我們用 asyncio 模組來重構一下:

上面的程式碼中 @asyncio.coroutine 作為裝飾器來裝飾協程,yield from 用來從其它協程中接收引數。

異常處理

Python中的協程允許異常在協程呼叫棧中傳遞,在協程掛起的地方捕獲到異常狀態。我們來看一個簡單的例子:

輸出如下:

這個特性使得用異常處理問題有一個統一的處理方式,不管是在同步還是非同步的程式碼中,因為事件迴圈可以合理地捕獲以及傳遞異常。我們來看一個事件迴圈和多層呼叫協程的例子:

輸出:

在上面的例子中,協程C依賴B的結果,B又依賴A的結果,A最後丟擲了一個異常。最後這個異常一直被傳遞到了C,然後被捕獲輸出。這個特性與同步的程式碼的方式基本一致,並不用手動在B中捕獲、再丟擲!

當然,這個例子非常理論化,沒有任何創意。讓我們來看一個更像生產環境中的例子:我們使用 ipify 寫一個程式非同步地獲取本機的ip地址。因為 asyncio 庫並沒有HTTP客戶端,我們不得不在TCP層手動寫一個HTTP請求,並且解析返回資訊。這並不難,因為API的內容都以及胸有成竹了(僅僅作為例子,不是產品程式碼),說幹就幹。實際應用中,使用 aiohttp 模組是一個更好的選擇。下面是實現程式碼:

是不是跟同步程式碼看著很像?:沒有回撥函式,沒有複雜的錯誤處理邏輯,非常簡單、可讀性非常高的程式碼。
下面是程式的輸出,沒有任何錯誤:

使用協程來處理非同步邏輯的主要優勢在我看來就是:錯誤處理與同步程式碼幾乎一致。比如在上面的程式碼中,協程呼叫鏈中任意一環出錯,並不會導致什麼問題,錯誤與同步程式碼一樣被捕獲,然後處理。

依賴多個互不相關協程的返回結果

在上面的例子中,我們寫的程式是順序執行的,雖然使用了協程,但互不相關的協程並沒有完美地併發。也就是說,協程中的每一行程式碼都依賴於前一行程式碼的執行完畢。有時候我們需要一些互不相關的協程併發執行、等待它們的完成結果,並不在意它們的執行順序。比如,使用網路爬蟲的時候,我們會給頁面上的所有外鏈傳送請求,並把返回結果放入處理佇列中。

協程可以讓我們用同步的方式編寫非同步的程式碼,但是對於處理互不相關的任務不論是完成後馬上處理抑或是最後統一處理,回撥的方式看上去是最好的選擇。但是,Python 3.4的 asyncio 模組同時也提供了以上兩種情形的支援。分別是函式 asyncio.as_completedasyncio.gather

我們來一個例子,例子中需要同時載入3個URL。採用兩種方式:
1.使用 asyncio.as_completed 一旦請求完成就處理
2.使用 asyncio.gather 等待所有都完成一起處理
與其載入真的URL地址,我們採用一個更簡單的方式,讓協程掛起隨機長度的時間。
下面是程式碼:

輸出如下:

更加深入

有很多內容本篇文章並沒有涉及到,比如 Futureslibuv這個視訊(需要梯子)是介紹Python中的非同步IO的。本篇文章中也有可能有很多我遺漏的內容,歡迎隨時在評論中給我補充。

相關文章