協程初步筆記
Coroutine 的概念說難不難說簡單也不簡單。昨天查閱了一些東西大致上感覺搞懂了,沒多少實踐光是看了下 Wiki 就敢寫文章講一個概念,這種蜜汁自信到底是從哪裡來的?不可能沒有錯,希望能指出。
(可能線程和進程都會專門寫筆記記錄,這裡為了流暢,大致說一下。)
先來看進程。進程一開始作為操作系統調度任務和管理資源、許可權等的基本單位。因為進程要乾的事情那麼多,所以操作系統會為每個進程創建很多數據結構,也就是進程的 context。當系統決定切換進程的時候這些 context 都需要切換,開銷很大。
於是就有了線程,線程將資源管理和調度分離,在同一個進程的不同線程間切換的時候就只需要切換調度相關的 context 就行了,開銷更小了。而且因為資源管理是在一起的,所以線程間能很方便的共享資源。
但對線程的執行是系統調度的,系統有一套精密的搶佔式多任務調度演算法,但是調度行為很難被程序所控制,所以不能對不同間線程的執行順序有過多的假定引起了很多問題。
與之對應的有用戶級線程的概念,在一個單獨的進程里用自身的調度邏輯來調度「線程」。當然不是說使用用戶級線程就是為了解決線程的問題,只是說和操作系統調度相對應,是自己調度。
用戶級線程實際上很難說是一般意義的線程,實際上是在單線程的邏輯中模擬控制流調度的產物,如果不用別的方式配合是沒有辦法光靠用戶級線程來真正創建多個系統線程運行在不同核心的。(之後打算看看 goroutine 怎麼實現的。)
但是我們很多時候需要線程不是因為想要達到並行,僅僅是為了並發而已,一條控制流也夠了,只要不阻塞。
協程就是用戶級線程的一種實現方式。
和現代大多數操作系統採用的搶佔式多任務不同,協程靠的是協作式多任務。意思是控制流不會被中斷,除非自己主動讓渡自身控制權給調度器。讓渡的時候也會保存上下文。
協作式多任務用在操作系統上是落後的,但是重生在單一線程上卻很有用,因為程序員能整體把控自己程序的控制流,而且協作式多任務的控制流切換也往往更可控。
好,現在甩甩頭,從另一方面來看,有人說協程是函數(子常式,subroutines)的推廣,那麼我們先從函數看起。
函數在數據上就是串指令序列而已,函數的數據編譯時就確定,被載入以後,在調用前後都不會在內存中駐留任何東西,執行一個函數只需要知道它的地址就行了。而在執行時函數就短暫地申請棧空間,函數的 context 可以看作是棧中的數據,寄存器尤其是 pc 寄存器的值所組成的。
那看函數的推廣:closure 函數(很多語言所有函數都是 closure),和函數不同,closure 在執行前後都不是安靜而純粹的指令,被創建出來以後就能看成一個運行時對象了。
Closure 的代碼一般和函數一樣是編譯時就確定的,但是創建時還需要捕獲 closure 內對外部對象的引用。C++ 和 Java 的玩家估計能猜到,這兩種語言實現 closure 的方法就是編譯成小類,然後運行時動態捕獲。這種捕獲的過程似乎叫 upvalue,所以 closure 就能看做普通函數+外部對象結構體對象,同一段代碼會生成很多不同的 closure 對象,它們都不是一個對象,儘管函數指針都指向同一個函數。(不過它們有相同的類型。)
所以 closure 的運行時除了函數那些,還有默認傳入的一個結構體對象,可以訪問到創建時捕獲的外部對象的引用。但 closure 的執行規則,比如說棧規則,比如說局部變數規則都是和普通函數無二的,只是作為一個對象能訪問到特殊的捕獲結構體而已。
現在到了主角 coroutine 了,和 closure 相似 coroutine 也是一個在普通函數之上的運行時對象。但是它們兩者的區別是,closure 作為對象,除了函數指針,只儲存函數所需的外部對象的引用。而 coroutine,並不需要儲存外部的引用,更關心的是函數執行過程中內部的狀態。
也就是說,和 closure 相似,作為一個對象儲存自身所需的信息,但是 coroutine 儲存的是內部局部變數的執行狀態,以及自身上次執行到的位置。普通函數這些 context 都是短暫居留的,當返回的時候 pc 會跳轉,棧內的局部變數都會被丟棄,而 coroutine 卻保存這些狀態。coroutine 退出以後因為 context 始終保存的原因,實際上是一種掛起的狀態,而再次被調用的時候就是從保存的 context 中恢復執行狀態。這和線程上下文切換是類似的,所以可以看作用戶級線程。
一方面像進程,但另一方面又可以從函數的角度看,coroutine 每次退出的時候都可以返回一個值,就像普通函數一樣。實際上開頭也說了,從功能上來說能把一個 coroutine 當作函數的加強版。
一般語言,coroutine 就是通過 yield 來產出值的,從線程的角度上來看,使用 yield 以後的 coroutine 並沒有消失,而是掛起,交出控制權,並等待下次被調度。從函數過程角度上來說,yield 以後 coroutine 自身就已經保存 context 、終止而返回了。
再仔細看看 yield 後發生什麼,如果是一個普通過程調用的 coroutine,那麼就控制權回到調用者之處,並且調用者得到所 yield 的返回值。而如果 coroutine yield 的是另一個 coroutine 的話,coroutine 也會立即保存 context 並退出,然後重啟所 yield 的 coroutine,也就是說把控制權轉出。
這裡很重要的就是 coroutine 之間的 yield 不能用 return 來理解,而是一種調度,它們間是平等的,也就是說,不存在被調用者要返回到調用者調用處這樣的關係,要回到調用者,必須顯式 yield 原調用者,以一種類似相互遞歸的方式回到剛才的位置。(yield 除了產出,還有讓出的意思)
維基上的例子:
var q := new queuencoroutine producen loopn while q is not fulln create some new itemsn add the items to qn yield to consumenncoroutine consumen loopn while q is not emptyn remove some items from qn use the itemsn yield to producen
而如果不去調度另一個 coroutine,直接 yield 一個普通值的話,最後的結果就是值會被返回到所有 coroutine 調用鏈條之前的那個通常調用處。
也就是說如果你在普通函數 F 里普通地調用 A,A yield B,B yield C,C yield 42,那麼 42 會直接返回到 F。其中 ABC 都是 coroutine。
實際上不要求 F 是普通函數,就算 F 也是 coroutine 也沒關係,只和調用方式有關:yield 調用時 coroutine 會立刻退出,棧也會直接釋放。
而這種調用規則比較適合利用對代碼 CPS 變換來實現……「我應該返回到哪兒」通過 yield 間傳遞 continuation 能優雅地實現。
至於在非同步上具體的使用模式,還不是很清楚,感覺可以配合一些外部的非同步原語和事件循環來調度。可以參考各種語言相關的庫。
基本參考於 Wikipedia。
推薦閱讀:
※使用coroutine實現狀態機(2)
※Kotlin雜談(五) - Coroutines(三): 基本語法
※Unity 協程運行時的監控和優化
※使用coroutine實現狀態機