最轻量级的C协程库:Protothreads
原文地址:https://www.linuxidc.com/Linux/2012-07/66395p2.htm
協程的好處不用再多說,作為與函數調用/返回相對的概念,它使我們思考問題的方式經歷一場變革。現在我們關注的是C,由于C本身的特質,將協程引入其中將會是一 個挑戰。無數先驅已經為這個目標拋了頭顱灑了熱血,于是我們有了libtask之類。而這里提到的,是一個堪稱最輕量級的協程實現:Protothreads(http://dunkels.com/adam/pt/index.html)。所謂最輕量級,就是說,功能已經不能再精簡了,幾乎就是原語級別的。——確實,這種最簡帶來了一些使用上的繁瑣不便,但在打退堂鼓之前,先來看看它的優點吧:
不依賴任何庫(包括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的一個超輕量級版。
至于PT_END,其作用除了清除pt指針以外,主要是為了返回協程的狀態。實際上PT_YIELD中的return也是帶值的,之所以foo函數要聲明為int,就是為了每次調用foo都能得到該協程當前的狀態,是掛起了、結束了,還是中途退出了等等。
應該注意到了一點,就是既然每次重入協程都要重新調用foo函數,則說明foo函數中留不下任何狀態,如果定義局部變量,則其內容都會丟失。嗯……這就是我指的“繁瑣與不便”的主要所在吧,你需要讓一切協程狀態都以外部變量的形式存在,典型做法是封裝成一個結構體,作為該函數的第二個參數。嗯,畢竟,C是接近底層的語言,讓它自動背著你創建好多變量的副本,或者好多個協程局部的堆棧,還是不如你自己精確掌控對每塊內存的使用,不是嗎?畢竟不能用腳本語言的眼光來看C ^_^
現在,用這種方式創建了好多協程,那么接下來用一個簡單的方式讓它們運轉起來,這個輪轉調度簡單得難以置信:
while (1) {foo1(p1);foo2(p2);...foon(pn); }
這就是調度器的主循環,只需要往復依次調用每個協程的入口函數即可。
以上敘述了Protothreads庫的核心內容,實際上該庫還包含了動態協程建立、協程間通信等設施,對于一個如此單薄的庫來說,還是相當令人驚喜的。最后為了再次強調其單薄,在此列舉一下其所有的頭文件:
- ??????? lc-addrlabels.h?????用GCC語法擴展實現的協程基礎
- ??????? lc-switch.h??????????? 用switch語句實現的協程基礎
- ??????? lc.h? ? ? ? ? ? ? ? ? ? ? ?該文件存在的意義僅僅為了選擇以上兩者之一
- ??????? pt.h? ? ? ? ? ? ? ? ? ? ? ?基于lc.h的協程設施的真正實現
- ??????? pt-sem.h?????????????? 協程間通信(信號量)的實現
源文件下載地址:http://dunkels.com/adam/pt/download.html?
總結
以上是生活随笔為你收集整理的最轻量级的C协程库:Protothreads的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 达夫设备(Duff‘s Device)
- 下一篇: 三、开发调试应用程序