如果你已經閱讀了前2篇文章:《調度起源》和《宏觀看調度器》,你對G、P、M肯定已經不再陌生,我們這篇文章就介紹Go調度器的基本原理,本文總結了12個主要的場景,覆蓋了以下內容:
- G的創建和分配。
- P的本地隊列和全局隊列的負載均衡。
- M如何尋找G。
- M如何從G1切換到G2。
- work stealing,M如何去偷G。
- 為何需要自旋線程。
- G進行系統調用,如何保證P的其他G'可以被執行,而不是餓死。
- Go調度器的搶占。
12場景
提示:圖在前,場景描述在后。
上圖中三角形、正方形、圓形分別代表了M、P、G,正方形連接的綠色長方形代表了P的本地隊列。
場景1:p1擁有g1,m1獲取p1后開始運行g1,g1使用go func()
創建了g2,為了局部性g2優先加入到p1的本地隊列。
場景2:g1運行完成后(函數:goexit
),m上運行的goroutine切換為g0,g0負責調度時協程的切換(函數:schedule
)。從p1的本地隊列取g2,從g0切換到g2,并開始運行g2(函數:execute
)。實現了線程m1的復用。
場景3:假設每個p的本地隊列只能存4個g。g2要創建了6個g,前4個g(g3, g4, g5, g6)已經加入p1的本地隊列,p1本地隊列滿了。
藍色長方形代表全局隊列。
場景4:g2在創建g7的時候,發現p1的本地隊列已滿,需要執行負載均衡,把p1中本地隊列中前一半的g,還有新創建的g轉移到全局隊列(實現中并不一定是新的g,如果g是g2之后就執行的,會被保存在本地隊列,利用某個老的g替換新g加入全局隊列),這些g被轉移到全局隊列時,會被打亂順序。所以g3,g4,g7被轉移到全局隊列。
場景5:g2創建g8時,p1的本地隊列未滿,所以g8會被加入到p1的本地隊列。
場景6:在創建g時,運行的g會嘗試喚醒其他空閑的p和m執行。假定g2喚醒了m2,m2綁定了p2,并運行g0,但p2本地隊列沒有g,m2此時為自旋線程(沒有G但為運行狀態的線程,不斷尋找g,后續場景會有介紹)。
[站外圖片上傳中...(image-d0a5f1-1554527569260)]
場景7:m2嘗試從全局隊列(GQ)取一批g放到p2的本地隊列(函數:findrunnable
)。m2從全局隊列取的g數量符合下面的公式:
n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))
公式的含義是,至少從全局隊列取1個g,但每次不要從全局隊列移動太多的g到p本地隊列,給其他p留點。這是從全局隊列到P本地隊列的負載均衡。
假定我們場景中一共有4個P,所以m2只從能從全局隊列取1個g(即g3)移動p2本地隊列,然后完成從g0到g3的切換,運行g3。
場景8:假設g2一直在m1上運行,經過2輪后,m2已經把g7、g4也挪到了p2的本地隊列并完成運行,全局隊列和p2的本地隊列都空了,如上圖左邊。
全局隊列已經沒有g,那m就要執行work stealing:從其他有g的p哪里偷取一半g過來,放到自己的P本地隊列。p2從p1的本地隊列尾部取一半的g,本例中一半則只有1個g8,放到p2的本地隊列,情況如上圖右邊。
場景9:p1本地隊列g5、g6已經被其他m偷走并運行完成,當前m1和m2分別在運行g2和g8,m3和m4沒有goroutine可以運行,m3和m4處于自旋狀態,它們不斷尋找goroutine。為什么要讓m3和m4自旋,自旋本質是在運行,線程在運行卻沒有執行g,就變成了浪費CPU?銷毀線程不是更好嗎?可以節約CPU資源。創建和銷毀CPU都是浪費時間的,我們希望當有新goroutine創建時,立刻能有m運行它,如果銷毀再新建就增加了時延,降低了效率。當然也考慮了過多的自旋線程是浪費CPU,所以系統中最多有GOMAXPROCS個自旋的線程,多余的沒事做線程會讓他們休眠(見函數:notesleep()
)。
場景10:假定當前除了m3和m4為自旋線程,還有m5和m6為自旋線程,g8創建了g9,g8進行了阻塞的系統調用,m2和p2立即解綁,p2會執行以下判斷:如果p2本地隊列有g、全局隊列有g或有空閑的m,p2都會立馬喚醒1個m和它綁定,否則p2則會加入到空閑P列表,等待m來獲取可用的p。本場景中,p2本地隊列有g,可以和其他自旋線程m5綁定。
場景11:(無圖場景)g8創建了g9,假如g8進行了非阻塞系統調用(CGO會是這種方式,見cgocall()
),m2和p2會解綁,但m2會記住p,然后g8和m2進入系統調用狀態。當g8和m2退出系統調用時,會嘗試獲取p2,如果無法獲取,則獲取空閑的p,如果依然沒有,g8會被記為可運行狀態,并加入到全局隊列。
場景12:(無圖場景)Go調度在go1.12實現了搶占,應該更精確的稱為請求式搶占,那是因為go調度器的搶占和OS的線程搶占比起來很柔和,不暴力,不會說線程時間片到了,或者更高優先級的任務到了,執行搶占調度。go的搶占調度柔和到只給goroutine發送1個搶占請求,至于goroutine何時停下來,那就管不到了。搶占請求需要滿足2個條件中的1個:1)G進行系統調用超過20us,2)G運行超過10ms。調度器在啟動的時候會啟動一個單獨的線程sysmon,它負責所有的監控工作,其中1項就是搶占,發現滿足搶占條件的G時,就發出搶占請求。
場景融合
如果把上面所有的場景都融合起來,就能構成下面這幅圖了,它從整體的角度描述了Go調度器各部分的關系。圖的上半部分是G的創建、負債均衡和work stealing,下半部分是M不停尋找和執行G的迭代過程。
如果你看這幅圖還有些似懂非懂,建議趕緊開始看雨痕大神的Golang源碼剖析,章節:并發調度。
總結,Go調度器和OS調度器相比,是相當的輕量與簡單了,但它已經足以撐起goroutine的調度工作了,并且讓Go具有了原生(強大)并發的能力,這是偉大的。如果你記住的不多,你一定要記住這一點:Go調度本質是把大量的goroutine分配到少量線程上去執行,并利用多核并行,實現更強大的并發。
下集預告
下篇會是源碼層面的內容了,關于源碼分析的書籍、文章可以先看起來了,先劇透一篇圖,希望閱讀下篇文章趕緊關注本公眾號。
推薦閱讀
參考資料
在學習調度器的時候,看了很多文章,這里列一些重要的:
- The Go scheduler: https://morsmachine.dk/go-scheduler
- Go's work-stealing scheduler: https://rakyll.org/scheduler/,中文翻譯版: https://lingchao.xin/post/gos-work-stealing-scheduler.html
- Go夜讀:golang 中 goroutine 的調度: https://reading.developerlearning.cn/reading/12-2018-08-02-goroutine-gpm/
- Scheduling In Go : Part I、II、III: https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html,中文翻譯版: http://www.lxweimin.com/p/cb6881a2661d
- 雨痕大神的golang源碼剖析: github.com/qyuhen/book
- 也談goroutine調度器: https://tonybai.com/2017/06/23/an-intro-about-goroutine-scheduler/
- kavya的調度PPT: https://speakerdeck.com/kavya719/the-scheduler-saga
- 搶占的設計提案,Proposal: Non-cooperative goroutine preemption: https://github.com/golang/proposal/blob/master/design/24543-non-cooperative-preemption.md
- 如果這篇文章對你有幫助,請點個贊/喜歡,感謝。
- 本文作者:大彬
- 如果喜歡本文,隨意轉載,但請保留此原文鏈接:http://lessisbetter.site/2019/04/04/golang-scheduler-3-principle-with-graph/