計算機語言協程的歷史、現在和未來

文 / 徐宥

計算機科學是一門應用科學,幾乎所有概念都是爲了理解或解決實際問題而生。協程(Coroutine)的出現也不例外。協程的概念,最早可以追溯到解決COBOL語言編譯器中的技術難題。


從磁帶到協程

COBOL是最早的高級語言之一,編譯器則是高級語言必不可少的一部分。現如今,我們對編譯器的瞭解,已經到了可以把核心內容濃縮成一本教科書的程度。然而在二十世紀六十年代,如何寫作高效的語言編譯器還是繞不過的現實問題。例如,1960年夏天,D. E. Knuth就是利用開車橫穿美國去加州理工讀研究生的時間,對着Burroughs 205機器指令集手寫COBOL編譯器。最早提出“協程”概念的Melvin Conway,也是從如何寫一個只掃描一遍程序(one-pass)的COBOL編譯器出發。衆多的“高手”紛紛投入編譯器開發,可見一門新科學發展之初也是篳路藍縷。

以現代眼光來看,高級語言編譯實際由多個步驟組合而成:詞法解析、語法解析、語法樹構建,以及優化和目標代碼生成等。編譯實質上就是從源程序出發,依次將這些步驟的輸出作爲下一步的輸入,最終輸出目標代碼。在現代計算機上實現這種管道式的架構毫無困難:只需要依次運行,中間結果存爲中間文件或放入內存即可。GCC和Clang編譯器,以及ANTLR構建的編譯器,都遵循這種設計。

在Conway的設計裏,詞法和語法解析不再是獨立運行的步驟,而是交織在一起。編譯器的控制流在詞法和語法解析之間來回切換:當詞法模塊讀入足夠多的token時,控制流交給語法分析;當語法分析消化完所有token後,控制流交給詞法分析。詞法和語法分別獨立維護自身的運行狀態。Conway構建的這種協同工作機制,需要參與者“讓出(yield)”控制流時,記住自身狀態,以便在控制流返回時能從上次讓出的位置恢復(resume)執行。簡言之,協程的全部精神就在於控制流的主動讓出和恢復。我們熟悉的子過程調用可以看作在返回時讓出控制流的一種特殊協程,其內部狀態在返回時被丟棄了,因此不存在“恢復”這個操作。

以現在眼光來看,編譯器的實現並非必需協程。然而,Conway用協程實現COBOL編譯器在當時絕不是捨近求遠。首先,從原理上,因爲COBOL並不是LL(1)型語法,無法簡單構建一個以詞法分析爲子過程的自動機。其次,當年計算機依賴於磁帶存儲設備,只支持順序存儲。也就是說,依次執行編譯步驟並依靠中間文件通信的設計是不現實的,各步驟必須同步前進。正是這樣的現實侷限和設計需要,催生了協程的概念。

自頂向下,無需協同

雖然協程伴隨着高級語言誕生,卻沒有能像子過程那樣成爲通用編程語言的基本元素。

從1963年首次提出到上世紀九十年代,我們在ALOGL、Pascal、C、FORTRAN等主流的命令式編程語言中都沒有看到原生的協程支持。協程只稀疏地出現在Simula、Modular-2(Pascal升級版)和Smalltalk等相對小衆的語言中。作爲一個比子進程更加通用的概念,在實際編程中卻沒有取代子進程,不得不說出乎意外。但如果結合當時的程序設計思想看,又在意料之中:協程不符合那個時代所崇尚的“自頂向下”的程序設計思想,自然也就不會成爲當時主流的命令式編程語言的一部分。

正如面向對象的語言是圍繞面向對象的開發理念設計一樣,命令式編程語言是圍繞自頂向下的開發理念設計的。在這種理念的指導下,程序被切分爲一個主程序和大大小小的子模塊,每個子模塊又可能調用更多子模塊。C家族語言的main()函數就是這種自頂向下思想的體現。在這種理念指導下,各模塊形成層次調用關係,而程序設計就是製作這些子過程。

在“自頂向下”這種層次化的理念下,具有鮮明層次的子過程調用成爲軟件系統最自然的組織方式,也是理所當然。相較之下,具有執行中讓出和恢復功能的協程在這種架構下無用武之地。可以說,自頂向下的設計思想從一開始就排除了對協程的需求。其後的結構化編程思想,更進一步強化了“子過程調用作爲唯一控制結構”的基本假設。在這樣的指導思想下,協程沒有成爲當時編程語言的一等公民。

但作爲一種易於理解的控制結構,協程的概念滲入到了軟件設計的許多方面。在結構化編程思想一統天下之時,Knuth曾專門寫過一篇《Structured Programming with GOTO》來爲GOTO語句辯護。在他列出的幾條GOTO可以方便編程且不破壞程序結構的例子中,有一個(例子7b)就是用GOTO實現協程控制結構。相較之下,不用GOTO的“結構化”代碼反而失去了良好的結構。當然,追求實際結果的工業界對於學界這場要不要剔除GOTO的爭論並不感冒。當時許多語言都附帶了不建議使用的GOTO語句,顯得左右逢源。這方面一個最明顯的例子就是Java——語言本身預留了goto關鍵字,而編譯器卻沒提供任何支持,在這場爭論中做足了中間派。

實踐中,協程的思想頻繁應用於任務調度和流處理。例如,Unix管道就可以看成是衆多命令間的協同操作。當然,管道的現代實現都以pipe()系統調用和進程間的通信爲基礎,而非簡單遵循協程的yield/resume語法。

許多協同式多任務操作系統,也可以看成協程運行系統。說到協同式多任務系統,一個常見的誤區是認爲協同式調度比搶佔式調度“低級”,因爲我們所熟悉的桌面操作系統,都是從協同式調度(如Windows 3.2、Mac OS 9等)過渡到搶佔式多任務系統的。實際上,調度方式並無高下,完全取決於應用場景。搶佔式系統允許操作系統剝奪進程執行權限,搶佔控制流,因而天然適合服務器和圖形操作系統,因爲調度器可以優先保證對用戶交互和網絡事件的快速響應。當年Windows 95剛推出時,搶佔式多任務就被作爲一大賣點大加宣傳。協同式調度則等到進程時間片用完或系統調用時轉移執行權限,因此適合實時或分時等對運行時間有保障的系統。

另外,搶佔式系統依賴於CPU的硬件支持。因爲調度器需要“剝奪”進程的執行權,就意味着調度器需要運行在比普通進程高的權限上,否則任何“流氓(rogue)”進程都可以去剝奪其他進程了。只有CPU支持了執行權限後,搶佔式調度才成爲可能。x86系統從80386處理器開始引入Ring機制支持執行權限,這也是爲何Windows 95和Linux其實只能運行在80386之後的x86處理器上的原因。而協同式多任務適用於那些沒有處理器權限支持的場景,這些場景包括資源受限的嵌入式系統和實時系統。在這些系統中,程序均以協程的方式運行。調度器負責控制流的讓出和恢復。通過協程的模型,無需硬件支持,我們就可以在一個“簡陋”的處理器上實現多任務系統。許多常見的智能設備,如運動手環,受硬件所限,都採用協同調度架構。

協程的復興和現代形式

編程思想能否普及開來,很大程度上在於應用場景。協程沒有能在自頂向下的世界裏立足,卻在動態語言世界中大放光彩,這裏最顯著的例子莫過於Python的迭代器和生成器。
回想一下在C的世界裏,循環的標準寫法是for (i = 0; i < n; ++i) { ... }。這行代碼包含兩個獨立的邏輯:for循環控制了i的邊界條件,++i控制了i的自增邏輯。這行代碼適用於C世界裏的數組即內存位移的範式,因此適合大多數訪問場景。對於STL和複雜數據結構,因爲往往只支持順序訪問,循環大多寫成:for (i = A.first(); i.hasNext();i = i.next()) { ... }

這種設計抽象出了一個獨立於數據結構的迭代器,專門負責數據結構上元素的訪問順序。迭代器把訪問邏輯從數據結構中分離出來,是一個常用的設計模式(GoF 23個設計模式之一),我們在STL和Java Collection中也常常看到迭代器的身影。在適當的時候,我們可以更進一步引入一個語法糖,將循環寫成:for i in A.Iterator() {func(i)}

事實上,許多現代語言都支持類似的語法。這種語法拋棄了以i變量作爲迭代指針的功能,要求迭代器自身能記住當前迭代位置,調用時返回下一個元素。讀者不難看到,這就是我們在文章開始提到的語法分析器的架構。正因爲如此,我們可以從協程的角度來理解迭代器:當控制流轉換到迭代器時,迭代器負責生成和返回下一個元素。一旦準備就緒,迭代器就讓出控制流。在Python中,這種特殊的迭代器實現又被成爲生成器。以協程角度切入的好處在於設計大大精簡。實際上,在Python中,生成器本身就是一個普通函數,和其他普通函數的唯一不同,在於它的返回語句是協程風格的yield。這裏,yield一語雙關,既是讓出控制流,也是生成迭代器的返回值。

前文我們僅討論了生成器最基本的特性。實際上,生成器的強大之處在於我們可以像Unix管道一樣串聯起來,組成所謂的生成器表達式。如果我們有一個可以生成1,2,3 …的生成器N,則square = (i **2 for i in N)就是一個生成平方數的生成器表達式。注意這裏圓括號語法和list comprehansion方括號語法的區別,square = [i **2 for i in N]是生成一個具體的列表。我們可以串聯這些生成器表達式,最終的控制流會在這些串聯的部分間轉換,無需編寫複雜的嵌套調用。當然,yield只是冰山的一角,現代的Python語言還充分利用了yield關鍵字構建yield from語句、(yield)語法等,讓我們毫無困難地將協程的思想融入到Python編程中,限於篇幅這裏不再展開。

我們前面說過,協程的思想本質上就是控制流的主動讓出和恢復機制。在現代語言中,可以實現協程思想的方法很多,這些實現間並無高下之分,所區別的就是是否適合應用場景。理解這一點,我們對於各種協程的分類,如半對稱/對稱協程、有棧與無棧協程等具體實現就能提綱挈領,無需在實現細節上糾結。

協程在實踐中的實現方式千差萬別,一個簡單的原因是,協程本身可以通過許多基本元素構建。基本元素的選取方式不一樣,構建出來的協程抽象也就有差別。例如,Lua語言選取了create、resume和yield作爲基本構建元素,從調度器層面構建出所謂的“非對程”協程系統;而Julia語言繞過調度器,通過在協程內調用yieldto函數完成了同樣的功能,構建出了一個所謂的對稱協程系統。儘管這兩種語言使用了同樣的setjmp庫,構造出來的原語卻不一樣。又如,許多C語言的協程庫都使用了ucontext庫實現,這是因爲POSIX本身提供了ucontext庫,不少協程實現是以ucontext爲藍本實現的。這些實現,都不可避免地帶上了ucontext系統的一些基本假設,如協程間是平等的,一般帶有調度器來協調協程等(如libtask實現,以及雲風的coroutine庫)。Go語言的一個鮮明特色就是通道(channel)作爲一級對象。因此,resume和yield等在其他語言裏的原語在Go中都以通道方式構建。我們還可以舉出諸多近似的例子。其風格差異往往和語言的歷史、演化路徑、要解決的問題相關,我們不必苛求其協程模型一定要如此這般。

總的來說,協程爲協同任務提供了一種運行時抽象。這種抽象非常適合於協同多任務調度和數據流處理。在現代操作系統和編程語言中,因爲用戶態線程切換代價比內核態線程小,協程成爲了一種輕量級的多任務模型。我們無法預測未來,但可以看到,協程已成爲許多擅長數據處理語言的一級對象。隨着計算機並行性能的提升,用戶態任務調度已成爲一種標準的多任務模型。在這樣的大趨勢下,協程這個簡單且有效的模型就顯得更加引人注目。


本文爲《程序員》原創文章,未經允許不得轉載,訂閱2016年《程序員》請點擊http://dingyue.programmer.com.cn

發佈了70 篇原創文章 · 獲贊 8 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章