自從 PyCon 2011 協程成爲熱點話題以來,我一直對此有着濃厚的興趣。爲了異步,咱們曾使用多線程編程。然而線程在有着 GIL 的 Python 中帶來的性能瓶頸和多線程編程的高出錯風險,「協程 + 多進程」的組合漸漸被認爲是將來發展的方向。技術容易更新,思惟轉變卻須要一個過渡。我以前在異步事件處理方面已經習慣了回調 + 多線程的思惟方式,轉換到協程還很是的不適應。這幾天我很是艱難地查閱了一些資料並思考,得出了一個可能並不可靠的總結。儘管這個總結的可靠性很值得懷疑,可是我仍是決定記錄下來,由於我以爲既然是學習者,就不該該怕無知。若是讀者發現個人見解有誤差並指出來,我將很是感激。jquery
線程的出現,爲開發者帶來了除多進程以外另外一種實現併發的方式。比起多進程,多線程有另外一些優點,好比能夠訪問進程內的變量,也就是共享資源。還有的說法說線程建立比進程建立開銷低,考慮到這個問題在 Windows 一類進程建立機制很蹩腳的系統才存在,故先忽略。總的來講,線程除了能夠實現進程實現的「併發執行」以外,還有另外一個功能,就是管理應用程序內部的「事件」。我不知道把這種事件處理分類到異步中是否是合適,但事件處理必定是基於共享進程內資源才能實現的,因此這是多線程能夠作到而多進程作不到的一點。ajax
異步處理基於兩個前提。第一個前提是支持併發,固然這是基本前提。這裏的併發並不必定要是並行,也就是說容許邏輯上異步,實現上串行;第二個前提是支持回調(callback),由於併發的、異步的處理不會阻塞當前正在被執行的流程,因此「任務完成後」要執行的步驟應該寫在回調中,絕大多數回調是經過函數來實現。算法
多線程之因此適合異步編程,是由於它同時支持併發和回調。不管是系統級的線程仍是用戶級的線程,邏輯上都能併發執行不一樣的控制流;同時由於能共享進程內資源,因此回調只須要經過簡單的回調函數。chrome
出於回調函數的處理比較雜亂,通常異步程序都引入了事件機制。也就是說把一系列的回調函數註冊到某個命名的事件,當這個事件被觸發的時候,執行這些回調函數。例如在 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 語言內置了協程的支持,也就是咱們通常用來製做迭代期的「生成器」(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# Test@use_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」事件,協程被喚醒。協程的這種「掛起」和「喚醒」機制實質上是將一個過程切分紅了若干個子過程,給了咱們一種以扁平的方式來使用事件回調模型。
用生成器實現的協程有些繁瑣,同時生成器自己也不是完整的協程實現,所以常常有人批評 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 異步編程中的應用將會愈來愈廣