Go調度器系列(3)圖解調度原理

如果你已經閱讀了前2篇文章:《調度起源》《宏觀看調度器》,你對G、P、M肯定已經不再陌生,我們這篇文章就介紹Go調度器的基本原理,本文總結了12個主要的場景,覆蓋了以下內容:

  1. G的創建和分配。
  2. P的本地隊列和全局隊列的負載均衡。
  3. M如何尋找G。
  4. M如何從G1切換到G2。
  5. work stealing,M如何去偷G。
  6. 為何需要自旋線程。
  7. G進行系統調用,如何保證P的其他G'可以被執行,而不是餓死。
  8. Go調度器的搶占。

12場景

提示:圖在前,場景描述在后。

image

上圖中三角形、正方形、圓形分別代表了M、P、G,正方形連接的綠色長方形代表了P的本地隊列。

場景1:p1擁有g1,m1獲取p1后開始運行g1,g1使用go func()創建了g2,為了局部性g2優先加入到p1的本地隊列。

image

場景2g1運行完成后(函數:goexit),m上運行的goroutine切換為g0,g0負責調度時協程的切換(函數:schedule。從p1的本地隊列取g2,從g0切換到g2,并開始運行g2(函數:execute)。實現了線程m1的復用

image

場景3:假設每個p的本地隊列只能存4個g。g2要創建了6個g,前4個g(g3, g4, g5, g6)已經加入p1的本地隊列,p1本地隊列滿了。

image

藍色長方形代表全局隊列。

場景4:g2在創建g7的時候,發現p1的本地隊列已滿,需要執行負載均衡,把p1中本地隊列中前一半的g,還有新創建的g轉移到全局隊列(實現中并不一定是新的g,如果g是g2之后就執行的,會被保存在本地隊列,利用某個老的g替換新g加入全局隊列),這些g被轉移到全局隊列時,會被打亂順序。所以g3,g4,g7被轉移到全局隊列。

image

場景5:g2創建g8時,p1的本地隊列未滿,所以g8會被加入到p1的本地隊列。

image

場景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。

image

場景8:假設g2一直在m1上運行,經過2輪后,m2已經把g7、g4也挪到了p2的本地隊列并完成運行,全局隊列和p2的本地隊列都空了,如上圖左邊。

全局隊列已經沒有g,那m就要執行work stealing:從其他有g的p哪里偷取一半g過來,放到自己的P本地隊列。p2從p1的本地隊列尾部取一半的g,本例中一半則只有1個g8,放到p2的本地隊列,情況如上圖右邊。

image

場景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())。

image

場景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源碼剖析,章節:并發調度。

image

總結,Go調度器和OS調度器相比,是相當的輕量與簡單了,但它已經足以撐起goroutine的調度工作了,并且讓Go具有了原生(強大)并發的能力,這是偉大的。如果你記住的不多,你一定要記住這一點:Go調度本質是把大量的goroutine分配到少量線程上去執行,并利用多核并行,實現更強大的并發。

下集預告

下篇會是源碼層面的內容了,關于源碼分析的書籍、文章可以先看起來了,先劇透一篇圖,希望閱讀下篇文章趕緊關注本公眾號。

image

推薦閱讀

Go調度器系列(1)起源
Go調度器系列(2)宏觀看調度器

參考資料

在學習調度器的時候,看了很多文章,這里列一些重要的:

  1. The Go scheduler: https://morsmachine.dk/go-scheduler
  2. Go's work-stealing scheduler: https://rakyll.org/scheduler/,中文翻譯版: https://lingchao.xin/post/gos-work-stealing-scheduler.html
  3. Go夜讀:golang 中 goroutine 的調度: https://reading.developerlearning.cn/reading/12-2018-08-02-goroutine-gpm/
  4. 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
  5. 雨痕大神的golang源碼剖析: github.com/qyuhen/book
  6. 也談goroutine調度器: https://tonybai.com/2017/06/23/an-intro-about-goroutine-scheduler/
  7. kavya的調度PPT: https://speakerdeck.com/kavya719/the-scheduler-saga
  8. 搶占的設計提案,Proposal: Non-cooperative goroutine preemption: https://github.com/golang/proposal/blob/master/design/24543-non-cooperative-preemption.md
  1. 如果這篇文章對你有幫助,請點個贊/喜歡,感謝
  2. 本文作者:大彬
  3. 如果喜歡本文,隨意轉載,但請保留此原文鏈接:http://lessisbetter.site/2019/04/04/golang-scheduler-3-principle-with-graph/
image
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容