Python “黑魔法” 之 Generator Coroutines

發表於2016-05-17

寫在前面

學過 Python 的都知道,Python 裡有一個很厲害的概念叫做 生成器(Generators)。一個生成器就像是一個微小的執行緒,可以隨處暫停,也可以隨時恢復執行,還可以和程式碼塊外部進行資料交換。恰當使用生成器,可以極大地簡化程式碼邏輯。

也許,你可以熟練地使用生成器完成一些看似不可能的任務,如“無窮斐波那契數列”,並引以為豪,認為所謂的生成器也不過如此——那我可要告訴你:這些都太小兒科了,下面我所要介紹的絕對會讓你大開眼界。

生成器 可以實現 協程,你相信嗎?

什麼是協程

在非同步程式設計盛行的今天,也許你已經對 協程(coroutines) 早有耳聞,但卻不一定了解它。我們先來看看 Wikipedia 的定義:

Coroutines are computer program components that generalize subroutines for nonpreemptive multitasking, by allowing multiple entry points for suspending and resuming execution at certain locations.

也就是說:協程是一種 允許在特定位置暫停或恢復的子程式——這一點和 生成器 相似。但和 生成器 不同的是,協程 可以控制子程式暫停之後程式碼的走向,而 生成器 僅能被動地將控制權交還給呼叫者。

協程 是一種很實用的技術。和 多程式 與 多執行緒 相比,協程 可以只利用一個執行緒更加輕便地實現 多工,將任務切換的開銷降至最低。和 回撥 等其他非同步技術相比,協程 維持了正常的程式碼流程,在保證程式碼可讀性的同時最大化地利用了 阻塞 IO 的空閒時間。它的高效與簡潔贏得了開發者們的擁戴。

Python 中的協程

早先 Python 是沒有原生協程支援的,因此在 協程 這個領域出現了百家爭鳴的現象。主流的實現由以下兩種:

  • 用 C 實現協程排程。這一派以 gevent 為代表,在底層實現了協程排程,並將大部分的 阻塞 IO 重寫為非同步。
  • 用 生成器模擬。這一派以 Tornado 為代表。Tornado 是一個老牌的非同步 Web 框架,涵蓋了五花八門的非同步程式設計方式,其中包括 協程。本文部分程式碼借鑑於 Tornado。

直至 Python 3.4,Python 第一次將非同步程式設計納入標準庫中(參見 PEP 3156),其中包括了用生成器模擬的 協程。而在 Python 3.5 中,Guido 總算在語法層面上實現了 協程(參見 PEP 0492)。比起 yield 關鍵字,新關鍵字 asyncawait 具有更好的可讀性。在不久的將來,新的實現將會慢慢統一混亂已久的協程領域。

儘管 生成器協程 已成為了過去時,但它曾經的輝煌卻不可磨滅。下面,讓我們一起來探索其中的魔法。

一個簡單的例子

假設有兩個子程式 mainprinterprinter 是一個死迴圈,等待輸入、加工並輸出結果。main 作為主程式,不時地向 printer 傳送資料。

這應該怎麼實現呢?

傳統方式中,這幾乎不可能在一個執行緒中實現,因為死迴圈會阻塞。而協程卻能很好地解決這個問題:

輸出:

這其實就是最簡單的協程。程式由兩個分支組成。主程式通過 send 喚起子程式並傳入資料,子程式處理完後,用 yield 將自己掛起,並返回主程式,如此交替進行。

協程排程

有時,你的手頭上會有多個任務,每個任務耗時很長,而你又不想同步處理,而是希望能像多執行緒一樣交替執行。這時,你就需要一個排程器來協調流程了。

作為例子,我們假設有這麼一個任務:

如果你直接執行 task,那它會在遍歷 times 次之後才會返回。為了實現我們的目的,我們需要將 task 人為地切割成若干塊,以便並行處理:

這裡的 yield 沒有邏輯意義,僅是作為暫停的標誌點。程式流可以在此暫停,也可以在此恢復。而通過實現一個排程器,我們可以完成多個任務的並行處理:

這裡我們用一個佇列(deque)儲存任務列表。其中的 run 是一個重要的方法: 它通過輪轉佇列依次喚起任務,並將已經完成的任務清出佇列,簡潔地模擬了任務排程的過程。

而現在,我們只需呼叫:

就可以得到預想中的效果了:

簡直完美!答案和醜陋的多執行緒別無二樣,程式碼卻簡單了不止一個數量級。

非同步 IO 模擬

你絕對有過這樣的煩惱:程式常常被時滯嚴重的 IO 操作(資料庫查詢、大檔案讀取、越過長城拿資料)阻塞,在等待 IO 返回期間,執行緒就像死了一樣,空耗著時間。為此,你不得不用多執行緒甚至是多程式來解決問題。

而事實上,在等待 IO 的時候,你完全可以做一些與資料無關的操作,最大化地利用時間。Node.js 在這點做得不錯——它將一切非同步化,壓榨效能。只可惜它的非同步是基於事件回撥機制的,稍有不慎,你就有可能陷入 Callback Hell 的深淵。

而協程並不使用回撥,相比之下可讀性會好很多。其思路大致如下:

  • 維護一個訊息佇列,用於儲存 IO 記錄。
  • 協程函式 IO 時,自身掛起,同時向訊息佇列插入一個記錄。
  • 通過輪詢或是 epoll 等事件框架,捕獲 IO 返回的事件。
  • 從訊息佇列中取出記錄,恢復協程函式。

現在假設有這麼一個耗時任務:

正常情況下,這個任務執行完需要 3 秒,倘若多個同步任務同步執行,執行時間會成倍增長。而如果利用協程,我們就可以在接近 3 秒的時間內完成多個任務。

首先我們要實現訊息佇列:

Event 是訊息的基類,其在初始化時會將自己放入訊息佇列 events_list 中。Event 和 排程器 使用回撥進行互動。

接著我們要 hack 掉 sleep 函式,這是因為原生的 time.sleep() 會阻塞執行緒。通過自定義 sleep 我們可以模擬非同步延時操作:

可以看出:sleep 在呼叫後就會立即返回,同時一個 SleepEvent 物件會被放入訊息佇列,經過timeout 秒後執行回撥。

再接下來便是協程排程了:

run 啟動了所有的子程式,並開始訊息迴圈。每遇到一處掛起,排程器自動設定回撥,並在回撥中重新恢復程式碼流。“1” 處巧妙地利用閉包儲存狀態。

最後是主程式碼:

輸出:

協程函式的層級呼叫

上面的程式碼有一個不足之處,即協程函式返回的是一個 Event 物件。然而事實上只有直接操縱 IO 的協程函式才有可能接觸到這個物件。那麼,對於呼叫了 IO 的函式的呼叫者,它們應該如何實現呢?

設想如下任務:

long_add 是 IO 的一級呼叫者,task 呼叫 long_add,並利用其返回值進行後續操作。

簡而言之,我們遇到的問題是:一個被喚起的協程函式如何喚起它的呼叫者?

正如在上個例子中,協程函式通過 Event 的回撥與排程器互動。同理,我們也可以使用一個類似的物件,在這裡我們稱其為 Future

Future 儲存在被呼叫者的閉包中,並由被呼叫者返回。而呼叫者通過在其上面設定回撥函式,實現兩個協程函式之間的互動。

Future 的程式碼如下,看起來有點像 Event

Future 的回撥函式允許接受一個引數作為返回值,以儘可能地模擬一般函式。

但這樣一來,協程函式就會有些複雜了。它們不僅要負責喚醒被呼叫者,還要負責與呼叫者之間的互動。這會產生許多重複程式碼。為了 D.R.Y,我們用裝飾器封裝這一邏輯:

coroutine 包裝過的生成器成為了一個普通函式,返回一個 Future 物件。_next 為喚醒的核心邏輯,通過一個類似遞迴的回撥設定簡潔地實現自我喚醒。當自己執行完時,會將自己閉包內的Future物件標記為done,從而喚醒呼叫者。

為了適應新變化,sleep 也要做相應的更改:

sleep 不再返回 Event 物件,而是一致地返回 Future,並作為 EventFuture 之間的代理者。

基於以上更改,排程器可以更加簡潔——這是因為協程函式能夠自我喚醒:

主程式:

由於我們使用了一個糟糕的事件輪詢機制,密集的計算會阻塞通往 stdout 的輸出,因而看起來所有的結果都是一起列印出來的。為此,我在列印時特地加上了時間戳,以演示協程的效果。輸出如下:

這事實上是 tornado.gen.coroutine 的簡化版本,為了敘述方便我略去了許多細節,如異常處理以及排程優化,目的是讓大家能較清晰地瞭解 生成器協程 背後的機制。因此,這段程式碼並不能用於實際生產中

小結

  • 這,才叫精通生成器。
  • 學習程式設計,不僅要知其然,亦要知其所以然。
  • Python 是有魔法的,只有想不到,沒有做不到。

References

相關文章