goroutine 排程器(scheduler)

衣舞晨風發表於2018-01-14

雖然golang的最小排程單元為協程(goroutine),但是作業系統最小的排程單元依然還是執行緒,所以golang scheduler(golang排程器)其要做的工作是如何將眾多的goroutine放在有限的執行緒上進行高效而公平的排程。

作業系統的排程不失為高效和公平,比如CFS排程演算法,那麼go為何要引入goroutine?原因很多,有人會說goroutine 相比於linux的pthread機制使用很方便。但是核心原因為goroutine的輕量級,無論是從程式到執行緒,還是從執行緒到協程,其核心都是為了使得我們的排程單元更加輕量級,我們可以輕易得建立幾萬幾十萬的goroutine而不用擔心記憶體耗盡等問題。golang引入goroutine試圖在語言核心層做到足夠高效能得同時(充分利用多核優勢、使用epoll高效處理網路/IO、實現垃圾回收等機制)儘量簡化程式設計。

ps:人類社會的發展是生產工具不斷髮展解放生產力的過程,語言的發展也是一樣,從機器語言到組合語言、C語言、物件導向C++\ Java以及到現在層出不窮的動態語言。程式語言作為生產工具,其發展核心目的為最大化IT工作人員的生產力。從這點看,我很看好go語言,極簡得程式設計之道簡直大愛。

以下基於Daniel Morsing的一篇文章介紹goroutine排程器。

首先基於執行緒,使用者態協程可以選擇以下3種排程機制。

N:1 即多個使用者態協程執行在一個os執行緒上,這種方式的優點是可以很快得進行上下文切換,但是缺點是不能利用多核優勢。

1:1 一個使用者態協程對應一個os執行緒,這種方式得優點是可以利用到多核的優勢,但是協程的排程完全依賴於os執行緒的排程,而os執行緒的排程的上線文切換的代價又比較大,從而導致這種模型排程的上下文切換代價比較大。

M:N golang scheduler 使用的m:n排程模型,即任意數量的使用者態協程可以執行在任意數量的os執行緒上,這樣不僅可以使得上線文切換更加輕量級,同時又可以充分利用多核優勢。 為了實現這種排程機制,golang 引入如下3個大結構:
這裡寫圖片描述
M:os執行緒(即作業系統核心提供的執行緒)

G:goroutine,其包含了排程一個協程所需要的堆疊以及instruction pointer(IP指令指標),以及其他一些重要的排程資訊。

P:M與P的中介,實現m:n 排程模型的關鍵,M必須拿到P才能對G進行排程,P其實限定了golang排程其的最大併發度。
這裡寫圖片描述
如上圖所示,2個M分別拿到context P在執行G,M只有拿到context P才能執行goroutine。被執行的goroutine在執行過程中呼叫 go func() ,會建立一個新的對應func() 的goroutine,並將這個goruotine加入到runqueue(就緒待排程的goroutine佇列,如上圖灰色部分所示),當前執行的goroutine在達到排程點(系統呼叫、網路IO、等待channel等)的時候,P會掛起當前執行的goroutine,從runqueue中pop一個goroutine,重新設定當前M的執行上下文繼續執行(即設定為pop出來的goroutine對應的執行堆疊以及IP(instruction Point))

仔細觀察我們可以發現,與golang的前一個版本的排程器不同,當前並不是採用全域性的runqueue佇列,而是每個P都對應有自己的一個local的runqueue,這樣可以避免每一次排程都進行一次鎖競爭,在32核機器上,鎖競爭會導致效能變得非常糟糕。

P的數量由使用者設定的GOMAXPROCS決定,當前不論GOMAXPROCS設定為多大,P的數量最大為256。由於M必須拿到P才能夠對G進行排程,所以P實際上限制了go的最大併發度,256對於現在的伺服器已經足夠使用了。因為P並不會由於當前排程的goroutine阻塞而不可用,只要當前還有可排程的goroutine,P始終會使用M繼續進行排程執行。所以其實P只需要設定為當前CPU的最大核數即可。

如果一切正常,排程器會以這樣一種方式簡單得執行,以下分析goroutine在兩種例外情況下的行為。

一、系統呼叫

這裡寫圖片描述

golang語言能夠控制執行緒M在使用者態執行情況下的排程行為,但是卻不能控制執行緒在陷入核心之後的行為。即在我們呼叫system call陷入核心沒有返回之前,go其實已經控制不了。那怎麼辦呢?如果當前陷入system call的執行緒在持有P的情況下Block很長時間,會導致其他可執行的goroutine由於沒有可用的P而不能得到排程。

為了保證排程的併發型,即保證拿到P的M能夠持續進行排程而不是block,golang 排程器也沒有很好的辦法,只能在進入系統呼叫之前從執行緒池拿一個執行緒或者新建一個執行緒的方式,將當前P交給新的執行緒M1從而使得runqueue中的goroutine可以繼續進行排程。當前M0則block在system call中等待G0返回。 在G0返回之後需要繼續執行,而繼續執行的條件是必須擁有一個P,如果當前沒有可用的P,則將G0放到全域性的runqueue中等待排程,M0退出或者放入執行緒池睡覺去。而如果有空閒的P,M0在拿到P之後繼續進行排程。(P的數量很好的控制了併發度)

P在當前local runqueue中的G全部排程完之後從global runqueue中獲取G進行排程,同樣系統也會定期檢查golobal runqueue中的G,以確保被放入global runqueue中的goroutine不會被餓死。

PS:golang對這層排程其實做了一定的優化,不是在一開始進行系統呼叫之前就新建一個新的M,而是使用一個全域性監控的monitor(sysmon),定期檢查進入系統呼叫的M,只有進入時間過長才會新建一個M。另外golang 底層基本對所有的IO都非同步化了,比如網路IO,golang底層在呼叫read返回EAGAIN錯誤的時候會將當前goroutine掛起,然後使用epoll監聽這個網路fd上的可讀事件,在當有資料可讀的時候喚醒對應的goroutine繼續進行排程。其中epoll事件管理執行緒即golang虛擬機器的sysmon執行緒。

二、stealing work

這裡寫圖片描述

為了使得排程更加公平和充分,golang引入了work steal排程演算法。 在P local runqueue上的goroutine全部排程完了之後,對應的M不會傻傻得等在那裡睡覺,而首先會嘗試從global runqueue中獲取goroutine進行排程。如果golbal runqueue中沒有goroutine,如上圖所示,當前M會從別的M對應P的local runqueue中搶一半的goroutine放入自己的P中進行排程。

三、goroutine狀態遷移

從上面的介紹可以發現,排程器會使goroutine在各種狀態來回切換。下圖使用”goroutine狀態遷移圖”來形象得描述goroutine在排程週期中的生老病死。
這裡寫圖片描述

原文地址:https://studygolang.com/articles/3491

個人微信公眾號:
這裡寫圖片描述

作者:jiankunking 出處:http://blog.csdn.net/jiankunking

相關文章