淺談coroutine與gevent

這篇文章是要大略介紹一下coroutine和Python的相關應用的函式庫gevent,在介紹coroutine前我們先來點情境,因為目前常見的coroutine應用都是在網路程式上,因此我們得先建立一些網路框架的模形再介紹coroutine會比較容易懂

不同的網路框架模型

網路隨著時代發展,已經成為現代生活中越來越重要的重要的基礎,而做為提供這些服務的伺服器,負載的連線也越來越多,因此網路程式的軟體、硬體架構也一直在演變,才有辦法承載越來越多的連線數量,有人希望在一台機器能夠同時處理1萬個連線以上,所以提出C10K問題,並且之後有不少新的的技術來達成這個目標,而在此我們只專注在於軟體的架構上,首先介紹最簡單的架構

阻塞式單一行程

這樣的網路程式非常的簡單,只有一個迴圈,單一個行程,處理完一個要求後才繼續處理下一個,理所當然這樣的效能非常差,因為連線在完成前其它的連線都無法被處理,現代的伺服器已經很少看見這樣的架構,但因為優點是簡單,如果沒有什麼大量同時連線要處理,其實這樣的架構就很足夠

阻塞式多行程

因為既然單一行程只能同時處理一個請求,那很簡單的想法就是每個請求開一個行程去處理,如此一來就能同時處理更多的請求,但是這樣做有缺點,行程的copy如果是fork的話,有os paging system在成本上其實還好,但是還是有,而且越多的連線就表示需要越多的context-switch,當連線量多到一定程度時,可能大部份的CPU時間都在忙著進行context-switch,如此一來這樣的架構在此情況下是沒有效率的,但是優點是寫伺服器的部份事實上和寫單一行程阻塞式不會差太多,一樣簡單好寫

阻塞式多行程多執行緒

除了多行程多阻塞式,有些程式為了減少process copy的成本,或是其它的考量,會在多個行程上開多個執行緒來處理請求,也有可能是單行程多執行緒,但是基本上和上面這幾種都沒有太大的差別,而引進了執行緒帶來了一些額外的問題,dead lock、race condition等等,當不同的執行緒如果在一起有共同的東西要處理,這些常見的同作問題就會出現,使得程式得寫得更小心

非阻塞式事件驅動

為了解決上面提到多行程和多執行緒等所帶來的問題,有一種做法是只有單一主要的迴圈來檢查有無網路IO的事件發生,再來決定要怎樣處理,這樣的好處在於省掉了context-switch、process copy等等成本,也不會有dead lock、race condition等問題,但缺點在於程式的部份會變複雜,因為當你一件事件被觸發,有事情還沒做完,你就得記下目前狀態,再下次事件觸發時再依先前的狀態來決定接下來要做什麼,不像上面是線性的程式執行那樣直觀,Twisted就是這樣的網路框架

非阻塞式Coroutine

那你或許會想,有沒有可能我們能有事件驅動的好處,和阻塞式那樣的直觀好處呢? 答案或許就是Coroutine,基本上它的本質也是事件驅動,只有單一的迴圈在檢查事件的發生,但是加上了coroutine的概念,而Gevent就是這樣的函式庫

Coroutine

講了這麼多次coroutine,我相信大部份的讀者可能還是不懂這到底是什麼鬼東西,對於大部份程式設計師而言這應該都算是較陌生的名字,對我而言,在一開始這也是個令人困惑的名詞,但事實上只要理解以後就會發現coroutine不是這麼的難懂,用簡單的一句話來說Coroutine,就是可以暫時中斷,之後再繼續執行的程序,我們來看一個例子,事實上Python就有最基礎的Coroutine,也就是generator

# -*- coding: utf8 -*-
def foo():
    for i in range(10):
        # 丟資料並且把主控權交給呼叫者
        yield i
        print u'foo: 主控又回到我手上了,打我阿笨蛋'

bar = foo()
# 執行coroutine
print bar.next()
print u'main: 現在主控權在我們手上,做點雜事'
print 'main:hello baby!'
# 回到剛才foo這個coroutine中斷的地方繼續執行
print bar.next()
print bar.next()

執行結果:

0
main: 現在主控權在我們手上,做點雜事
main:hello baby!
foo: 主控又回到我手上了,打我阿笨蛋
1
foo: 主控又回到我手上了,打我阿笨蛋
2

看到了嗎? 我們的foo在執行的過程中被中斷又繼續了好幾次,這就是coroutine,你可能覺得這樣一點用都沒有,我在一開始也這樣想,thread的context-switch也是可以暫停然後再繼續執行,所以這樣的特性好處在哪裡? 有幾個重點在於

  • thread之間需要context-switch,而且成本很高,但是coroutine之間的切換很快
  • coroutine的成本很低,可以很輕易的產生大量的coroutine
  • 這些事情全是在同一個thread裡發生的,因此不會有race condition等問題發生 (還是可能會有)
  • thread的context-switch雖然我們可以進行某種程度的控制,但是很多部份還是得靠OS來決定要先排程哪個thread,而coroutine的執行是由我們自己控制的

接著我們用圖來說明coroutine和thread之間的差別

你可以發現,Coroutine所做的其實就只是在單一thread裡面不同的coroutine裡互相切換,本質上和thread很像,所以也有些coroutine叫做micro-thread,而切換通常都是我們主動去做的,看到下面這張,當你建立了不同的thread,context-switch未必是在你預期的情況下發生的,而且通常都是由OS引發的,除此之外,如果你老爸夠有錢,買了最新的Intel iCore 2000的一千核心CPU,那麼恭喜你,你的thread可能是由不同的processor執行,因此不同thread的程式真正的同時執行是有可能的

等等! 這時候你可能抓起你手邊的OS恐龍本敲你自己的頭,你這樣問道: 嘿! 恐龍本上面有提到FCFS、RR等等排程演算法,那Coroutine呢? 答案是如同我們前面提到,這由我們自己決定,這是好處之一,這時你可能又會抓著你的頭髮大叫 “網路呢? 網路呢? 講了半天我只看到Coroutine之間切來切去,我根本看不到在這裡面的半點網路成份,哪怕是一公克也好”

確實,當我一開始在讀相關資料時也很困惑,ˊ這樣跳來跳去又如何? 網路應用到底在哪裡? 現在就回到我們的主題來

少了非同步IO的Coroutine,就像少了哇沙必的生魚片

到目前為止都很難和網路有什麼相關的聯想,但是想到網路就會想到IO,而事實上這樣的優點就是在於有大量IO時會顯得特別好用,我們考慮一下當遇到IO時就將主控權交給別人

Gevent的運作方式基本上就很類似這張圖,它的Coroutine是由greenlet實作的,而每個Coroutine都有一個parent,最頂層的Coroutine就是main thread或是當前的thread,每當Coroutine遇到IO的時候,就將主控權交給root coroutine,它會視哪些coroutine的IO event是已完成的,就將主控權交給他,其實就只是這樣而已,事實上程式寫起來完全和一般的阻塞式伺服器沒什麼兩樣,但是它千真萬確是非同步的,這就是它神奇的地方,我們來看點實際的例子,我們直接拿gevent範例裡的同步下載程式

#!/usr/bin/python
# Copyright (c) 2009 Denis Bilenko. See LICENSE for details.

"""Spawn multiple workers and wait for them to complete"""

urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org']

import gevent
from gevent import monkey

# patches stdlib (including socket and ssl modules) to cooperate with other greenlets
monkey.patch_all()

import urllib2

def print_head(url):
    print 'Starting %s' % url
    data = urllib2.urlopen(url).read()
    print '%s: %s bytes: %r' % (url, len(data), data[:50])

jobs = [gevent.spawn(print_head, url) for url in urls]

gevent.joinall(jobs)

這程式太簡單了對吧? 但它確實能做到極高效能的同步下載,我們做一點簡單的解釋,首先令人困惑的是monkey.patch_all(),會有這行,是因為Python內建的各種函式庫裡的IO函式庫、及會阻塞住的函數,例如Sleep都會讓整個程式卡住,而不是利用Selector/epoll之類的功能來處理,所以monkey這個函式庫就是負責將Python內建的函式庫取代成以gevent的非同步形式的函式,如此一來當執行到那些IO之類的動作,會切到MainThread的coroutine進行排程,而非直接卡在那裡等結果,而當IO動作真的完成了,gevent內部會將該coroutine標示為可執行的,因此下次有機會就會排到那個coroutine,看到下面的spawn,就是在產生coroutine,在這裡的coroutine因為事實上是greenlet這個Python函式庫題供的,所以事實上叫做greenlet

你可以在腦中想像一下,spawn首先呼產生了三個執行print_head的routine ,在joinall的地方,把主控權交給第一個print_head,而在函數裡遇到了urllib2.urlopen這個IO動作,因此它將自己設為等待狀態,並且將主控權交還給MainThread,而MainThread將主控權排給了第二個print_head,同樣的也遇到了urllib IO動作,第三個也是一樣,而這三個都在等待,主控權便再次回到了MainThread,它等待有哪個gevent的IO事件完成了,就將主控權交給它,如此一來,重覆這樣的過程,三個網頁的同步下載就在coroutine的切換之間完成了,當三函數return,joinall也會結束,整個程式就跑完了

一開始會很難理解,但一但弄清楚之後就會瞭解這樣寫法的簡單和實用性,你可以忘記它背後的原理,把它當作是一般的阻塞式網路程式來寫,就會很輕鬆,但又輕量、高效能,以上大概就談到這裡,有興趣可以玩玩看gevent

This entry was posted in Python, 中文文章 and tagged , , , . Bookmark the permalink.

19 Responses to 淺談coroutine與gevent

  1. netsnail says:

    如果讓系統基於io去切換Coroutine, 程式仍然會和阻塞式多行程多執行緒一樣遭遇到dead lock和race condition的。

    比起在gevent,我跟喜歡stackless python(能用Erlang更好)多些, 遵循規範,用channel去替代shared memory, 編寫程式還是很簡單的。

  2. victor says:

    @netsnail

    嗯… 我仔細想了一下似乎確實也會有那樣的情況,考慮一下

    number = self.map[123] + 100
    print number
    self.map[123] = number

    如果print這裡切到其它Coroutine剛好也修改self.map[123]的值,這樣或許也是race condition的一種

    只是,比起thread在之間都有可能無預警的context-switch,race condition和dead lock會比較難掌控

    至於dead lock我看到的文似乎有提到說他們的經驗來說dead lock很少,幾乎沒有

    Stackless我也有研究一下子,我知道greenlet是基於函式庫的方式去實行coroutine的,而Stackless是從直譯器下手,所以Stackless效能會比較好,而greenlet好處在於不用換直譯器,除此之外的差別是在哪裡? @@?

    至於Erlang,我有試著玩過,不過functional language的思考方式不太習慣,還玩不太上手

  3. Pingback: Some reminiscences, some memories » Blog Archive » Web编程异步模型的 Gearman 实现(残)

  4. lipingtababa says:

    好文章啊,我终于明白python的yield的用处了.能保存状态供下次使用 🙂
    之前看那些用yield做循环的例子,总觉得这是一个多余的key word

  5. Jacky says:

    so, the key point is, does the “main_thread” know that the tasklet is halted on IO?

  6. victor says:

    @Jacky

    Indeed, the main thread doesn’t know which tasklets are blocked for IO event. The back-end of gevent is libevent, there is a greenlet named Hub which runs the event loop. You can find the Hub.run here:

    http://bitbucket.org/denis/gevent/src/tip/gevent/hub.py

    Every IO function in gevent, use some methods like core.read_event to register a callback and switch back to the hub by get_hub().switch(), namely, fall back to the event iteration. When the IO event is done, then switch back to original tasklet and continue its task. It works just like event-driven network model, but with additional coroutine feature.

    It is possible to mark greenlet as halted, but it is not necessary, libevent knows nothing about coroutine. But however, you can implement your scheduling model if you like to 🙂

  7. GuoJing says:

    好文章,这个不错,醍醐灌顶啊。

  8. Jun Wang says:

    看完樓主寫的博客之後,虎軀一震,菊花一緊,很快理解了。。太感謝了。

  9. Pingback: [Python] quick survey of asynchronous frameworks « 軟件開發原始人的進化史

  10. Pingback: Coroutine/Generator/Iterator « Abner’s Postgraduate Days

  11. hookits says:

    pep 380完成了,python原生的coroutine。

    改天介紹一下啊

  12. Pingback: geniux's study » Coroutine(协程) 介绍

  13. 听临 says:

    看了你的博客觉得好好玩啊, 原来技术博客也可以写得那么搞笑…

  14. LOuis says:

    相当不错,一下子明白鸟

  15. Pingback: Taipei.py 三月聚會議題 Scrapy & Bottle

  16. reddevil says:

    每當Coroutine遇到IO的時候,就將主控權交給root coroutine,它會視哪些coroutine的IO event是已完成的,就將主控權交給他
    这句话当中root coroutine是如何知道哪个coroutineIO event已完成了呢?

  17. Pingback: Python Gevent应用

  18. Pingback: 动通信息技术团队博客 » 一些python