一個(gè)“蠅量級(jí)”C語言協(xié)程庫
協(xié)程(coroutine)顧名思義就是“協(xié)作的例程”(co-operative routines)。跟具有操作系統(tǒng)概念的線程不一樣,協(xié)程是在用戶空間利用程序語言的語法語義就能實(shí)現(xiàn)邏輯上類似多任務(wù)的編程技巧。實(shí)際上協(xié)程的概念比線程還要早,按照 Knuth 的說法“子例程是協(xié)程的特例”,一個(gè)子例程就是一次子函數(shù)調(diào)用,那么實(shí)際上協(xié)程就是類 函數(shù)一樣的程序組件,你可以在一個(gè)線程里面輕松創(chuàng)建數(shù)十萬個(gè)協(xié)程,就像數(shù)十萬次函數(shù)調(diào)用一樣。只不過子例程只有一個(gè)調(diào)用入口起始點(diǎn),返回之后就結(jié)束了,而 協(xié)程入口既可以是起始點(diǎn),又可以從上一個(gè)返回點(diǎn)繼續(xù)執(zhí)行,也就是說協(xié)程之間可以通過 yield 方式轉(zhuǎn)移執(zhí)行權(quán),對(duì)稱(symmetric)、平級(jí)地調(diào)用對(duì)方,而不是像例程那樣上下級(jí)調(diào)用關(guān)系。當(dāng)然 Knuth 的“特例”指的是協(xié)程也可以模擬例程那樣實(shí)現(xiàn)上下級(jí)調(diào)用關(guān)系,這就叫非對(duì)稱協(xié)程(asymmetric coroutines)。
基于事件驅(qū)動(dòng)模型
我們舉一個(gè)例子來看看一種對(duì)稱協(xié)程調(diào)用場景,大家最熟悉的“生產(chǎn)者-消費(fèi)者”事件驅(qū)動(dòng)模型,一個(gè)協(xié)程負(fù)責(zé)生產(chǎn)產(chǎn)品并將它們加入隊(duì)列,另一個(gè)負(fù)責(zé)從隊(duì)列中取出產(chǎn)品并使用它。為了提高效率,你想一次增加或刪除多個(gè)產(chǎn)品。偽代碼可以是這樣的:
- # producer coroutine
- loop
- while queue is not full
- create some new items
- add the items to queue
- yield to consumer
- # consumer coroutine
- loop
- while queue is not empty
- remove some items from queue
- use the items
- yield to producer
大多數(shù)教材上拿這種模型作為多線程的例子,實(shí)際上多線程在此的應(yīng)用還是顯得有點(diǎn)“重量級(jí)”,由于缺乏 yield 語義,線程之間不得不使用同步機(jī)制來避免產(chǎn)生全局資源的竟態(tài),這就不可避免產(chǎn)生了休眠、調(diào)度、切換上下文一類的系統(tǒng)開銷,而且線程調(diào)度還會(huì)產(chǎn)生時(shí)序上的不 確定性。而對(duì)于協(xié)程來說,“掛起”的概念只不過是轉(zhuǎn)讓代碼執(zhí)行權(quán)并調(diào)用另外的協(xié)程,待到轉(zhuǎn)讓的協(xié)程告一段落后重新得到調(diào)用并從掛起點(diǎn)“喚醒”,這種協(xié)程間 的調(diào)用是邏輯上可控的,時(shí)序上確定的,可謂一切盡在掌握中。
當(dāng)今一些具備協(xié)程語義的語言,比較重量級(jí)的如C#、erlang、golang,以及輕量級(jí)的python、lua、javascript、 ruby,還有函數(shù)式的scala、scheme等。相比之下,作為原生態(tài)語言的 C 反而處于尷尬的地位,原因在于 C 依賴于一種叫做棧幀的 例程調(diào)用,例程內(nèi)部的狀態(tài)量和返回值都保留在堆棧上,這意味著生產(chǎn)者和消費(fèi)者相互之間無法實(shí)現(xiàn)平級(jí)調(diào)用,當(dāng)然你可以改寫成把生產(chǎn)者作為主例程然后將產(chǎn)品作 為傳遞參數(shù)調(diào)用消費(fèi)者例程,這樣的代碼寫起來費(fèi)力不討好而且看起來會(huì)很難受,特別當(dāng)協(xié)程數(shù)目達(dá)到十萬數(shù)量級(jí),這種寫法就過于僵化了。
這就引出了協(xié)程的概念,如果將每個(gè)協(xié)程的上下文(比如程序計(jì)數(shù)器)保存在其它地方而不是堆棧上,協(xié)程之間相互調(diào)用時(shí),被調(diào)用的協(xié)程只要從堆棧以外的地方恢復(fù)上次出讓點(diǎn)之前的上下文即可,這有點(diǎn)類似于 CPU 的上下文切換,遺憾的是似乎只有更底層的匯編語言才能做到這一點(diǎn)。
難道 C 語言只能用多線程嗎?幸運(yùn)的是,C 標(biāo)準(zhǔn)庫給我們提供了兩種協(xié)程調(diào)度原語:一種是setjmp/longjmp,另一種是ucontext 組件,它們內(nèi)部(當(dāng)然是用匯編語言)實(shí)現(xiàn)了協(xié)程的上下文切換,相較之下前者在應(yīng)用上會(huì)產(chǎn)生相當(dāng)?shù)牟淮_定性(比如不好封裝,具體說明參考聯(lián)機(jī)文檔),所以后者應(yīng)用更廣泛一些,網(wǎng)上絕大多數(shù) C 協(xié)程庫也是基于 ucontext 組件實(shí)現(xiàn)的。
“蠅量級(jí)”的協(xié)程庫
在此,我來介紹一種“蠅量級(jí)”的開源 C 協(xié)程庫 protothreads。 這是一個(gè)全部用 ANSI C 寫成的庫,之所以稱為“蠅量級(jí)”的,就是說,實(shí)現(xiàn)已經(jīng)不能再精簡了,幾乎就是原語級(jí)別。事實(shí)上 protothreads 整個(gè)庫不需要鏈接加載,因?yàn)樗性创a都是頭文件,類似于 STL 這樣不依賴任何第三方庫,在任何平臺(tái)上可移植;總共也就 5 個(gè)頭文件,有效代碼量不足 100 行;API 都是宏定義的,所以不存在調(diào)用開銷;最后,每個(gè)協(xié)程的空間開銷是 2 個(gè)字節(jié)(是的,你沒有看錯(cuò),就是一個(gè) short 單位的“棧”!)當(dāng)然這種精簡是要以使用上的局限為代價(jià)的,接下來的分析會(huì)說明這一點(diǎn)。
先來看看 protothreads 作者,Adam Dunkels,一位來自瑞典皇家理工學(xué)院的計(jì)算機(jī)天才帥哥。話說這哥們挺有意思的,寫了好多輕量級(jí)的作品,都是 BSD 許可證。順便說一句,輕量級(jí)開源軟件全世界多如牛毛,可像這位哥們寫得如此出名的并不多。比如嵌入式網(wǎng)絡(luò)操作系統(tǒng) Contiki,國人耳熟能詳?shù)?TCP/IP 協(xié)議棧 uIP 和 lwIP 也是出自其手。上述這些軟件都是經(jīng)過數(shù)十年企業(yè)級(jí)應(yīng)用的考驗(yàn),質(zhì)量之高可想而知。
很多人會(huì)好奇如此“蠅量級(jí)”的代碼究竟是怎么實(shí)現(xiàn)的呢?在分析 protothreads 源碼之前,我先來給大家補(bǔ)一補(bǔ) C 語言的基礎(chǔ)課;-^)簡而言之,這利用了 C 語言特性上的一個(gè)“奇技淫巧”,而且這種技巧恐怕連許多具備十年以上經(jīng)驗(yàn)的 C 程序員老手都不見得知曉。當(dāng)然這里先要聲明我不是推薦大家都這么用,實(shí)際上這是以破壞語言的代碼規(guī)范為代價(jià),在一些嚴(yán)肅的項(xiàng)目工程中需要謹(jǐn)慎對(duì)待,除非你 想被炒魷魚。
C 語言的“yield 語義”
下面的教程來自于一位 ARM 工程師、天才黑客 Simon Tatham(開源 Telnet/SSH 客戶端 PuTTY 和匯編器 NASM 的作者,吐槽一句,PuTTY的源碼號(hào)稱是所有正式項(xiàng)目里最難 hack 的 C,你應(yīng)該猜到作者是什么語言出身)的博文:Coroutines in C。中文譯文在這里。
我們知道 python 的 yield 語義功能類似于一種迭代生成器,函數(shù)會(huì)保留上次的調(diào)用狀態(tài),并在下次調(diào)用時(shí)會(huì)從上個(gè)返回點(diǎn)繼續(xù)執(zhí)行。用 C 語言來寫就像這樣:
- int function(void) {
- int i;
- for (i = 0; i < 10; i++)
- return i; /* won't work, but wouldn't it be nice */
- }
連續(xù)對(duì)它調(diào)用 10 次,它能分別返回 0 到 9。該怎樣實(shí)現(xiàn)呢?可以利用 goto 語句,如果我們?cè)诤瘮?shù)中加入一個(gè)狀態(tài)變量,就可以這樣實(shí)現(xiàn):
- 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 */
- }
- }
#p#
這個(gè)方法是可行的。我們?cè)谒行枰?yield 的位置都加上標(biāo)簽:起始位置加一個(gè),還有所有 return 語句之后都加一個(gè)。每個(gè)標(biāo)簽用數(shù)字編號(hào),我們?cè)跔顟B(tài)變量中保存這個(gè)編號(hào),這樣就能在我們下次調(diào)用時(shí)告訴我們應(yīng)該跳到哪個(gè)標(biāo)簽上。每次返回前,更新狀態(tài)變 量,指向到正確的標(biāo)簽;不論調(diào)用多少次,針對(duì)狀態(tài)變量的 switch 語句都能找到我們要跳轉(zhuǎn)到的位置。
但這還是難看得很。最糟糕的部分是所有的標(biāo)簽都需要手工維護(hù),還必須保證函數(shù)中的標(biāo)簽和開頭 switch 語句中的一致。每次新增一個(gè) return 語句,就必須想一個(gè)新的標(biāo)簽名并將其加到 switch 語句中;每次刪除 return 語句時(shí),同樣也必須刪除對(duì)應(yīng)的標(biāo)簽。這使得維護(hù)代碼的工作量增加了一倍。
仔細(xì)想想,其實(shí)我們可以不用 switch 語句來決定要跳轉(zhuǎn)到哪里去執(zhí)行,而是直接利用 switch 語句本身來實(shí)現(xiàn)跳轉(zhuǎn):
- 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 */
- }
- }
- }
酷!沒想到 switch-case 語句可以這樣用,其實(shí)說白了 C 語言就是脫胎于匯編語言的,switch-case 跟 if-else 一樣,無非就是匯編的條件跳轉(zhuǎn)指令的另類實(shí)現(xiàn)而已(這也間接解釋了為何匯編程序員經(jīng)常揶揄 C 語言是“大便一樣的代碼”)。我們還可以用 __LINE__ 宏使其更加一般化:
- int function(void) {
- static int i, state = 0;
- switch (state) {
- case 0: /* start of function */
- for (i = 0; i < 10; i++) {
- state = __LINE__ + 2; /* so we will come back to "case __LINE__" */
- return i;
- case __LINE__:; /* resume control straight after the return */
- }
- }
- }
這樣一來我們可以用宏提煉出一種范式,封裝成組件:
- #define Begin() static int state=0; switch(state) { case 0:
- #define Yield(x) do { state=__LINE__; return x; case __LINE__:; } while (0)
- #define End() }
- int function(void) {
- static int i;
- Begin();
- for (i = 0; i < 10; i++)
- Yield(i);
- End();
- }
怎么樣,看起來像不像發(fā)明了一種全新的語言?實(shí)際上我們利用了 switch-case 的分支跳轉(zhuǎn)特性,以及預(yù)編譯的 __LINE__ 宏,實(shí)現(xiàn)了一種隱式狀態(tài)機(jī),最終實(shí)現(xiàn)了“yield 語義”。
還有一個(gè)問題,當(dāng)你歡天喜地地將這種鮮為人知的技巧運(yùn)用到你的項(xiàng)目中,并成功地拿去向你的上司邀功問賞的時(shí)候,你的上司會(huì)怎樣看待你的代碼呢?你的 宏定義中大括號(hào)沒有匹配完整,在代碼塊中包含了未用到的 case,Begin 和 Yield 宏里面不完整的七拼八湊……你簡直就是公司里不遵守編碼規(guī)范的反面榜樣!
別著急,在原文中 Simon Tatham 大牛幫你找到一個(gè)堅(jiān)定的反駁理由,我覺得對(duì)程序員來說簡直是金玉良言。
將編程規(guī)范用在這里是不對(duì)的。文章里給出的示例代碼不是很長,也不很復(fù)雜,即便以狀態(tài)機(jī)的方式改寫還是能夠看懂的。但是隨著代碼越來越長,改寫的難度將越來越大,改寫對(duì)直觀性造成的損失也變得相當(dāng)相當(dāng)大。
想一想,一個(gè)函數(shù)如果包含這樣的小代碼塊:
- case STATE1:
- /* perform some activity */
- if (condition) state = STATE2; else state = STATE3;
對(duì)于看代碼的人說,這和包含下面小代碼塊的函數(shù)沒有多大區(qū)別:
- LABEL1:
- /* perform some activity */
- if (condition) goto LABEL2; else goto LABEL3;
是的,這兩個(gè)函數(shù)的結(jié)構(gòu)在視覺上是一樣的,而對(duì)于函數(shù)中實(shí)現(xiàn)的算法,兩個(gè)函數(shù)都一樣不利于查看。因?yàn)槟闶褂脜f(xié)程的宏而炒你魷魚的人,一樣會(huì)因?yàn)槟銓?的函數(shù)是由小塊的代碼和 goto 語句組成而吼著炒了你。只是這次他們沒有冤枉你,因?yàn)橄衲菢釉O(shè)計(jì)的函數(shù)會(huì)嚴(yán)重?cái)_亂算法的結(jié)構(gòu)。
編程規(guī)范的目標(biāo)就是為了代碼清晰。如果將一些重要的東西,像 switch、return 以及 case 語句,隱藏到起“障眼”作用的宏中,從編程規(guī)范的角度講,可以說你擾亂了程序的語法結(jié)構(gòu),并且違背了代碼清晰這一要求。但是我們這樣做是為了突出程序的算 法結(jié)構(gòu),而算法結(jié)構(gòu)恰恰是看代碼的人更想了解的。
任何編程規(guī)范,堅(jiān)持犧牲算法清晰度來換取語法清晰度的,都應(yīng)該重寫。如果你的上司因?yàn)槭褂昧诉@一技巧而解雇你,那么在保安把你往外拖的時(shí)候要不斷告訴他這一點(diǎn)。
原文作者最后給出了一個(gè) MIT 許可證的 coroutine.h 頭文件。值得一提的是,正如文中所說,這種協(xié)程實(shí)現(xiàn)方法有個(gè)使用上的局限,就是協(xié)程調(diào)度狀態(tài)的保存依賴于 static 變量,而不是堆棧上的局部變量, 實(shí)際上也無法用局部變量(堆棧)來保存狀態(tài),這就使得代碼不具備可重入性和多線程應(yīng)用。后來作者補(bǔ)充了一種技巧,就是將局部變量包裝成函數(shù)參數(shù)傳入的一個(gè) 虛構(gòu)的上下文結(jié)構(gòu)體指針,然后用動(dòng)態(tài)分配的堆來“模擬”堆棧,解決了線程可重入問題。但這樣一來反而有損代碼清晰,比如所有局部變量都要寫成對(duì)象成員的引 用方式,特別是局部變量很多的時(shí)候很麻煩,再比如宏定義 malloc/free 的玩法過于托大,不易控制,搞不好還增加了被炒魷魚的風(fēng)險(xiǎn)(只不過這次是你活該)。
我個(gè)人認(rèn)為,既然協(xié)程本身是一種單線程的方案,那么我們應(yīng)該假定應(yīng)用環(huán)境是單線程的,不存在代碼重入問題,所以我們可以大膽地使用 static 變量,維持代碼的簡潔和可讀性。事實(shí)上我們也不應(yīng)該在多線程環(huán)境下考慮使用這么簡陋的協(xié)程,非要用的話,前面提到 glibc 的 ucontext 組件也是一種可行的替代方案,它提供了一種協(xié)程私有堆棧的上下文,當(dāng)然這種用法在跨線程上也并非沒有限制,請(qǐng)仔細(xì)閱讀聯(lián)機(jī)文檔。
#p#
Protothreads的上下文
感謝 Simon Tatham 的淳淳教誨,接下來我們可以 hack 一下源碼了。先來看看實(shí)現(xiàn) protothreads 的數(shù)據(jù)結(jié)構(gòu), 實(shí)際上它就是協(xié)程的上下文結(jié)構(gòu)體,用以保存狀態(tài)變量,相信你很快就明白為何它的“堆棧”只有 2 個(gè)字節(jié):
- struct pt {
- lc_t lc;
- }
里面只有一個(gè) short 類型的變量,實(shí)際上它是用來保存上一次出讓點(diǎn)的程序計(jì)數(shù)器。這也映證了協(xié)程比線程的靈活之處,就是協(xié)程可以是 stackless 的,如果需要實(shí)現(xiàn)的功能很單一,比如像生產(chǎn)者-消費(fèi)者模型那樣用來做事件通知,那么實(shí)際上協(xié)程需要保存的狀態(tài)變量僅僅是一個(gè)程序計(jì)數(shù)器即可。像 python generator 也是 stackless 的,當(dāng)然實(shí)現(xiàn)一個(gè)迭代生成器可能還需要保留上一個(gè)迭代值,前面 C 的例子是用 static 變量保存,你也可以設(shè)置成員變量添加到上下文結(jié)構(gòu)體里面。如果你真的不確定用協(xié)程調(diào)度時(shí)需要保存多少狀態(tài)變量,那還是用 ucontext 好了,它的上下文提供了堆棧和信號(hào),但是由用戶負(fù)責(zé)分配資源,詳細(xì)使用方法見聯(lián)機(jī)文檔。
- typedef struct ucontext {
- struct ucontext_t *uc_link;
- sigset_t uc_sigmask;
- stack_t uc_stack;
- ...
- } ucontext_t;
Protothreads的原語和組件
有點(diǎn)扯遠(yuǎn)了,回到 protothreads,看看提供的協(xié)程“原語”。有兩種實(shí)現(xiàn)方法,在 ANSI C 下,就是傳統(tǒng)的 switch-case 語句:
- #define LC_INIT(s) s = 0; // 源碼中是有分號(hào)的,一個(gè)低級(jí) bug,啊哈~
- #define LC_RESUME(s) switch (s) { case 0:
- #define LC_SET(s) s = __LINE__; case __LINE__:
- #define LC_END(s) }
但這種“原語”有個(gè)難以察覺的缺陷:就是你無法在 LC_RESUME 和 LC_END (或者包含它們的組件)之間的代碼中使用 switch-case語句,因?yàn)檫@會(huì)引起外圍的 switch 跳轉(zhuǎn)錯(cuò)誤!為 此,protothreads 又實(shí)現(xiàn)了基于 GNU C 的調(diào)度“原語”。在 GNU C 下還有一種語法糖叫做標(biāo)簽指針,就是在一個(gè) label 前面加 &&(不是地址的地址,是 GNU 自定義的符號(hào)),可以用 void 指針類型保存,然后 goto 跳轉(zhuǎn):
- typedef void * lc_t;
- #define LC_INIT(s) s = NULL
- #define LC_RESUME(s) \
- do { \
- if (s != NULL) { \
- goto *s; \
- }
- } while (0)
- #define LC_CONCAT2(s1, s2) s1##s2
- #define LC_CONCAT(s1, s2) LC_CONCAT2(s1, s2)
- #define LC_SET(s) \
- do { \
- LC_CONCAT(LC_LABEL, __LINE__): \
- (s) = &&LC_CONCAT(LC_LABEL, __LINE__); \
- } while (0)
好了,有了前面的基礎(chǔ)知識(shí),理解這些“原語”就是小菜一疊,下面看看如何建立“組件”,同時(shí)也是 protothreads API,我們先定義四個(gè)退出碼作為協(xié)程的調(diào)度狀態(tài)機(jī):
- #define PT_WAITING 0
- #define PT_YIELDED 1
- #define PT_EXITED 2
- #define PT_ENDED 3
下面這些 API 可直接在應(yīng)用程序中調(diào)用:
- /* 初始化一個(gè)協(xié)程,也即初始化狀態(tài)變量 */
- #define PT_INIT(pt) LC_INIT((pt)->lc)
- /* 聲明一個(gè)函數(shù),返回值為 char 即退出碼,表示函數(shù)體內(nèi)使用了 proto thread,(個(gè)人覺得有些多此一舉) */
- #define PT_THREAD(name_args) char name_args
- /* 協(xié)程入口點(diǎn), PT_YIELD_FLAG=0表示出讓,=1表示不出讓,放在 switch 語句前面,下次調(diào)用的時(shí)候可以跳轉(zhuǎn)到上次出讓點(diǎn)繼續(xù)執(zhí)行 */
- #define PT_BEGIN(pt) { char PT_YIELD_FLAG = 1; LC_RESUME((pt)->lc)
- /* 協(xié)程退出點(diǎn),至此一個(gè)協(xié)程算是終止了,清空所有上下文和標(biāo)志 */
- #define PT_END(pt) LC_END((pt)->lc); PT_YIELD_FLAG = 0; \
- PT_INIT(pt); return PT_ENDED; }
- /* 協(xié)程出讓點(diǎn),如果此時(shí)協(xié)程狀態(tài)變量 lc 已經(jīng)變?yōu)?nbsp;__LINE__ 跳轉(zhuǎn)過來的,那么 PT_YIELD_FLAG = 1,表示從出讓點(diǎn)繼續(xù)執(zhí)行。 */
- #define PT_YIELD(pt) \
- do { \
- PT_YIELD_FLAG = 0; \
- LC_SET((pt)->lc); \
- if(PT_YIELD_FLAG == 0) { \
- return PT_YIELDED; \
- } \
- } while(0)
- /* 附加出讓條件 */
- #define PT_YIELD_UNTIL(pt, cond) \
- do { \
- PT_YIELD_FLAG = 0; \
- LC_SET((pt)->lc); \
- if((PT_YIELD_FLAG == 0) || !(cond)) { \
- return PT_YIELDED; \
- } \
- } while(0)
- /* 協(xié)程阻塞點(diǎn)(blocking),本質(zhì)上等同于 PT_YIELD_UNTIL,只不過退出碼是 PT_WAITING,用來模擬信號(hào)量同步 */
- #define PT_WAIT_UNTIL(pt, condition) \
- do { \
- LC_SET((pt)->lc); \
- if(!(condition)) { \
- return PT_WAITING; \
- } \
- } while(0)
- /* 同 PT_WAIT_UNTIL 條件反轉(zhuǎn) */
- #define PT_WAIT_WHILE(pt, cond) PT_WAIT_UNTIL((pt), !(cond))
- /* 協(xié)程調(diào)度,調(diào)用協(xié)程 f 并檢查它的退出碼,直到協(xié)程終止返回 0,否則返回 1。 */
- #define PT_SCHEDULE(f) ((f) < PT_EXITED)
- /* 這用于非對(duì)稱協(xié)程,調(diào)用者是主協(xié)程,pt 是和子協(xié)程 thread (可以是多個(gè))關(guān)聯(lián)的上下文句柄,主協(xié)程阻塞自己調(diào)度子協(xié)程,直到所有子協(xié)程終止 */
- #define PT_WAIT_THREAD(pt, thread) PT_WAIT_WHILE((pt), PT_SCHEDULE(thread))
- /* 用于協(xié)程嵌套調(diào)度,child 是子協(xié)程的上下文句柄 */
- #define PT_SPAWN(pt, child, thread) \
- do { \
- PT_INIT((child)); \
- PT_WAIT_THREAD((pt), (thread)); \
- } while(0)
暫時(shí)介紹這么多,用戶還可以根據(jù)自己的需求隨意擴(kuò)展組件,比如實(shí)現(xiàn)信號(hào)量,你會(huì)發(fā)現(xiàn)脫離了操作系統(tǒng)環(huán)境下的信號(hào)量竟是如此簡單:
- struct pt_sem {
- unsigned int count;
- };
- #define PT_SEM_INIT(s, c) (s)->count = c
- #define PT_SEM_WAIT(pt, s) \
- do { \
- PT_WAIT_UNTIL(pt, (s)->count > 0); \
- --(s)->count; \
- } while(0)
- #define PT_SEM_SIGNAL(pt, s) ++(s)->count
這些應(yīng)該不需要我多說了吧,呵呵,讓我們回到最初例舉的生產(chǎn)者-消費(fèi)者模型,看看protothreads表現(xiàn)怎樣。
#p#
Protothreads實(shí)戰(zhàn)
- #include "pt-sem.h"
- #define NUM_ITEMS 32
- #define BUFSIZE 8
- static struct pt_sem mutex, full, empty;
- PT_THREAD(producer(struct pt *pt))
- {
- static int produced;
- PT_BEGIN(pt);
- for (produced = 0; produced < NUM_ITEMS; ++produced) {
- PT_SEM_WAIT(pt, &full);
- PT_SEM_WAIT(pt, &mutex);
- add_to_buffer(produce_item());
- PT_SEM_SIGNAL(pt, &mutex);
- PT_SEM_SIGNAL(pt, &empty);
- }
- PT_END(pt);
- }
- PT_THREAD(consumer(struct pt *pt))
- {
- static int consumed;
- PT_BEGIN(pt);
- for (consumed = 0; consumed < NUM_ITEMS; ++consumed) {
- PT_SEM_WAIT(pt, &empty);
- PT_SEM_WAIT(pt, &mutex);
- consume_item(get_from_buffer());
- PT_SEM_SIGNAL(pt, &mutex);
- PT_SEM_SIGNAL(pt, &full);
- }
- PT_END(pt);
- }
- PT_THREAD(driver_thread(struct pt *pt))
- {
- static struct pt pt_producer, pt_consumer;
- PT_BEGIN(pt);
- PT_SEM_INIT(&empty, 0);
- PT_SEM_INIT(&full, BUFSIZE);
- PT_SEM_INIT(&mutex, 1);
- PT_INIT(&pt_producer);
- PT_INIT(&pt_consumer);
- PT_WAIT_THREAD(pt, producer(&pt_producer) & consumer(&pt_consumer));
- PT_END(pt);
- }
源碼包中的 example-buffer.c 包含了可運(yùn)行的完整示例,我就不全部貼了。整體框架就是一個(gè) asymmetric coroutines,包括一個(gè)主協(xié)程 driver_thread 和兩個(gè)子協(xié)程 producer 和 consumer ,其實(shí)不用多說大家也懂的,代碼非常清晰直觀。我們完全可以通過單線程實(shí)現(xiàn)一個(gè)簡單的事件處理需求,你可以任意添加數(shù)十萬個(gè)協(xié)程,幾乎不會(huì)引起任何額外的 系統(tǒng)開銷和資源占用。唯一需要留意的地方就是沒有一個(gè)局部變量,因?yàn)?protothreads 是 stackless 的,但這不是問題,首先我們已經(jīng)假定運(yùn)行環(huán)境是單線程的,其次在一個(gè)簡化的需求下也用不了多少“局部變量”。如果在協(xié)程出讓時(shí)需要保存一些額外的狀態(tài)量, 像迭代生成器,只要數(shù)目和大小都是確定并且可控的話,自行擴(kuò)展協(xié)程上下文結(jié)構(gòu)體即可。
當(dāng)然這不是說 protothreads 是萬能的,它只是貢獻(xiàn)了一種模型,你要使用它首先就得學(xué)會(huì)適應(yīng)它。下面列舉一些 protothreads 的使用限制:
- 由于協(xié)程是stackless的,盡量不要使用局部變量,除非該變量對(duì)于協(xié)程狀態(tài)是無關(guān)緊要的,同理可推,協(xié)程所在的代碼是不可重入的。
- 如果協(xié)程使用 switch-case 原語封裝的組件,那么禁止在實(shí)際應(yīng)用中使用 switch-case 語句,除非用 GNU C 語法中的標(biāo)簽指針替代。
- 一個(gè)協(xié)程內(nèi)部可以調(diào)用其它例程,比如庫函數(shù)或系統(tǒng)調(diào)用,但必須保證該例程是非阻塞的,否則所在線程內(nèi)的所有協(xié)程都將被阻塞。畢竟線程才是執(zhí)行的最小單位,協(xié)程不過是按“時(shí)間片輪度”的例程而已。
官網(wǎng)上還例舉了更多實(shí)例,都非常實(shí)用。另外,一個(gè)叫 Craig Graham 的工程師擴(kuò)展了 pt.h,使得 protothreads 支持 sleep/wake/kill 等操作,文件在此 graham-pt.h。
協(xié)程庫 DIY 攻略
看到這里,手養(yǎng)的你是否想迫不及待地 DIY 一個(gè)協(xié)程組件呢?哪怕很多動(dòng)態(tài)語言本身已經(jīng)支持了協(xié)程語義,很多 C 程序員仍然傾向于自己實(shí)現(xiàn)組件,網(wǎng)上很多開源代碼底層用的主要還是 glibc 的 ucontext 組件,畢竟提供堆棧的協(xié)程組件使用起來更加通用方便。你可以自己寫一個(gè)調(diào)度器,然后模擬線程上下文,再然后……你就能搞出一個(gè)跨平臺(tái)的COS了(笑)。 GNU Pth 線程庫就是這么實(shí)現(xiàn)的,其原作者德國人 Ralf S. Engelschall (又是個(gè)開源大牛,還寫了 OpenSSL 等許多作品)就寫了一篇論文教大家如何實(shí)現(xiàn)一個(gè)線程庫。另外 protothreads 官網(wǎng)上也有一大堆推薦閱讀。Have fun!