協程(coroutine)顧名思義就是“協作的例程”co-operative routines)。跟具有操作系統概念的線程不一樣,協程是在用戶空間利用程序語言的語法語義就能實現邏輯上類似多任務的編程技巧。實際上協程的概念比線程還要早,按照 Knuth 的說法“子例程是協程的特例”,一個子例程就是一次子函數調用,那麼實際上協程就是類 函數一樣的程序組件,你可以在一個線程裡面輕松創建數十萬個協程,就像數十萬次函數調用一樣。只不過子例程只有一個調用入口起始點,返回之後就結束了,而 協程入口既可以是起始點,又可以從上一個返回點繼續執行,也就是說協程之間可以通過 yield 方式轉移執行權,對稱symmetric)、平級地調用對方,而不是像例程那樣上下級調用關系。當然 Knuth 的“特例”指的是協程也可以模擬例程那樣實現上下級調用關系,這就叫非對稱協程asymmetric coroutines)。
基於事件驅動模型
我們舉一個例子來看看一種對稱協程調用場景,大家最熟悉的“生產者-消費者”事件驅動模型,一個協程負責生產產品並將它們加入隊列,另一個負責從隊列中取出產品並使用它。為了提高效率,你想一次增加或刪除多個產品。偽代碼可以是這樣的:
- # 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
大多數教材上拿這種模型作為多線程的例子,實際上多線程在此的應用還是顯得有點“重量級”,由於缺乏 yield 語義,線程之間不得不使用同步機制來避免產生全局資源的竟態,這就不可避免產生了休眠、調度、切換上下文一類的系統開銷,而且線程調度還會產生時序上的不 確定性。而對於協程來說,“掛起”的概念只不過是轉讓代碼執行權並調用另外的協程,待到轉讓的協程告一段落後重新得到調用並從掛起點“喚醒”,這種協程間 的調用是邏輯上可控的,時序上確定的,可謂一切盡在掌握中。
當今一些具備協程語義的語言,比較重量級的如C#、erlang、golang,以及輕量級的python、lua、javascript、 ruby,還有函數式的scala、scheme等。相比之下,作為原生態語言的 C 反而處於尴尬的地位,原因在於 C 依賴於一種叫做棧幀的 例程調用,例程內部的狀態量和返回值都保留在堆棧上,這意味著生產者和消費者相互之間無法實現平級調用,當然你可以改寫成把生產者作為主例程然後將產品作 為傳遞參數調用消費者例程,這樣的代碼寫起來費力不討好而且看起來會很難受,特別當協程數目達到十萬數量級,這種寫法就過於僵化了。
這就引出了協程的概念,如果將每個協程的上下文比如程序計數器)保存在其它地方而不是堆棧上,協程之間相互調用時,被調用的協程只要從堆棧以外的地方恢復上次出讓點之前的上下文即可,這有點類似於 CPU 的上下文切換,遺憾的是似乎只有更底層的匯編語言才能做到這一點。
難道 C 語言只能用多線程嗎?幸運的是,C 標准庫給我們提供了兩種協程調度原語:一種是setjmp/longjmp,另一種是ucontext 組件,它們內部當然是用匯編語言)實現了協程的上下文切換,相較之下前者在應用上會產生相當的不確定性比如不好封裝,具體說明參考聯機文檔),所以後者應用更廣泛一些,網上絕大多數 C 協程庫也是基於 ucontext 組件實現的。
在此,我來介紹一種“蠅量級”的開源 C 協程庫 protothreads。 這是一個全部用 ANSI C 寫成的庫,之所以稱為“蠅量級”的,就是說,實現已經不能再精簡了,幾乎就是原語級別。事實上 protothreads 整個庫不需要鏈接加載,因為所有源碼都是頭文件,類似於 STL 這樣不依賴任何第三方庫,在任何平台上可移植;總共也就 5 個頭文件,有效代碼量不足 100 行;API 都是宏定義的,所以不存在調用開銷;最後,每個協程的空間開銷是 2 個字節是的,你沒有看錯,就是一個 short 單位的“棧”!)當然這種精簡是要以使用上的局限為代價的,接下來的分析會說明這一點。
先來看看 protothreads 作者,Adam Dunkels,一位來自瑞典皇家理工學院的計算機天才帥哥。話說這哥們挺有意思的,寫了好多輕量級的作品,都是 BSD 許可證。順便說一句,輕量級開源軟件全世界多如牛毛,可像這位哥們寫得如此出名的並不多。比如嵌入式網絡操作系統 Contiki,國人耳熟能詳的 TCP/IP 協議棧 uIP 和 lwIP 也是出自其手。上述這些軟件都是經過數十年企業級應用的考驗,質量之高可想而知。
很多人會好奇如此“蠅量級”的代碼究竟是怎麼實現的呢?在分析 protothreads 源碼之前,我先來給大家補一補 C 語言的基礎課;-^)簡而言之,這利用了 C 語言特性上的一個“奇技淫巧”,而且這種技巧恐怕連許多具備十年以上經驗的 C 程序員老手都不見得知曉。當然這裡先要聲明我不是推薦大家都這麼用,實際上這是以破壞語言的代碼規范為代價,在一些嚴肅的項目工程中需要謹慎對待,除非你 想被炒鱿魚。
下面的教程來自於一位 ARM 工程師、天才黑客 Simon Tatham開源 Telnet/SSH 客戶端 PuTTY 和匯編器 NASM 的作者,吐槽一句,PuTTY的源碼號稱是所有正式項目裡最難 hack 的 C,你應該猜到作者是什麼語言出身)的博文:Coroutines in C。中文譯文在這裡。
我們知道 python 的 yield 語義功能類似於一種迭代生成器,函數會保留上次的調用狀態,並在下次調用時會從上個返回點繼續執行。用 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 */
- }
- }