python 高效能編程之協程

來源:互聯網
上載者:User

標籤:

用 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 高效能編程之協程

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.