1. 程式人生 > >深入理解python3.4中Asyncio庫與Node.js的非同步IO機制

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

譯者前言

  • 如何用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的同學可能很快能寫出類似下面的程式:

Python
123456789101112131415 log_execution_time=require('./utils').log_execution_time;var fib=function fib(n){if(n<2)returnn;returnfib(n-1)+fib(n-2);};var timed_fib=log_execution_time(fib);var sayHello=function sayHello(){console.log(Math.floor((newDate()).getTime()/1000)+" - Hello world!");};var handleInput=function handleInput(data){n=parseInt(data.toString());console.log('fib('+n+') = '+timed_fib(n));};process.stdin.on('data',handleInput);setInterval(sayHello,3000);

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

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

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

Python
1234 fromlog_execution_time importlog_execution_timedeffib(n):returnfib(n-1)+fib(n-2)ifn>1elsentimed_fib=log_execution_time(fib)

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

Python
12345678910111213141516171819202122 fromthreadingimportThreadfromtimeimportsleepfromtimeimporttimefromfib importtimed_fibdefprint_hello():whileTrue:print("{} - Hello world!".format(int(time())))sleep(3)defread_and_process_input():whileTrue:n=int(input())print('fib({}) = {}'.format(n,timed_fib(n)))defmain():# Second thread will print the hello message. Starting as a daemon means# the thread will not prevent the process from exiting.t=Thread(target=print_hello)t.daemon=Truet.start()# Main thread will read and process inputread_and_process_input()if__name__=='__main__':main()

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

Python
123456789 python3.4hello_threads.py1412360472-Hello world!371412360475-Hello world!1412360478-Hello world!1412360481-Hello world!Executing fib took8.96seconds.fib(37)=241578171412360484-Hello world!

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

Python
123456789 node hello.js1412360534-Hello world!1412360537-Hello world!45Calculation took12.793secondsfib(45)=11349031701412360551-Hello world!1412360554-Hello world!1412360557-Hello world!

不過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秒種已過,如果是那就再輸出一遍。
下面是程式碼:

Python
123456789101112131415161718192021222324 importselectorsimportsysfromtimeimporttimefromfib importtimed_fibdefprocess_input(stream):text=stream.readline()n=int(text.strip())print('fib({}) = {}'.format(n,timed_fib(n)))defprint_hello():print("{} - Hello world!".format(int(time())))defmain():selector=selectors.DefaultSelector()# Register the selector to poll for "read" readiness on stdinselector.register(sys.stdin,selectors.EVENT_READ)last_hello=0# Setting to 0 means the timer will start right awaywhileTrue:# Wait at most 100 milliseconds for input to be availableforevent,mask inselector.select(0.1):process_input(event.fileobj)iftime()-last_hello>3:last_hello=time()print_hello()if__name__=='__main__':main()

然後輸出:

Python
123456789 $python3.4hello_eventloop.py1412376429-Hello world!1412376432-Hello world!1412376435-Hello world!37Executing fib took9.7seconds.fib(37)=241578171412376447-Hello world!1412376450-Hello world!

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

事件迴圈——回撥

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

Python
1234567891011121314151617181920212223242526272829303132333435363738394041424344