協程的好處不用再多說,作為與函數調用/返回相對的概念,它使我們思考問題的方式經歷一場變革。現在我們關注的是C,由於C本身的特質,將協程引入其中將會是一 個挑戰。無數先驅已經為這個目標拋了頭顱灑了熱血,於是我們有了libtask之類。而這裡提到的,是一個堪稱最輕量級的協程實現:Protothreads(主頁:http://www.sics.se/~adam/pt/)。所謂最輕量級,就是說,功能已經不能再精簡了,幾乎就是原語級別的。——確實,這種最簡帶來了一些使用上的繁瑣不便,但在打退堂鼓之前,先來看看它的優點吧:
不依賴任何庫(包括C標准庫和OS,是的,可以在bootloader裡使用它),甚至本身都算不上個“庫”,事實上整個實現都只有.h文件。
充上一條,.h文件共也只有5個而已,總共的有效行數也就100數量級(版本1.4)。
接著補充,那些行中大部分也都是宏定義,所以使用該庫導致程序的膨脹基本可以忽略不計。
每個協程的內存開銷只有一個指針那麼大。
說實話,這種形式的所謂“庫”的最佳使用方式,是去參考其源代碼然後直接借鑒到自己的程序中。這麼點代碼就能實現協程的功能,其原理也就一層窗戶紙。事實上Protothreads使用了兩種方式來實現協程,你可以選擇其中一種方式:
用switch語句來實現。
用GCC擴展語法來實現。
前者通用性好但低效,使用起來也有更多不便,後者相反。默認是前者,本人傾向於後者(後者MinGW也支持的),這歸咎於用慣了GCC,而且後者從思想上確實更加簡明,沒有trick的意味。這裡的原理敘述也以後者為主。
這個如洪水猛獸般的“擴展語法”,其實就是:可以把label地址保存到變量。label就是goto的那個label,就是那個人人喊打的goto。如下:
begin:
printf("This is a message\n");
/* goto begin; -- 我們本來應該這麼用 */
void *p = &&begin;
goto *p;
&&不是取地址又取地址^_^而是擴展語法,這個運算符用於label,表示取其在代碼段中的地址,就是說獲得一個指針。指向代碼段的指針,第一反應是函數指針,但這個不是,因為它並不指向一個函數的入口,而是指向其腹部。這種指針類型C中是不存在的,GCC也不想把事情搞大,整出個新數據類型來,於是用void *通吃了。這樣這個值就可以當普通數據一樣擺弄來擺弄去,最後靠goto *p,來從其他任何地方跳到這個地址來執行。
或許還記得,C的goto是不能跨越函數邊界的,從理論角度這叫確保了單入單出的結構化編程,從底層實現角度,則保證了棧幀不混亂,即:如果goto到另一個函數的代碼段中,但另一個函數的棧幀並沒有准備好,棧頂還是當前函數的棧幀,那麼目的函數在訪問局部數據時候就會發生混亂。這種原來不可能發生的混亂,在這種擴展語法的支持下成為了可能。這是需要注意的一點,在使用擴展的goto語句的時候也要注意不要越過函數邊界(當然,如果你BT到了解棧幀協議並試圖手工建立棧幀的話,就當我沒說^_^)。
Protothreads庫對協程的實現,說來也簡單,且看一個協程函數的示意:
int foo(struct pt *p) {
PT_BEGIN(p);
…… /* 代碼段1 */
PT_YIELD(p);
…… /* 代碼段2 */
PT_END(p);
}
這個函數,在每次重入這個協程的時候都要被調用,靠這些PT_開頭的宏,函數可以確定每次被調用時應該執行函數體的哪一部分。比如調用兩次foo的話,第一次會執行代碼段1,第二次則執行代碼段2。原理如下:
結構體struct pt其實只有一個void *型成員,就是傳說中那“一個指針的開銷”,每個協程都有個對應的此物。該指針在初始化的時候被置NULL(由另一個宏PT_INIT在別處完成),在foo函數中,PT_BEGIN會檢查這個指針,若是NULL,則表明是第一次啟動該協程,什麼也不做。接下來遇到了PT_YIELD,即協程掛起原語。此宏內部定義一個label,並立即將該label保存進pt結構體中。這樣,此處可能有多種方式進入,一是順序執行到此,二是從別處goto過來。這所謂別處,其實就在PT_BEGIN。如果它檢查到pt不為空,則立即goto過去。現在PT_YIELD根據到達此處的方式做進一步判斷,如果是自然執行到此,該掛起了,則立即reeturn出函數。否則,則是剛剛重入回來,繼續執行下邊的代碼段2。這個判斷是如何進行的?——靠一個標志位,PT_BEGIN每次被調用都首先置一個標志,而PT_YIELD則在label之前清除這個標志。這樣,在label之後,PT_YIELD就可以據此判斷,若標志沒了,則是自然執行到此,若標志存在,則是從PT_BEGIN處goto過來的。——說穿了,就是setjmp的一個超輕量級版。