這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
前言
近日在學習go語言,go語言的一個重大特色就是支援協程(coroutine),即使用者級線程。由運行在使用者態程式實現“執行體”的調度和切換(本文將一個可並發執行的邏輯單元稱為“執行體”),整個過程運行在一個或多個線程上,執行體切換過程不用“陷入”核心態,因此較為輕量。
這種方式也有一定的缺點,因為協程概念提出的很早,主流語言卻沒有提供支援,必有原因,這個我們以後討論。
當然,為了更好的瞭解goroutine,我們就有必要談談goroutine調度模型的實現。不過,作者是java開發人員,對分析c及彙編源碼之類的事情能力有限,雖然有很多參考資料及公開的源碼,分析來分析去,我也是醉了。所以在看了一些goroutine scheduler資料後,本文從另一個角度切入,描述下一個簡單的協程調度模型如何?。最後的分析可能與goroutine實際的實現不太一致,還望大家見諒。
單線程上的協程調度模型
假設只有一個線程,該線程運行一個發送器,發送器從其管理的執行體集合中取出一個執行體交給線程執行。該線程執行邏輯如下:
如果考慮執行體各種非正常退出的情況:
由此我們可以確定,實現該線程邏輯需要兩個抽象:執行體和發送器。
- 執行體包含:執行體的上下文環境,執行邏輯等
- 發送器:執行體集合(鏈表),執行體切換等
該模型有兩個問題:
- 除非執行體執行完畢、主動放棄或調用系統調用,執行體將一直“佔用”線程。調度器因為沒有“佔用”線程,也只能幹看著。這種非搶佔式方式是協程調度模型的通病。
- 執行體執行過程中可能向執行體集合添加新的執行體,導致該線程負擔過大。
多個線程上協程調度模型
可以想見,如果只是運行在一個線程上,那麼goroutine也太簡單了,接下來談談運行在多個線程上的情況。
不管是運行在幾個線程上,線程總得一個一個啟動,是第一個線程的運行邏輯。當然,在第一個線程啟動前,肯定要為整個調度模型做一些資料結構的初始化工作。
多個線程上調度跟單個線程調度有一個很大的不同就是:一旦執行體鏈表包含的執行體過多,就需要開啟新的線程,轉移部分執行體。反之,如果某個線程執行體鏈表為空白,則需要從全域鏈表或其它線程那裡“挖”執行體。
執行體本身是有狀態的。
- 主動放棄執行。將執行體設定為runnable狀態,然後放入到全域執行體集合。(看來執行體主動放棄執行,不僅是放棄所線上程的執行機會,還不想在本線程繼續呆了)
- channel操作和網路操作等。執行體被置為wating狀態,從本線程執行體集合中剝離,直到其依賴的條件滿足。
- 調用系統調用。
第一個線程開始執行後,go調度模型還會建立一個sysmon線程(負責監控,不運行執行體)還有一個問題,那就是一旦執行體中調用系統調用,整個線程會被阻塞。為了不影響線程中其它執行體的執行,Go語言完全是自己封裝的系統調用,所以在封裝系統調用的時候,可以做不少手腳,也就是進入系統調用的時候執行entersyscall,退出後又執行exitsyscall函數。entersyscall將執行體從當前執行體鏈表中剝離,並將改變P的狀態為syscall。Sysmon監控線程會掃描所有的P,發現一個P處於了syscall的狀態,就知道這個P遇到了執行體在做系統調用,於是系統監控線程就會建立一個新的線程去把這個處於syscall的P給搶過來,開始幹活,這樣這個小車中的其它執行體就可以繞過之前系統調用的等待了。被搶走P的線程等系統調用返回後,發現自己的P沒了,不能繼續幹活了,於是只能把執行系統調用的執行體放回到全域鏈表中,自己睡覺去了。
goroutine調度模型的四個抽象
有了上述鋪墊,我們再來看goroutine調度模型4個重要結構,分別是M、G、P、Sched,前三個定義在runtime.h中,Sched定義在proc.c中。
- Sched結構就是調度器,它維護有儲存M和G的隊列(可能是全域的)以及調度器的一些狀態資訊等。
- M代表核心級線程,一個M就是一個線程,goroutine就是跑在M之上的;M是一個很大的結構,裡面維護小對象記憶體cache(mcache)、當前執行的goroutine、隨機數發生器等等非常多的資訊。
- P全稱是Processor,處理器,它的主要用途就是用來執行goroutine的,所以它也維護了一個goroutine隊列,裡面儲存了所有需要它來執行的goroutine。
- G就是goroutine實現的核心結構了,G維護了goroutine需要的棧、程式計數器以及它所在的M等資訊。
這樣從前到後談下來,是不是更順一些。
參考文獻
goroutine與調度器