協程

就一個簡單實現的語言來說,如果有併發需求,像之前說的直接使用宿主環境的線程,加上必要的調度控制即可實現需求,但如果要求比較高,觸發到上篇講的線程和單線程異步的相關缺陷,一個較普遍的解決辦法是採用用戶態併發,即對於os內核來說,進程只有一個或少數幾個線程,而對於源語言來說,接口和使用線程別無二致,由虛擬機實現對這些“線程”的調度,虛擬機的實現則可以一定程度簡化、優化調度算法和內存佔用,從而達到高併發高效率的目的。這個過程中一般使用到協程技術 

協程這個概念是1963年提出來的,最早的出發點並不是針對併發(那時候的os應該還沒有線程),而是另一種編程模式,相對子例程而言。子例程通俗說就是函數,現在我們寫程序,已經習慣了函數的調用是在棧中,有調用者和被調用者的區別,還有遞歸調用,而協程模型中,各協程雖然看上去是一個函數,但彼此之間是對等的,沒有誰調用誰的關係;子例程的調用由於是一個棧結構,則需要嚴格按照後進先出的方式來調用和返回,而協程的生命週期則是自由的,按照實際需要來結束(返回) 

如果查閱一些資料,可能會看到說另一個區別是,協程是一個多入口多出口的子例程,但這個入口和出口跟一般子例程還是有區別,一般子例程是“調用”和“返回”,而一個協程的出入更合適的說法是“切換”,舉個簡單的生產者-消費者的例子(代碼摘自wiki): 
var q := new queue 
生產者協程: 
loop 
    while q is not full 
        create some new items 
        add the items to q 
    yield to consume 
消費者協程: 
loop 
    while q is not empty 
        remove some items from q 
        use the items 
    yield to produce 

這裏的yield表示切換協程,生產者和消費者都是一個無限loop,生產者往隊列q裏面儘可能增加新任務,然後切換到消費者執行,消費者處理完q裏面所有任務後,切換回生產者執行,可以拿這個例子和函數調用、線程併發比較下: 

如果採用函數調用的方式,上述代碼直接改yield切換爲調用是行不通的,因爲函數調用必須有調用者和被調用者,如果produce裏面調用了consume,consume裏面又去調用produce,則無限遞歸,因此必須考慮實際情況,consume作爲produce的子例程,produce的yield改爲call,consume的yield改爲return,這樣形成一個從屬關係即可,畢竟是要先produce才能consume。函數調用和協程的另一個區別是,雖然每次函數調用都有自己的局部環境,但隨着return切換,局部環境會銷燬,而協程的局部環境是跟協程的生命週期走的,實際上上面這段代碼在一個不支持協程的語言裏面實現的時候,相當於建立兩個協程的數據環境,yield則是直接goto到對應代碼執行 

協程和線程很類似,事實上很多時候協程被認爲是用戶態調度的線程,這個說法勉強也算對,但由於協程出現早,如果咬文嚼字的話不應該用線程來描述協程,應該說線程是協程的替代品。考慮上面的例子,如果用線程實現就非常簡單了,比如yield改爲sleep一段時間,主動放棄cpu,os會幫我們在兩個線程切換,或者如果q是一個內核對象,則對q的出入操作可以由os來阻塞調度,這樣代碼就能進一步簡化了 

從這個角度說,協程在程序中的實現跟線程在os中的實現是很類似的,一個協程包含一個自己的數據環境(對應線程的棧環境),執行代碼(對應線程的入口函數),聲明週期(線程入口函數的調用和返回)。線程調度依賴os,協程則可以自己調度,但是,要實現自己調度需要一個非常自由的goto(線程調度實際也是切換上下文環境後直接jmp),而在基於函數調用模式的語言中實現協程,還是跟線程的os一樣使用一箇中央調度器。或許唯一的區別是,線程的標準調度是被硬件強行中斷的,而協程是自己交出控制權 

和函數,線程類比,則一個協程的“多入口多出口”也能很好的理解,比如如下協程代碼: 
func f(): 
    for i from 1 to 10: 
        return i 
如果f是個函數,則上面這段代碼就相當於return 1,但如果是協程,則依次調用f的時候,會返回1到10,也就是說每次return會返回給調用者當前的i,同時當前執行狀態也被保留,下次調用時從上次的狀態繼續,顯然這是一個狀態機,因此協程可以用一個對象實現(python代碼): 
class F: 
    def __init__(self): 
        self.i = 1 
    def __call__(self): 
        if self.i > 10: 
            raise CrEnded() 
        ret = self.i 
        self.i += 1 
        return ret 
f = F() 
while True: 
    try: 
        print f() 
    except CrEnded: 
        break 

f是一個狀態機,重載它的()運算(__call__),就能像上面一樣反覆調用了,協程最終結束後,再調用會拋出CrEnded異常,當然也可以有其他類型的表示結束的方法。python中的協程(generator生成器)實際就是類似這樣實現的 

而如果用線程來實現這個例子,由於線程是os調度的,要想f受控運行,需要通過通訊來控制: 
func f(): 
    for i from 1 to 10: 
        recv_from_queue() 
        send_to_queue(i) 
f在一個線程執行,通過一個queue和外界通訊,每recv到一個請求,就將i send回去。換句話說,線程的多入口多出口是通過通訊和阻塞實現的 

協程通過自己主動yield放棄執行來調度,一個協程本質就是一個自動機,雖然對一個自動機而言,整個流程是比較清晰的,但是如果業務比較複雜,手工寫自動機也是比較繁瑣的,比如說,我們要從若干數據庫和表中分別讀取一個用戶的各種信息,組成一個總信息結構,每個信息的讀取可能阻塞的,則需要多次yield,用協程: 
user = User(id) 
trans = get_info_a(user.id) //提交info a的請求 
yield trans 
user.info_a = trans.result 
... //其他info 
這裏爲了寫清楚一些,get_info_a提交請求後返回一個事務對象trans,然後將trans在切換時以“返回值”的形式返回,當結果回來時,外部控制代碼填寫trans.result,然後繼續執行。當然完全不必這麼麻煩,python直接用這種語法: 
user.info_a = yield get_info_a(user.id) 
提交請求並yield返回trans,yield本身是個表達式,其值爲繼續執行時傳入的參數 

這樣一來,就可以在協程很方便的寫同步代碼了,但是,外部代碼依然要自己實現爲異步的,僅僅是協程的自動機代碼寫起來緊湊而已,不至於像單線程異步那樣凌亂。而更嚴重的問題是,如果我要實現函數調用,比如一個函數a調用b,a的其他地方和b都可能阻塞,那麼改成協程的話,就不得不建立a和b兩個協程,它倆還是對等的,這樣寫代碼還是比較麻煩,至少不符合現在流行的線程+函數的習慣了
發佈了49 篇原創文章 · 獲贊 19 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章