這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
原文: Golang調度器源碼分析, 作者: 無心之禍
為什麼Golang需要調度器?
Goroutine的引入是為了方便高並發程式的編寫。 一個Goroutine在進行阻塞操作(比如系統調用)時,會把當前線程中的其他Goroutine移交到其他線程中繼續執行, 從而避免了整個程式的阻塞。
由於Golang引入了記憶體回收(gc),在執行gc時就要求Goroutine是停止的。通過自己實現調度器,就可以方便的實現該功能。 通過多個Goroutine來實現並發程式,既有非同步IO的優勢,又具有多線程、多進程編寫程式的便利性。
引入Goroutine,也意味著引入了極大的複雜性。一個Goroutine既要包含要執行的代碼, 又要包含用於執行該代碼的棧和PC、SP指標。
調度器解決了什麼問題?
棧管理
既然每個Goroutine都有自己的棧,那麼在建立Goroutine時,就要同時建立對應的棧。 Goroutine在執行時,棧空間會不停增長。 棧通常是連續增長的,由於每個進程中的各個線程共用虛擬記憶體空間,當有多個線程時,就需要為每個線程分配不同起始地址的棧。 這就需要在分配棧之前先預估每個線程棧的大小。如果線程數量非常多,就很容易棧溢出。
為瞭解決這個問題,就有了Split Stacks技術: 建立棧時,只分配一塊比較小的記憶體,如果進行某次函數調用導致棧空間不足時,就會在其他地方分配一塊新的棧空間。 新的空間不需要和老的棧空間連續。函數調用的參數會拷貝到新的棧空間中,接下來的函數執行都在新棧空間中進行。
Golang的棧管理方式與此類似,但是為了更高的效率,使用了連續棧 (Golang連續棧) 實現方式也是先分配一塊固定大小的棧,在棧空間不足時,分配一塊更大的棧,並把舊的棧全部拷貝到新棧中。 這樣避免了Split Stacks方法可能導致的頻繁記憶體配置和釋放。
搶佔式調度
Goroutine的執行是可以被搶佔的。如果一個Goroutine一直佔用CPU,長時間沒有被調度過, 就會被runtime搶佔掉,把CPU時間交給其他Goroutine。
調度器的設計
Golang調度器引入了三個結構來對調度的過程建模:
- G 代表一個Goroutine;
- M 代表一個作業系統的線程;
- P 代表一個CPU處理器,通常P的數量等於CPU核心數(GOMAXPROCS)。
三者都在runtime2.go中定義,他們之間的關係如下:
- G需要綁定在M上才能運行;
- M需要綁定P才能運行;
- 程式中的多個M並不會同時都處於執行狀態,最多隻有GOMAXPROCS個M在執行。
早期版本的Golang是沒有P的,調度是由G與M完成。 這樣的問題在於每當建立、終止Goroutine或者需要調度時,需要一個全域的鎖來保護調度的相關對象(sched)。 全域鎖嚴重影響Goroutine的並發效能。 (Scalable Go Scheduler)
通過引入P,實現了一種叫做work-stealing的調度演算法:
- 每個P維護一個G隊列;
- 當一個G被建立出來,或者變為可執行狀態時,就把他放到P的可執行隊列中;
- 當一個G執行結束時,P會從隊列中把該G取出;如果此時P的隊列為空白,即沒有其他G可以執行, 就隨機播放另外一個P,從其可執行檔G隊列中偷取一半。
該演算法避免了在Goroutine調度時使用全域鎖。
調度器的實現
schedule()與findrunnable()函數
Goroutine調度是在P中進行,每當runtime需要進行調度時,會調用schedule()函數, 該函數在proc1.go檔案中定義。
schedule()函數首先調用runqget()從當前P的隊列中取一個可以執行的G。 如果隊列為空白,繼續調用findrunnable()函數。findrunnable()函數會按照以下順序來取得G:
- 調用runqget()從當前P的隊列中取G(和schedule()中的調用相同);
- 調用globrunqget()從全域隊列中取可執行檔G;
- 調用netpoll()取非同步呼叫結束的G,該次調用為非阻塞調用,直接返回;
- 調用runqsteal()從其他P的隊列中“偷”。
如果以上四步都沒能擷取成功,就繼續執行一些低優先順序的工作:
- 如果處於記憶體回收標記階段,就進行記憶體回收的標記工作;
- 再次調用globrunqget()從全域隊列中取可執行檔G;
- 再次調用netpoll()取非同步呼叫結束的G,該次調用為阻塞調用。
如果還沒有獲得G,就停止當前M的執行,返回findrunnable()函數開頭重新執行。 如果findrunnable()正常返回一個G,shedule()函數會調用execute()函數執行該G。 execute()函數會調用gogo()函數(在彙編源檔案asm_XXX.s中定義,XXX代表系統架構),gogo() 函數會從G.sched結構中恢複出G上次被調度器暫停時的寄存器現場(SP、PC等),然後繼續執行。
如何進行搶佔?
runtime在程式啟動時,會自動建立一個系統線程,運行sysmon()函數(在proc1.go中定義)。 sysmon()函數在整個程式生命週期中一直執行,負責監視各個Goroutine的狀態、判斷是否要進行記憶體回收等。
sysmon()會調用retake()函數,retake()函數會遍曆所有的P,如果一個P處於執行狀態, 且已經連續執行了較長時間,就會被搶佔。retake()調用preemptone()將P的stackguard0設為stackPreempt(關於stackguard的詳細內容,可以參考 Split Stacks),這將導致該P中正在執行的G進行下一次函數調用時, 導致棧空間檢查失敗。進而觸發morestack()(彙編代碼,位於asm_XXX.s中)然後進行一連串的函數調用,主要的調用過程如下:
1 |
morestack()(彙編代碼)-> newstack() -> gopreempt_m() -> goschedImpl() -> schedule() |
在goschedImpl()函數中,會通過調用dropg()將G與M解除綁定;再調用globrunqput()將G加入全域runnable隊列中。最後調用schedule() 來用為當前P設定新的可執行檔G。
關於Golang搶佔式調度的進一步學習,可以參考 Go Preemptive Scheduler Design Doc。