用greenlet實現Python中的併發

發表於2017-01-03

上一篇介紹生成器時,我們講到了協程(Coroutine),它也被稱為微執行緒。回顧一下,協程可以在一個函式執行過程中將其掛起,去執行另一個函式,並在必要時將之前的函式喚醒。在Python的語言環境裡,協程是相當常用的實現“併發”的方法。上一篇的例子中,我們演示瞭如何使用yield關鍵字來實現協程,不過這個看上去非常不直觀。這裡我們要介紹一個非常好用的框架greenlet,很多知名的網路併發框架如eventlet,gevent都是基於它實現的。

第一個例子

沿襲我們一直以來的習慣,先從例子開始,這次偷個懶,直接把官方文件中的例子拿過來:

這裡建立了兩個greenlet協程物件,gr1和gr2,分別對應於函式test1()和test2()。使用greenlet物件的switch()方法,即可以切換協程。上例中,我們先呼叫”gr1.switch()”,函式test1()被執行,然後列印出”12″;接著由於”gr2.switch()”被呼叫,協程切換到函式test2(),列印出”56″;之後”gr1.switch()”又被呼叫,所以又切換到函式test1()。但注意,由於之前test1()已經執行到第5行,也就是”gr2.switch()”,所以切換回來後會繼續往下執行,也就是列印”34″;現在函式test1()退出,同時程式退出。由於再沒有”gr2.switch()”來切換至函式test2(),所以程式第11行”print 78″不會被執行。

所以,程式執行下來的輸出就是:

很好理解吧。使用switch()方法切換協程,也比”yield”, “next/send”組合要直觀的多。上例中,我們也可以看出,greenlet協程的執行,其本質是序列的,所以它不是真正意義上的併發,因此也無法發揮CPU多核的優勢,不過,這個可以通過協程+程式組合的方式來解決,本文就不展開了。另外要注意的是,在沒有進行顯式切換時,部分程式碼是無法被執行到的,比如上例中的”print 78″。

父子關係

建立協程物件的方法其實有兩個引數”greenlet(run=None, parent=None)”。引數”run”就是其要呼叫的方法,比如上例中的函式test1()和test2();引數”parent”定義了該協程物件的父協程,也就是說,greenlet協程之間是可以有父子關係的。如果不設或設為空,則其父協程就是程式預設的”main”主協程。這個”main”協程不需要使用者建立,它所對應的方法就是主程式,而所有使用者建立的協程都是其子孫。大家可以把greenlet協程集看作一顆樹,樹的根節點就是”main”,上例中的”gr1″和”gr2″就是其兩個位元組點。

在子協程執行完畢後,會自動返回父協程。比如上例中test1()函式退出,程式碼會返回到主程式。讓我們寫個更清晰的例子來實驗下:

這裡建立greenlet物件”gr2″時,指定了其父協程是”gr1″。所以在函式test2()裡,雖然沒有”gr1.switch()”程式碼,但是在其退出後,程式一樣回到了函式test1(),並且執行”print 34″。同樣,在test1()退出後,程式碼回到了主程式,並執行”print 78″。所以,最後的輸出就是:

如果上例中,”gr2″的父協程不是”gr1″而是”main”的話,那test2()執行完畢就會回到主程式並直接列印”78″,這樣”print 34″就不會執行。大家可以試一試。

還有一個重要的點,就是協程退出後,就無法再被執行了。如果上例在函式test1()中,再加一句”gr2.switch()”,執行的結果是一樣的。因為第二次呼叫”gr2.switch()”,什麼也不會執行。

大家可能會感覺到父子協程之間的關係,就像函式呼叫一樣,一個巢狀一個。的確,其實greenlet協程的實現就是使用了棧,其執行的上下文儲存在棧中,”main”主協程處於棧底的位置,而當前執行中的協程就在棧頂。這同函式是一樣。此外,在任何時候,你都可以使用”greenlet.getcurrent()”,獲取當前執行中的協程物件。比如在函式test2()中執行”greenlet.getcurrent()”,其返回就等於”gr2″。

異常

既然協程是存放在棧中,那一個協程要丟擲異常,就會先拋到其父協程中,如果所有父協程都不捕獲此異常,程式才會退出。我們試下,把上面的例子中函式test2()的程式碼改為:

程式執行後,我們可以看到Traceback資訊:

同時大家可以試下,如果將”gr2″的父協程設為空,Traceback資訊就會變為:

因此,如果”gr2″的父協程是”gr1″的話,異常先回拋到函式test1()的程式碼”gr2.switch()”處。所以,我們再對函式test1()改動下:

執行後的結果,如果”gr2″的父協程是”gr1″,則異常被捕獲,並列印90。否則,異常會被丟擲。以上實驗很好的證明了,子協程丟擲的異常會根據棧裡的順序,依次拋到父協程裡。

有一個異常是特例,不會被拋到父協程中,那就是”greenlet.GreenletExit”,這個異常會讓當前協程強制退出。比如,我們將函式test2()改為:

那程式碼行”print 78″永遠不會被執行。但這個異常不會往上拋,所以其父協程還是可以正常執行。

另外,我們可以通過greenlet物件的”throw()”方法,手動往一個協程裡拋個異常。比如,我們在test1()裡調一個throw()方法:

這樣,異常就會被丟擲,執行後的Trackback是這樣的:

如果將”gr2.throw(NameError)”放在”try”語句中,那該異常就會被捕獲,並列印”90″。另外,當”gr2″的父協程不是”gr1″而是”main”時,異常會直接拋到主程式中,此時函式test1()中的”try”語句就不起作用了。

協程間傳遞訊息

在介紹生成器時,我們聊過可以使用生成器的send()方法來傳遞引數。greenlet也同樣支援,只要在其switch()方法呼叫時,傳入引數即可。我們再來基於本文第一個例子改造下:

在test1()中呼叫”gr2.switch()”,由於協程”gr2″之前未被啟動,所以傳入的引數”56″會被賦在test2()函式的引數”x”上;在test2()中呼叫”gr1.switch()”,由於協程”gr1″之前已執行到第5行”y = gr2.switch(56)”這裡,所以傳入的引數”34″會作為”gr2.switch(56)”的返回值,賦給變數”y”。這樣,兩個協程之間的互傳訊息就實現了。

讓我們將上一篇介紹生成器時寫的生產者消費者的例子,改為greenlet實現吧:

更多參考資料

greenlet的官方文件
greenlet的原始碼

本文中的示例程式碼可以在這裡下載

 

相關文章