標籤:
用 greenlet 協程處理非同步事件
自從 PyCon 2011 協程成為熱點話題以來,我一直對此有著濃厚的興趣。為了非同步,我們曾使用多線程編程。然而線程在有著 GIL 的 Python 中帶來的效能瓶頸和多線程編程的高出錯風險,“協程 + 多進程”的組合漸漸被認為是未來發展的方向。技術容易更新,思維轉變卻需要一個過渡。我之前在非同步事件處理方面已經習慣了回調 + 多線程的思維方式,轉換到協程還非常的不適應。這幾天我非常艱難地查閱了一些資料並思考,得出了一個可能並不可靠的總結。儘管這個總結的可靠性很值得懷疑,但是我還是決定記錄下來,因為我覺得既然是學習者,就不應該怕無知。如果讀者發現我的看法有偏差並指出來,我將非常感激。
多線程下非同步編程的方式
線程的出現,為開發人員帶來了除多進程之外另一種實現並發的方式。比起多進程,多線程有另一些優勢,比如可以訪問進程內的變數,也就是共用資源。還有的說法說線程建立比進程建立開銷低,考慮到這個問題在 Windows 一類進程建立機制很蹩腳的系統才存在,故先忽略。總的來說,線程除了可以實現進程實現的“並發執行”之外,還有另一個功能,就是管理應用程式內部的“事件”。我不知道把這種事件處理分類到非同步中是不是合適,但事件處理一定是基於共用進程內資源才能實現的,所以這是多線程可以做到而多進程做不到的一點。
非同步處理基於兩個前提。第一個前提是支援並發,當然這是基本前提。這裡的並發並不一定要是並行,也就是說允許邏輯上非同步,實現上串列;第二個前提是支援回調(callback),因為並發的、非同步處理不會阻塞當前正在被執行的流程,所以“任務完成後”要執行的步驟應該寫在回調中,絕大多數回調是通過函數來實現。
多線程之所以適合非同步編程,是因為它同時支援並發和回調。無論是系統級的線程還是使用者級的線程,邏輯上都能並發執行不同的控制流程;同時因為能共用進程內資源,所以回調只需要通過簡單的回呼函數。
出於回呼函數的處理比較雜亂,一般非同步程式都引入了事件機制。也就是說把一系列的回呼函數註冊到某個命名的事件,當這個事件被觸發的時候,執行這些回呼函數。例如在 ECMAScript 中,需要在訪問了遠程網址之後,要把響應的結果填充到頁面中,在同步(阻塞)的情況下是這麼做的:
// 在開啟了豆瓣首頁的標籤頁// 開啟了一個 firebut/chrome console 測試var http = new XMLHttpRequest();// 第三個參數為 false 代表不使用非同步http.open("GET", "/site", false);// 發送請求http.send();// 填充響應,一秒鐘變頁面document.write(http.response);
處理起來非常簡單,因為 XMLHttpRequest 的 send 方法會阻塞主線程,所以我們去讀取 http.response 的時候一定已經完成了遠端存取。如果使用基於多線程和回呼函數的非同步方式呢?問題會變得麻煩很多:
var http = new XMLHttpRequest();http.open("GET", "/site", true);// 現在必須使用回呼函數http.onreadystatechange = function() {
if (http.readyState == http.DONE) {
if (http.status == 200) {
document.write(http.response);
}
} else if (http.readyState == http.LOADING) {
document.write("正在載入<br />");
}};http.send();
由於使用非同步方式之後 send 方法不再阻塞主線程,所以必須設定 onreadystatechange 回呼函數。XMLHttpRequest 有多種載入狀態,每次狀態改變會調用一次使用者佈建的回呼函數。現在編程變得麻煩,但是使用者體驗變得更好,因為不再阻塞主線程,使用者可以看到“正在載入”的提示,並且在此期間還可以非同步做其他事情。為了簡化回呼函數的使用,一般採取兩種方式改進回調,第一種方式是對於簡單的回調,直接在參數中將回呼函數傳入,這種方式對有匿名函數的語言來說方便了很多(比如 ECMAScript 和 Ruby,顯然 C 語言和 Python 不在此列);第二種方式是對於複雜的回調,以事件管理器替代。仍然是 ajax 請求的例子,jquery 提供的封裝就採取了第一種方式:
$.get("/site", function(response){
document.write(http.response);});
而 W3C 規定的瀏覽器 window 對象,則採取了事件管理器的方式管理更為複雜的非同步支援:
// 別在 IE 下試,IE 的函數名不一樣。window.addEventListener("load", function(){
// do something}, false);
採取事件管理器的本質還是使用回調,不過這種方式提出了“事件”的概念,將回呼函數統一註冊到一個管理器中,並對應到各自的“事件”,需要調用這一系列回呼函數的時候,就“觸發”這一個“事件”,管理器會調用註冊進來的回呼函數。這種做法解除了調用者和被調用者的耦合,其實就是 GoF 觀察者模式 [0]的具體應用。
用多線程實現非同步弊病
“我們仍然認為,如果在連 a=a+1 都沒有確定結果的語言中,無人可以寫出正確的程式。” —— 《編程之魂》 [1]
用多線程來實現非同步最大的弊病,是它真的是並發的。採用線程實現的非同步,即使不存在多核並行,線程執行的先後仍然是不可預知的。作業系統課程上我們也學到過,稱之為不可再現性。究其原因,線程的調度畢竟是調度器來完成的,無論是系統級的調度還是使用者級的調度,調度器都會因為 IO 操作、時間片用完等諸多的原因,而強制奪取某個線程的控制權。這種不可再現性給線程編程帶來了極大的麻煩。如果是上段中的簡單代碼還沒什麼,若是情況更加複雜一些,在單獨的線程中操作了某共用資源,那麼這個共用資源就會成為危險的臨界資源,一時疏忽忘記加鎖就會帶來資料不一致問題。而加鎖本身是把對資源的並行訪問序列化,所以鎖往往又是拖慢系統效率的罪魁禍首,由此又發展出了多種複雜的鎖機制。
Unix 編程哲學強調 Simple is better,有時跳出來想想,有些複雜性是不是走了彎路導致的呢?首先,多線程編程以並發和事件機制來實現非同步,並發可以帶來效能的提升,同時能給我們非阻塞工作方式。對於臨界資源的訪問,我們又必須使之序列化,甚至誕生了管道、訊息佇列這種絕對序列化的通訊方式。為何不乾脆就讓所有的操作序列化,以此換取資源的安全,多核資源的利用則交給多進程實現呢?Python 的做法就是這樣。Python 的線程是系統級線程,由核心調度,卻不是真正的並發執行。因為 Python 有一個全域解譯器鎖(GIL),它導致 Python 內部的線程執行實質上是串列的。
串列的線程無法充分利用多核資源,但是換來了安全執行緒,看上去是比較明智的選擇,但 Python 的線程卻有個很大的缺點 —— 這些線程是系統級的。系統級線程由核心來調度,調度的開銷會比想象的要大,而很多情況下這些調度開銷是付出的很沒有價值的。比如一次非同步遠程網址擷取,本來只需要在開始訪問網路的時候釋放主線程式控制制權,得到響應之後返回主線程式控制制權,使用系統級線程之後調度全部委託給了系統核心,簡單問題往往就複雜化了。協程(Coroutine) [2] 提供了不同於線程的另一種方式,它首先是序列化的。其次,在序列化的過程中,協程允許使用者顯式釋放控制權,將控制權轉移另一個過程。釋放控制權之後,原過程的狀態得以保留,直到控制權恢複的時候,可以繼續執行下去。所以協程的控制權轉移也稱為“掛起”和“喚醒”。
Python 中的協程
其實 Python 語言內建了協程的支援,也就是我們一般用來製作迭代期的“產生器”(Generator)。產生器本身不是一個完整的協程實現,所以此外 Python 的第三方庫中還有一個優秀的替代品 greenlet [3] 。
使用產生器作為協程支援,可以實現簡單的事件調度模型:
from time import sleep# Event Managerevent_listeners = {}def fire_event(name):
event_listeners[name]()def use_event(func):
def call(*args, **kwargs):
generator = func(*args, **kwargs)
# 執行到掛起
event_name = next(generator)
# 將“喚醒掛起的協程”註冊到事件管理器中
def resume():
try:
next(generator)
except StopIteration:
pass
event_listeners[event_name] = resume
return call# [email protected]_eventdef test_work():
print("=" * 50)
print("waiting click")
yield "click" # 掛起當前協程, 等待事件
print("clicked !!")if __name__ == "__main__":
test_work()
sleep(3) # 做了很多其他事情
fire_event("click") # 觸發了 click 事件
測試回合可以看到,列印出“waiting click”之後,暫停了三秒,也就是協程被掛起,控制權回到主控制流程上,之後觸發“click”事件,協程被喚醒。協程的這種“掛起”和“喚醒”機制實質上是將一個過程切分成了若干個子過程,給了我們一種以扁平的方式來使用事件回調模型。
用 greenlet 實現簡單事件架構
用產生器實現的協程有些繁瑣,同時產生器本身也不是完整的協程實現,因此經常有人批評 Python 的協程比 Lua 弱。其實 Python 中只要放下產生器,使用第三方庫 greenlet,就可以媲美 Lua 的原生協程了。greenlet 提供了在協程中直接切換控制權的方式,比產生器更加靈活、簡潔。
基於把協程看成“切開了的回調”的視角,我使用 greenlet 製作了一個簡單的事件架構。
from greenlet import greenlet, getcurrentclass Event(object):
def __init__(self, name):
self.name = name
self.listeners = set()
def listen(self, listener):
self.listeners.add(listener)
def fire(self):
for listener in self.listeners:
listener()class EventManager(object):
def __init__(self):
self.events = {}
def register(self, name):
self.events[name] = Event(name)
def fire(self, name):
self.events[name].fire()
def await(self, event_name):
self.events[event_name].listen(getcurrent().switch)
getcurrent().parent.switch()
def use(self, func):
return greenlet(func).switch
使用這個事件架構,可以很容易的完成掛起過程 -> 轉移控制權 -> 事件觸發 -> 喚醒過程的步驟。還是上文產生器協程中使用的例子,用基於 greenlet 的事件架構實現出來是這樣的:
from time import sleepfrom event import EventManagerevent = EventManager()event.register("click")@event.usedef test(name):
print "=" * 50
print "%s waiting click" % name
event.await("click")
print "clicked !!"if __name__ == "__main__":
test("micro-thread")
print "do many other works..."
sleep(3) # do many other works
print "done... now trigger click event."
manager.fire("click")
同樣,運行結果如下:
==================================================
micro-thread waiting click
do many other works...
done... now trigger click event.
clicked !!
在“do may other works”列印出來之後,控制權從協程切出,暫停了三秒,直到事件 click 被觸發才重新切入協程中。
非 Python 領域,有一個叫 Jscex [4] 的庫在沒有協程的 ECMAScript 中實現了類似協程的功能,並以之控制事件。
總結
總的來說,我個人感覺協程給了我們一種更加輕量的非同步編程方式。在這種方式中沒有調度複雜的系統級線程,沒有容易出錯的臨界資源,反而走了一條更加透明的路 —— 顯式的切換控制權代替調度器充滿“猜測”的調度演算法,放棄進程內並發使用清晰明了的串列方式。結合多進程,我想協程在非同步編程尤其是 Python 非同步編程中的應用將會越來越廣
python 高效能編程之協程