【轉】C語言的協程

原文地址http://www.yeolar.com/note/2013/02/17/coroutines/


一個例子

先給大家看一下這段代碼:

#include <stdio.h>

typedef struct
{
    int i;
    int num;
    int state;
} task;

#define crBegin(state) \
        switch (state) { case 0:

#define crReturn(state, ret) \
        (state) = __LINE__; return (ret); case __LINE__:

#define crEnd() \
        }

int cb(task *t)
{
    crBegin(t->state);
    for (;;) {
        t->num = 1;
        for (t->i = 0; t->i < 20; t->i++) {
            crReturn(t->state, t->num);
            t->num += 1;
        }
    }
    crEnd();
}

int main()
{
    task t;
    int i;

    t.state = 0;

    for (i = 0; i < 100; i++) {
        printf("%d ", cb(&t));
    }
    return 0;
}

它會輸出5組1 ~ 20的數字。是不是有點暈,我們把宏展開再看一下 cb 函數。爲了便於閱讀,做了一些調整:

int cb(task *t)
{
    switch (t->state) {
    case 0:
        for (;;) {
            t->num = 1;
            for (t->i = 0; t->i < 20; t->i++) {
                t->state = __LINE__ + 2;
                return t->num;
    case __LINE__:
                t->num += 1;
            }
        }
    }
}

看清楚了嗎?這其實是 switch 的一個技巧,通過它實現了一種斷點的效果,類似於Python的 yield 。它是一個非常簡單的C的協程實現。

協程的概念

我們再看看 Wiki上對協程的解釋 :

與子例程一樣,協程也是一種程序組件。相對子例程而言,協程更爲一般和靈活,但在實踐中使用沒有子例程那樣廣泛。協程源自 Simula 和 Modula-2 語言,但也有其他語言支持。協程更適合於用來實現彼此熟悉的程序組件,如合作式多任務,迭代器,無限列表和管道。

因爲相對於子例程協程可以有多個入口和出口點,可以用協程來實現任何的子例程。事實上,正如 Knuth 所說:“子例程是協程的特例。”

每當子例程被調用時,執行從被調用子例程的起始處開始;然而,接下來的每次協程被調用時,從協程返回(或屈服)的位置接着執行。

因爲子例程只返回一次,要返回多個值就要通過集合的形式。這在有些語言,如 Forth, 裏很方便,而其他語言,如 C ,只允許單一的返回值所以就需要引用一個集合。相反地,因爲協程可以返回多次,返回多個值只需要在後繼的協程調用中返回附加的值即可。在後繼調用中返回附加值的協程常被稱爲產生器。

子例程容易實現於堆棧之上,因爲子例程將調用的其他子例程作爲下級。相反地,協程對等地調用其他協程,最好的實現是用 continuations(由有垃圾回收的堆實現)以跟蹤控制流程。

在當今的主流編程環境裏,線程是協程的合適的替代者,線程提供了用來管理“同時”執行的代碼段實時交互的功能。因爲要解決大量困難的問題,線程包括了許多強大和複雜的功能並導致了困難的學習曲線。當需要的只是一個協程時,使用線程就過於技巧了。然而——不像其他的替代者——在支持 C 的環境中,線程也是廣泛有效的,對很多程序員也比較熟悉,並被很好地實現,文檔化和支持。

怎麼樣,現在是不是明白協程是怎麼回事了?

C語言的協程

關於C語言的協程編程,PuTTY的作者Simon Tatham寫有一篇很棒的 文章 ,我們的一位同行張傑在看PuTTY代碼時看到了作者對協程的使用,並給文章做了一份 翻譯 ,在這裏把譯文給出(對照原文做了一些修改),同時在此表達對兩位敬意。


介紹

設計大型程序一直都是件困難的事情,其中常常會遇到的一個難題是:一段生成數據的代碼和一段處理這些數據的代碼,讓哪一個做調用函數,哪一個做被調函數?

下面是用於遊程編碼的解碼器代碼和解析器代碼,都很簡單:

/* Decompression code */
while (1) {
    c = getchar();
    if (c == EOF)
        break;
    if (c == 0xFF) {
        len = getchar();
        c = getchar();
        while (len--)
            emit(c);
    } else
        emit(c);
}
emit(EOF);
/* Parser code */
while (1) {
    c = getchar();
    if (c == EOF)
        break;
    if (isalpha(c)) {
        do {
            add_to_token(c);
            c = getchar();
        } while (isalpha(c));
        got_token(WORD);
    }
    add_to_token(c);
    got_token(PUNCT);
}

上面兩段代碼都很簡單,易讀易懂。第一段每次調用 emit() 生成一個字符;第二段每次調用 getchar() 消費一個字符。只要能想辦法在調用 emit() 和 getchar() 時能讓二者互相傳數據,那麼就能很容易地將這兩段代碼連到一起,從而將解碼器的輸出直接送進解析器。

很多現代操作系統中,在兩個進程或線程之間使用管道就可以實現。解碼器中的 emit() 向管道中寫入數據,解析器的 getchar() 在管道的另一端讀出數據,簡單可靠,但是卻不輕量也不易移植。而且也不值得爲這麼簡單的任務將程序拆分成幾個線程。

本文將給出一個極具創造性的方法來解決這種結構問題。

重寫

傳統的方法是改寫管道任意一端的代碼,使其能夠被調用。下面給出瞭如何對上面兩段代碼進行改寫的例子。

int decompressor(void) {
    static int repchar;
    static int replen;
    if (replen > 0) {
        replen--;
        return repchar;
    }
    c = getchar();
    if (c == EOF)
        return EOF;
    if (c == 0xFF) {
        replen = getchar();
        repchar = getchar();
        replen--;
        return repchar;
    } else
        return c;
}
void parser(int c) {
    static enum {
        START, IN_WORD
    } state;
    switch (state) {
        case IN_WORD:
        if (isalpha(c)) {
            add_to_token(c);
            return;
        }
        got_token(WORD);
        state = START;
        /* fall through */

        case START:
        add_to_token(c);
        if (isalpha(c))
            state = IN_WORD;
        else
            got_token(PUNCT);
        break;
    }
}

當然了,你不必將兩段程序都進行改寫,只要改一段就行了。如果你將解碼器改成上面那樣,它每次調用會返回一個字符,那麼只要將解析器中對 getchar() 的調用替換成對 decompressor() 的調用,就好了。相反地,如果你像上面那樣改寫了解析器,對每個輸入字符調用一次,那麼就可以將解碼器中調用的 emit() 換成 parser() 。別把兩個函數都改寫成被調函數,除非你是想受罪。

其實這裏已經暴露出一個問題。這兩個改寫過的函數都比原來的要難看。把這兩個過程寫成調用函數要比被調函數更易讀。如果單單通過看代碼來推斷解析器所要解析的語法或者解碼器所要處理的壓縮格式,那麼原來的兩段代碼都比改寫後的代碼要清晰些。所以我們最好是能不改寫任何一段代碼。

Knuth的協程

在《計算機程序設計藝術》一書中,Donald Knuth給出了一個解決這類問題的方法。他的方法是徹底拋棄棧的概念。別再考慮要有一個調用函數和一個被調函數,試着將它們想象成平等的,相互合作的。

用專業術語來說就是:將傳統的“調用”原語用個稍微不同的“調用”來代替。這個新的“調用”能夠將返回值保存在其它地方,而不是棧上,並且還能夠跳轉到由另一個已保存的返回值所指定的地址上。這樣,解碼器每次生成一個新的字符,就保存自己的程序計數器並跳轉到上次離開解析器時的地址上;而對解析器來說,它每次需要一個新字符時,它保存自己的程序計數器並跳轉到上一次離開解碼器的地址上。程序可以這樣在兩個函數之間切換所需要的次數。

理論上,這樣是不錯。但實際中,我們卻只能用匯編語言這麼作,因爲通用的高級語言沒有一個支持協程調用原語。類似於C這樣的語言全都要依賴於基於棧的結構,因此在函數間傳遞控制時,必須要有一個調用函數和一個被調函數。所以如果你想寫可移植的代碼,這個技術和使用Unix管道的方法一樣不可行。

基於棧的協程

我們真正要的是找到C語言中能摹仿Knuth的協程調用原語的能力。我們必須接受這個現實:在C語言中,必須要有調用函數和被調函數。調用函數對我們來說沒有任何問題,我們完全按照原算法寫代碼就行,無論什麼時候,如果它生成(或者需要)一個字符,那就調用另外一個函數。

問題是出在被調函數上。對於被調函數,我們希望有一個“能從返回處再繼續”的函數,也就是說從這個函數返回後,當再次調用它時,能從上次返回語句之後的位置繼續運行。例如,我們希望能寫出這樣一個函數:

int function(void) {
    int i;
    for (i = 0; i < 10; i++)
        return i;   /* won't work, but wouldn't it be nice */
}

連續對它調用10次,它能分別返回0到9.

我們該怎樣實現呢?其實我們可以利用 goto 語句跳轉到函數中的任意一點。如果我們在函數中加入一個狀態變量,我們就可以這樣實現:

int function(void) {
    static int i, state = 0;
    switch (state) {
        case 0: goto LABEL0;
        case 1: goto LABEL1;
    }
    LABEL0: /* start of function */
    for (i = 0; i < 10; i++) {
        state = 1; /* so we will come back to LABEL1 */
        return i;
        LABEL1:; /* resume control straight after the return */
    }
}

這個方法是可行的。我們在所有可能需要恢復執行的位置都加上標籤:起始位置加一個,還有所有 return 語句之後都加一個。我們在狀態變量中保存每次調用這個函數時的狀態,這樣它就能在我們下次調用時告訴我們應該跳到哪個標籤上。每次返回前,更新狀態變量,指向到正確的標籤;不論調用多少次,針對狀態變量的 switch 語句都能找到我們要跳轉到的位置。

但這還是難看得很。最糟糕的部分是所有的標籤都需要手工維護,還必須保證函數中的標籤和開頭 switch 語句中的一致。每次新增一個 return 語句,就必須想一個新的標籤名並將其加到 switch 語句中;每次刪除 return 語句時,同樣也必須刪除對應的標籤。這使得維護代碼的工作量增加了一倍。

Duff的裝置

C語言中著名的Duff裝置利用了一個事實: switch 的 case 語句,即使放在 switch 的一個子塊中,仍然是合法的。Tom Duff利用這一點給出了一個優化的輸出循環:

switch (count % 8) {
    case 0:        do {  *to = *from++;
    case 7:              *to = *from++;
    case 6:              *to = *from++;
    case 5:              *to = *from++;
    case 4:              *to = *from++;
    case 3:              *to = *from++;
    case 2:              *to = *from++;
    case 1:              *to = *from++;
                   } while ((count -= 8) > 0);
}

我們可以把這個技巧用在協程上。不用 switch 語句來決定要跳轉到哪裏去執行,而是直接利用 switch 語句本身來實現跳轉:

int function(void) {
    static int i, state = 0;
    switch (state) {
        case 0: /* start of function */
        for (i = 0; i < 10; i++) {
            state = 1; /* so we will come back to "case 1" */
            return i;
            case 1:; /* resume control straight after the return */
        }
    }
}

現在看到希望了。我們要做的事情就是精心構造抽取出幾個宏來,將這種看起來不倫不類的實現用一些看起來更結構化的語句隱藏起來:

#define crBegin static int state=0; switch(state) { case 0:
#define crReturn(i,x) do { state=i; return x; case i:; } while (0)
#define crFinish }
int function(void) {
    static int i;
    crBegin;
    for (i = 0; i < 10; i++)
        crReturn(1, i);
    crFinish;
}

(注意:使用 do ... while(0) 是爲了確保 crReturn 出現在 if ... else 之間時不需要使用大括號將它包裹起來)

好了,這基本上就是我們想要的了。我們可以使用 crReturn 將函數返回,並能夠在下次調用時從返回的位置之後繼續執行。當然,我們必須要遵守幾條規則(函數體要嵌在 crBegin 和 crFinish 之間;所有需要跨 crReturn 使用的局部變量都要定義成 static 變量;不能把 crReturn 放在其他 switch 語句中);不過這幾條規則對我們並沒有太大限制。

現在就剩下一個問題了,就是 crReturn 的第一個參數。就像在上一節中我們每增加一個新的標籤都要考慮防止標籤名重複一樣,現在我們必須保證 crReturn 的第一個參數不能相同。其實就算重名的話,後果也不嚴重 —— 編譯器能夠發現這個錯誤,因此不會在運行時導致嚴重後果 —— 但我們還是要避免這個問題。

這個問題是能夠解決的。ANSI C提供了 __LINE__ 宏,這個宏展開後是當前代碼行的行號,接下來我們就修改一下crReturn :

#define crReturn(x) do { state=__LINE__; return x; \
                         case __LINE__:; } while (0)

現在我們就不用再擔心那些關於狀態參數了,只是增加了第四條規則(不要將2個 crReturn 語句放在同一行)。

評估

現在我們有了這個東西,就可以用它再來改寫一下原來那兩段代碼了。

int decompressor(void) {
    static int c, len;
    crBegin;
    while (1) {
        c = getchar();
        if (c == EOF)
            break;
        if (c == 0xFF) {
            len = getchar();
            c = getchar();
            while (len--)
                crReturn(c);
        } else
            crReturn(c);
    }
    crReturn(EOF);
    crFinish;
}
void parser(int c) {
    crBegin;
    while (1) {
        /* first char already in c */
        if (c == EOF)
            break;
        if (isalpha(c)) {
            do {
                add_to_token(c);
               crReturn( );
            } while (isalpha(c));
            got_token(WORD);
        }
        add_to_token(c);
        got_token(PUNCT);
        crReturn( );
    }
    crFinish;
}

我們已經把解碼器和解析器都改寫成了被調函數,它們不需要像上次那樣的大規模的重構工作了。每個函數的結構完全是原來算法的翻版。讀代碼的人能夠推出解析器所能識別的語法,也能推出解碼器所使用的壓縮格式,與讀那兩段使用狀態機來實現的代碼比起來,這要容易多了。一旦你適應了這種新的代碼形式,就會發現這兩段代碼的控制流程相當直觀:解碼器產生出一個字符時,它就調用 crReturn 將這個字符傳給調用函數,並等待當需要下一個字符時再次被調用;當解析器需要一個字符時,它就通過 crReturn 返回,並等待下一次被調用時通過參數 c 傳入新的字符。

不過,代碼中還是有一處小小的結構調整: getchar() (其實,在改寫的代碼中是 crReturn )放在了 parser() 的循環的結尾,而不是開頭,這是因爲在進入函數時,第一個字符已經通過 c 傳進來了。我們應該能接受這個結構上的小改動,要是你真的對此感覺強烈,可以這樣認爲:在開始向 parser() 送入數據之前,它需要一次初始化調用。

當然,和前面一樣,我們不必把兩個函數都用協程的宏改寫。改一個就足夠了,另一個可以作爲它的調用函數。

我們已經實現了文章開始時的目標:一個可移植的ANSI C方法,解決數據在生產者和消費者之間的傳遞問題,而不必把代碼用狀態機重寫。我們用 switch 語句的一個很少用到的特性,結合C語言的預處理器,構造出一個隱式的狀態機,進而實現了我們的目標。

編程規範

顯然,這個技巧跟每本編程規範書中的內容都相悖。如果你在公司的代碼中這樣用,你很有可能會受到指責,並因爲這種不遵守紀律的行爲而被警告:你的宏定義中大括號沒有匹配完整,在子代碼塊中包含了未用到的 case ,還有crReturn 中亂七八糟的不完整的內容... 寫出這樣不負責任的代碼,不炒了你纔怪。你應該爲此感到羞愧。

我要聲明將編程規範用在這裏是不對的。文章裏給出的示例代碼不是很長,也不很複雜,即便以狀態機的方式改寫還是能夠看懂的。但是隨着代碼越來越長,改寫的難度將越來越大,改寫對直觀性造成的損失也變得相當相當大。

想一想,一個函數如果包含這樣的小代碼塊:

case STATE1:
/* perform some activity */
if (condition) state = STATE2; else state = STATE3;

對於看代碼的人說,這和包含下面小代碼塊的函數沒有多大區別

LABEL1:
/* perform some activity */
if (condition) goto LABEL2; else goto LABEL3;

一個是調用函數,另一個是被調函數。是的,這兩個函數的結構在視覺上是一樣的,而對於函數中實現的算法,兩個函數都一樣不利於查看。因爲你使用協程的宏而炒你魷魚的人,一樣會因爲你寫的函數是由小塊的代碼和 goto 語句組成而吼着炒了你。只是這次他們沒有冤枉你,因爲像那樣設計的函數會嚴重擾亂算法的結構。

編程規範的目標就是爲了代碼清晰。如果將一些重要的東西,象 switch 、 return 以及 case 語句,隱藏到起“障眼”作用的宏中,從編程規範的角度講,可以說你擾亂了程序的語法結構,並且違背了代碼清晰這一要求。但是我們這樣做是爲了突出程序的算法結構,而算法結構恰恰是看代碼的人更想了解的。

任何編程規範,如果非要犧牲算法清晰度來換取語法的清晰度,都應該進行修改。如果你的上司因爲使用了這一技巧而解僱你,那麼在保安把你往外拖的時候要不斷告訴他這一點。

提煉及編碼

在正規的應用程序中,這個協程的實現很少能用得上,因爲它用到了靜態變量,因此是不可重入的,也不能支持多線程。理想情況下,在一個實際的應用程序中,你會希望能在幾個不同的上下文中調用同一個函數,並且每次在某個上下文中調用這個函數時,都能在這個上下文中上一次返回的位置之後恢復執行。

這非常容易實現。我們給函數增加一個新的參數,一個指向上下文結構體的指針;我們將所有的局部變量還有協程用到的狀態變量都聲明成結構體中的元素。

這確實有點難看,因爲你突然不能用 i 作爲循環計數,而要用 ctx->i ;事實上,所有重要的變量都成了協程上下文結構體中的成員。但是,這解決了重入的問題,並且沒影響到函數的結構。

(當然,要是C語言支持Pascal語言的 with 語句,我們就可以將這個間接的引用隱藏掉。但是很遺憾。而對於C++語言,我們可以把協程的兩個函數設計成類的成員函數,所有的局部變量設計成類的成員變量,從而將作用域的問題隱藏掉。)

這裏引用的C語言頭文件實現了一套預定義的協程使用的宏。文件中定義了2套宏函數,前綴分別是 scr 和 ccr 。 scr宏是一套簡單的實現,用於可以使用靜態變量的情況; ccr 宏更高級一些,能支持重入。在頭文件的註釋中有完整的說明。

需要注意的是,VC++ 6並不喜歡這種協程技巧,因爲其默認的debug狀態(Program Database for Edit and Continue)對 __LINE__ 宏的支持有點兒怪。如果想用VC++ 6編譯一個使用了協程的宏,你就要關掉Edit and Continue。(在project settings中,選擇c/c++標籤,在General中,將Debug info改爲Program Database for Edit and Continue之外的其他值)。

(這個頭文件是MIT許可的,所以你可以任意使用,沒有任何限制。如果你發現MIT對你的使用方式有限制,可以給我發郵件,我會考慮給你授權。)

使用 這個鏈接 獲得coroutine.h。

感謝您的閱讀。共享並享受共享吧!

參考文獻

  • Donald Knuth, The Art of Computer Programming, Volume 1. Addison-Wesley, ISBN 0-201-89683-4. Section 1.4.2 描述了協程的最原始的形式。

  • http://www.lysator.liu.se/c/duffs-device.html 是Tom Duff自己對Duff裝置的討論。注意,文章的最後似乎暗示了Duff自己也發明了這種協程或者類似的技巧。

    2005-03-07更新: Tom Duff在一篇博客的評論中確認了 ,在他原來的郵件中寫到:"一種叛逆的使用 switch語句的方法來實現中斷驅動的狀態機",這實際上和我在這裏描述的方法是一樣的。

  • PuTTY 是一個Win32的Telnet和SSH的客戶端軟件。其中的SSH協議的實現就是使用協程技巧的一個示例。就我目前所知,這部分代碼是正規工程的代碼中,最難hack的C代碼。


結語

好了,Simon大叔的話說完了。大家鼓掌,太精彩了!當然,這個技巧的用途不廣,但是這篇文章非常詳細地解釋了協程的概念和實際場景,以及在C語言中的一些解決思路。

現在回過頭來看開始的那段代碼,你就完全明白了吧。(我終於把a.c清理出去了)

關於協程的話題就到此爲止,感興趣可以看看Wiki上列出的一些使用了協程概念的語言和庫。比如Erlang、Lua、Stackless Python。

 http://www.yeolar.com/note/2013/02/17/coroutines/


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章