在各語言的非同步解決方案之間,Python 3.4的asyncio顯得不太一樣,其前身為Guido van Rossum開啟的tulip專案,基於協程(coroutine)來實現非同步,開發者可以將asyncio單純當成API來使用,然而,若能瞭解其背後的發展歷史,不僅有趣,對於瞭解asyncio的實現原理與應用,也會有很大的幫助。

yield的歷史

協程並不是常見的程式語言基本元素,也因此正如《流暢的Python》談及協程的章節,一開始就引用David Beazley的話,談到協程是「最沒人寫到、晦澀且顯然無用的Python功能」,然而,近來在探討asyncio的文件中經常看到對協程的探討,基本上都是從yield的介紹開始,正如許多Python開發者所知道的,yield最常的作用之一,就是作為生成器(Generator)。

想像一下,如果需要無限長(或極大數量)的自然數,由於記憶體容量之限制,這不可能用list來產生,如果Python開發者熟悉一級函式的概念,實作方式之一是從函式中傳回一個函式,以Closure讓傳回的函式保有計數狀態,以便在每次呼叫函式時遞增數字並傳回,只是這樣撰寫上不夠方便,若使用yield的話,就不需要使用到Closure,而可以只使用區域變數來達到相同功能,略為熟悉Python的開發者,都能夠輕鬆地實現這個功能。

此外,需要時才產生一個值,這是產生器(Generator)的概念,Python在2.2時提出了yield,規範文件PEP 255的標題名稱,正是〈Simple Generators〉。實際上,一個函式若有yield,傳回的產生器就實現了迭代器協定(然而迭代器不一定就是產生器),不過,這時還稱不上是個協程,只不過是能在yield時暫停流程,並將指定值及流程交給呼叫方。

在Python 2.5中實作了PEP 342,為yield建立的產生器加入了send、throw等方法,因此,呼叫方就可以對產生器發布資料、引發例外了,這形成了兩個流程間雙向溝通的可能性,PEP 342文件標題開頭也出現了Corutines這個字眼。

對於協程,《流暢的Python》中有句目前我覺得最為清楚的解釋:「一個可以與呼叫方協作的程序,可產生與接收呼叫方傳送的值」,不過Python的協程只能在呼叫方與非呼叫方之間切換,而不能在任意流程之間切換,因此實際上也常被稱為半協程(semi-coroutine)。

在更進一步的應用中,產生器可能層層銜接其他的產生器,為此PEP 380增加了yield from,並在Python 3.3中實現,同時,這是yield至目前為止的最後一次變動。而當產生器層層嵌套時,yield from的主要功能是建立一個管道,讓最外層的呼叫方能夠方便地與更內層的產生器協作,避免中間不必要的樣版程式。

生產者與消費者

在PEP 255提出之後,yield的作用是作為產生器,而Python中也有許多的產生器實作,目的通常是實現惰性求值,以在某些場合提升效率。大部份的文件都是這麼介紹yield的運用,而在PEP 342中,則談到:「協程是表現許多演算法時自然的方式,像是模擬、遊戲、非同步輸入輸出,以及其他事件驅動程式設計或者協作的多工形式」。

生產者與消費者的程式,或許是個不錯的例子。在沒有協程的語言中,通常會使用執行緒來實現生產者與消費者,在各自的流程中實現生產與消費的行為,並在某個特定條件下進行等待,或者通知另一方繼續流程。實際上,執行緒的等待,相當於讓出目前於CPU中的執行權利,而通知相當於重啟另一執行緒的流程,同樣是讓出流程與重啟流程,前者可以用Python的yield,而後者可以用next或者是send來實現。例如,對於生產者、消費者,可能是這麼實現:

def producer():
    while True:
        yield something()
               
def consumer():
    while True:
        consume(yield)

接著,就只要有個clerk之類的角色,使用next()來驅動producer()傳回的產生器取得產品,使用send()來驅動consumer()傳回的產生器來消耗產品,如此也能完成執行緒能達到之功能。若要進行一個簡單的程式clerk實作,可參考我的Gist

綠色執行緒

就流程切換本身來說,執行緒與協程有著相似性。在使用執行緒時,雖然可以透過等待、通知之類的機制,來進行某種程度的流程控制,不過,很大部份還是由作業系統決定了切換的時機。而在使用Python的yield、next、send等之時,流程的切換是由開發者決定,相較於執行緒來說,協程的成本很低,而且是在一個執行緒之中完成,因此有時會看到以微執行緒(microthread)、綠色執行緒(green thread)等方式,來形容某些類型的協程實現。

執行緒會在程式阻斷時被切換,像是輸入輸出,因此,常見到使用執行緒來實現並行,以充分運用CPU的時間,像是使用執行緒來實現多個並行的下載,或者是實現多人連線的功能。運用協程時,如果有方式能夠在被阻斷時,就將流程還給呼叫方,在某個事件發生時,再推動產生器繼續後面之流程,那麼也就能夠使用協程來實現並行。

因此除了使用yield建立產生器之外,這時還需要一個主迴圈,不斷地檢查並處理事件,在某些事件發生時推進產生器,而原本會阻斷的程式庫,必須替換為可非同步執行的實作,這可以透過select之類的模組來實現。在Python生態圈中有個gevent程式庫,可以透過monkey.patch_all()來對既有程式庫進行補強。

真正在實作時,當然不像方才的生產者、消費者那麼簡單。在《流暢的Python》中,有個計程車隊模擬程式,當中的計程車旅程是交錯的,如果想瞭解主迴圈、事件檢查與處理、產生器的推進等如何實作,會是個不錯的範例。

從yield的發展認識asyncio

不過,Monkey patching看來不符合Python的哲學,Gevent社群撰寫的〈gevent For the Working Python Developer〉中就寫到:「Monkey patching仍然是邪惡的,只不過這種情況下是有用的邪惡(useful evil)」這也就是為什麼asyncio要明確地使用@coroutine、yield from,而後來Python 3.5改用async與await,至於一個可以await的協程,就是awaitable物件。

在《流暢的Python》、〈History of Coroutine in Python〉〈How the heck does async/await work in Python 3.5?〉,這些都曾提及yield的歷史,因而導引出協程、迴圈、事件、async與await以及awaitable物件等,組成asyncio的元素。

這些元素使得asyncio使用起來,與基於回呼的非同步框架很不相同,而asyncio還很新,顯然還需要有更多文件來說明該怎麼運用,像這樣去瞭解發展歷程(也許還要包含其他非同步框架的發展史)會是個啟發,而這也有助於瞭解何時可使用協程來取代執行緒,以及找出asyncio(或其他基於協程的第三方程式庫)之適用場合。

專欄作者

熱門新聞

Advertisement