這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

http://www.slideshare.net/matthewrdale/demystifying-the-go-scheduler
http://www.cs.columbia.edu/~aho/cs6998/reports/12-12-11_DeshpandeSponslerWeiss_GO.pdf
轉 http://ga0.github.io/golang/2015/09/20/golang-runtime-scheduler.html
1 為什麼Golang需要調度器?
Goroutine的引入是為了方便高並發程式的編寫。 一個Goroutine在進行阻塞操作(比如系統調用)時,會把當前線程中的其他Goroutine移交到其他線程中繼續執行, 從而避免了整個程式的阻塞。
由於Golang引入了記憶體回收(gc),在執行gc時就要求Goroutine是停止的。通過自己實現調度器,就可以方便的實現該功能。 通過多個Goroutine來實現並發程式,既有非同步IO的優勢,又具有多線程、多進程編寫程式的便利性。
引入Goroutine,也意味著引入了極大的複雜性。一個Goroutine既要包含要執行的代碼, 又要包含用於執行該代碼的棧和PC、SP指標。
2 調度器解決了什麼問題?
2.1 棧管理
既然每個Goroutine都有自己的棧,那麼在建立Goroutine時,就要同時建立對應的棧。 Goroutine在執行時,棧空間會不停增長。 棧通常是連續增長的,由於每個進程中的各個線程共用虛擬記憶體空間,當有多個線程時,就需要為每個線程分配不同起始地址的棧。 這就需要在分配棧之前先預估每個線程棧的大小。如果線程數量非常多,就很容易棧溢出。
為瞭解決這個問題,就有了Split Stacks技術: 建立棧時,只分配一塊比較小的記憶體,如果進行某次函數調用導致棧空間不足時,就會在其他地方分配一塊新的棧空間。 新的空間不需要和老的棧空間連續。函數調用的參數會拷貝到新的棧空間中,接下來的函數執行都在新棧空間中進行。
Golang的棧管理方式與此類似,但是為了更高的效率,使用了連續棧 (Golang連續棧) 實現方式也是先分配一塊固定大小的棧,在棧空間不足時,分配一塊更大的棧,並把舊的棧全部拷貝到新棧中。 這樣避免了Split Stacks方法可能導致的頻繁記憶體配置和釋放。
2.2 搶佔式調度
Goroutine的執行是可以被搶佔的。如果一個Goroutine一直佔用CPU,長時間沒有被調度過, 就會被runtime搶佔掉,把CPU時間交給其他Goroutine。
3 調度器的設計
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調度時使用全域鎖。
4 調度器的實現
4.1 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等),然後繼續執行。
4.2 如何進行搶佔?
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中)然後進行一連串的函數調用,主要的調用過程如下:
morestack()(彙編代碼)-> newstack() -> gopreempt_m() -> goschedImpl() -> schedule()
在goschedImpl()函數中,會通過調用dropg()將G與M解除綁定;再調用globrunqput()將G加入全域runnable隊列中。最後調用schedule() 來用為當前P設定新的可執行檔G。
關於Golang搶佔式調度的進一步學習,可以參考 Go Preemptive Scheduler Design Doc。
http://blog.csdn.net/heiyeshuwu/article/details/51178268
http://blog.amalcao.me/blog/2014/05/09/erlang-and-go-de-bing-fa-diao-du-qian-xi/
Erlang & Go 的並發調度淺析
作為當前業界比較關注的兩種面向並發領域的程式設計語言,Erlang和Go的調度是如何?的?
Go 語言和 Erlang 都是面向並發應用的語言,都採用輕量級線程和訊息傳遞模型。儘管Go在文法上也支援共用,但必須以通訊的方式同步方能保證其正確性。Erlang則是完全不支援進程間的共用,狀態資訊完全需要依靠訊息彼此傳遞。
從底層來看,在 Google 官方編譯器中,Go 語言的 Goroutine 是一種類似協程的結構,由於採用了定製的C編譯器來構建,因此其環境切換的效率要高於C庫的 coroutine(只需要切換PC和棧幀,其他寄存器由函數調用者負責儲存); 而在 Go 的 GCC 前端中,Goroutine 則直接由C庫的 coroutine 機制實現。由於 Erlang 是基於 BEAM 虛擬機器執行的,因此它的所謂 “輕量進程” 也就僅僅是 BEAM 上的概念,不對應C語言或OS級的概念。
從調度策略來看,Go 完全是協作式調度,一個執行中的 Goroutine 僅在操作被阻塞或顯示讓出處理器時被切換出去,Goroutine之間也沒有優先順序之分; Erlang 則採用一種名為“Reduction-Counting”的輪轉調度策略,並且存在4個進程優先順序。
值得注意的是在 Go 1.2 版之後,增加了一些簡單的搶佔機制,但僅有使用者程式函數調用時刻才可能觸發搶佔的判斷,並不是真正意義上的搶佔,具體思想參見這裡。
Go 的調度器的最新版實現了M:N的調度方式,通過 GOMAXPROCS
指定最大的並行能力; Erlang 的 BEAM 虛擬機器也支援SMP方式,一般情況下以系統的核心數或硬體執行緒數作為其調度器個數,每個調度器會綁定到一個OS線程,IO 等阻塞型操作由單獨的系統線程負責調度。
Go 的Server Load Balancer一般是採用 “Work-Stealing” 方式;Erlang則是維護一個“任務遷移隊列”,調度器會定期計算任務遷移的路徑。此外,Erlang也提供了“Work-Stealing” 方式作為補充。充。
Go的調度模型簡介
對於線程調度器,一般有3中模型:
- N:1,即多個使用者線程運行在一個OS線程上
- 1:1,即使用者線程和OS線程一一對應
- N:M,即一定數量的使用者線程映射到一定數量的OS線程上
第一種方式的優點是使用者線程切換較快,但可擴充性不好,難以很好發揮多核處理器的並行性(libtask 屬於該類型); 而第二種與之相反,其能很好的利用多核並行性,但是使用者線程資源開銷和調度成本都比較大。 第三種方式理論上能在調度開銷和並行性之間取得較好的折衷。
在Go 1.1 中,Dmirty Vyukov 對調度器進行了重新設計,由原來的 1:1 模型進化到 M:N 模型,從而使 Go 在並行編程效能上有了顯著的提升。
Go 的新調度器模型主要涉及3個核心概念:M、P及G,如所示:
M 代表OS的線程,P代表當前系統的處理器數(一般由GOMAXPROCS
環境變數指定),G代表Go語言的使用者級線程,也就是通常所說的 Goroutine。
新的調度器由1:1 進化到 M:N 的關鍵在於新加了 P 這個抽象結構。在多核平台上,P的數量一般對應處理器核心或硬體執行緒的數量,調度器需要保證所有的P都有G執行,以保證並行度。
M 必須與P綁定方能執行任務G,如所示:
在舊版 Go 調度器實現中,由於缺少P, 一旦運行 G (goroutine)的 M (OS線程)陷入阻塞狀態(如調用某個阻塞的系統調用)時,M 對應的 OS 線程就會被作業系統調度出去,從而導致系統中其他就緒的G也不能執行;而添加了P這個邏輯結構後,一旦發生上述情況,阻塞的 M 將被與其對應的 P 剝離,RUNTIME會再分配一個 M 並將其與已經剝離出來的 P 綁定,運行其他就緒的G。這個過程如所示:
在實際實現中,考慮到代碼執行的局部性因素,一般會傾向於延遲 M 與 P 剝離的時機。具體來說,RUNTIME中存在一個駐留的線程sysmon,用來監控所有進入Syscall 的 M,只有當 Syscall 執行時間超出某個閾值時,才會將 M 與 P 分離。
另外一個保證系統運行穩定性的方式是負載平衡機制,在Go中,用了 “任務竊取” 的方法。
首先介紹一下 Go 的任務隊列,每個 P 都有一個私人的任務隊列 (實現上是一個用數組表示的迴圈鏈表)以及一個公用隊列(單鏈表表示),私人隊列的功能是為了減輕公用隊列的競爭開銷。
當一個 P 的私人任務隊列為空白時,它會從全域隊列中尋找就緒態的 G 執行;如果全域隊列也為空白,則會隨機播放竊取其他 P 私人執行隊列中的任務G,從而保證所有線程儘可能以最大負載工作。其如下:
由於 P 的私人隊列採用了數組結構,很容易計算出隊列中間的位置,因此“竊取者” 採用了與 “被竊取者” 均分任務的方法,以儘可能達到負載平衡。
無論從公用隊列取任務還是進行“竊取”,都會引起一定的競爭開銷,因此 RUNTIME 會傾向於將建立任務或新轉變為就緒態的任務添加到當前執行 P 的私人隊列中。 僅當執行的任務調用 yield 機制讓出處理器或進入了一個長時間執行的系統調用時,該任務才會被添加到公用隊列中。
以上關於Go調度器的部分內容及圖片轉自:http://morsmachine.dk/go-scheduler
Erlang的調度模型簡介
由於 Erlang 程式是運行在 BEAM 虛擬機器之上,因此其調度器在實現上和 Go 等 Native 語言存在較大的差異,但其內部涉及的基本原理都是類似的,可以互相參考。
早期的 BEAM 虛擬機器是單線程啟動並執行,直到2006年才引入了 SMP 版本的 BEAM 虛擬機器,經過了若干早期版本的演化,逐漸形成了今天的版本。最新版本的Erlang可以通過命令列參數指定是否啟用 SMP 版本虛擬機器。
BEAM 上的調度單位是“輕量進程”,這是一種虛擬機器上的輕量級執行線索(由於 Erlang 的 process 是不共用記憶體的,行為更像進程而非線程,因此我們在這裡叫它“輕量進程”)。每個 Erlang 進程包括一個控制塊(PCB)、一個棧和私人的堆空間,一些特殊的結構,如位元據,ETS 表是進程間共用的,使用全域堆空間。
BEAM 虛擬機器裡存在一些並行的調度器,一般情況下,一個調度器會映射為一個 OS 線程,這種方式類似於早期的Go語言實現(只有M和G,沒有P),每個調度器擁有各自的任務隊列,調度器之間的Server Load Balancer通過引入專門的任務遷移機製得以實現。其原理如所示:
通常,調度器的數量與運行平台的處理器核心數或硬體執行緒數相等,也可以通過 BEAM 命令列參數指定,或在運行時動態修改。
在BEAM系統中,除了process之外,還存在三種其他的調度單位:連接埠(ports)、鏈入式驅動(linkd-in drivers)和系統級活動(system level activities); 這三種特殊的任務形式主要用來進行IO操作和執行其他語言的代碼等功能,其部分功能很像 Go 中對執行阻塞 Syscall 任務的“剝離”機制,具體實現方法這裡暫時不討論。我們主要將精力集中在 Erlang 的 process 的調度機制上。
與 Go 不同,Erlang 的調度器是一個輪轉而非協作式的調度器,每個進程建立時會被分配一個稱為“reduction”的值,是一個計算量的度量(基本上等同於函數調用的次數),類似 OS 的時間片。進程每執行一定量的計算後,reduction值就會累計,一旦達到閾值,該進程就會給切換出去。這種調度方式在 Erlang 中被稱為 “reduction-counting”。
採用輪轉的調度方式能更好的防止程式設計不當而導致的個別進程餓死的情況,同時能夠實現更好的即時性功能。
同時,Erlang還為進程提供了四個不同的優先順序:max,high,normal和low。不同優先順序進程按優先順序調度;同級進程按輪轉方式調度。每個調度器包含3個任務隊列,Max和High具有單獨的隊列,normal和low則位於同一個隊列 —— 調度器忽略一定次數的low級進程來實現二者間的差別。
Erlang 調度器之所以能夠實現優先順序輪轉調度,主要是得益於其基於虛擬機器的執行方式:由於每條Erlang指令都需要經過 BEAM 解釋執行,因此 process 的運行完全處於BEAM的監控之下,BEAM可以方便的完成對進程的切換。與之相對,由於 Go 的 Goroutine 與 RUNTIME 都是 Native 執行的,其在執行上的地位是平等的,RUNTIME 沒有能力切換一個執行中的 Goroutine,除非其自己調出或調用RUNTIME 功在 ,因而只能實現協作調度。
註: Go 1.2 中,添加了簡單的“使用者態”任務搶佔機制,主要是在系統線程sysmon中監控Goroutine的執行時間,然後藉助“動態棧擴充”機制,在函數調用時刻切入RUNTIME並實現搶佔。這種方式雖然很巧妙,但對某些特殊的情況,如沒有調用非inline函數的耗時計算等,就沒有多大效果力了。
Erlang 調度器通過定期進行“任務遷移”來達到Server Load Balancer。“任務遷移”過程在同一時刻只能由一個調度器發起。首先,根據各調度器的任務隊列的長度計算一個叫“Migtation limit”的值,這個值就是各調度器就緒隊列長度的均值;然後,開始計算“Migataion Path”,演算法是:
- 計算各隊列長度與“Migtation limit”差值
- 找到差值中正最大和負最小的隊列,記錄一個從前者到後者進行任務遷移路徑,以達到二者都接近“Migtation limit”
- 重複步驟1,直到達到負載平衡
顯示了上述演算法的執行個體:
“Migatation Path” 計算完成後,在每個調度時刻,調度器都會檢查該路徑,根據其指導去抓取(pull)或推送(push)相應任務隊列的任務。這一步驟完成了真正的負載平衡。
作為“任務遷移”機制的補充,Erlang調度器還支援“任務竊取”機制:當一個活躍的調度器自己的任務隊列為空白且不能通過“任務遷移路徑”抓取任務時,它會主動竊取其他調度器任務隊列上的就緒任務,如果仍然沒有可供執行的任務,則該調度器進入Waiting狀態。
關於Erlang調度模型,主要部分參考了這篇文章的第三章及Erlang/OTP源碼。
結論
通過上述簡單對比,我們大體上瞭解了Erlang和Go兩種語言在並發任務調度上的異同,可以說二者各有優缺點:Go 的調度模型更加高效(Native)而 Erlang 則提供了更強大的功能(即時性、優先順序)。
關於調度器,其實還有很多內容,如 Go 和 Erlang 都支援“記憶體回收”,而GC在兩種語言中對調度的影響如何等;同時,講Go的 M:N 調度時說到 M 一旦陷入Syscall 阻塞後,系統會建立一個新的M(OS 線程)來接管 P 及其任務隊列,那麼當設計一個高度並發的IO系統時(如 Web 服務器),頻繁的Syscall會導致大量 OS 線程建立,從而影響效能。Go如何解決這個問題呢?
在後續分析中,會針對 IO 和 GC 部分進行更加深入的討論,以解答餘下的有關調度器的問題。
特別說明: 由於Go語言正處於高速發展的階段,因此一些現在分析的內容可能會隨時更新,在本文完成時, 其穩定版本是 1.2 , 而包含大量更新的 1.3 版也呼之欲出,因此若本文內容不免出現滯後或錯誤,請大家及時指正!