Go排程器系列(3)圖解排程原理

shitaibin發表於2019-04-06

如果你已經閱讀了前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場景

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

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

場景1:p1擁有g1,m1獲取p1後開始執行g1,g1使用go func()建立了g2,為了區域性性g2優先加入到p1的本地佇列。

場景2g1執行完成後(函式: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,後續場景會有介紹)。

場景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分配到少量執行緒上去執行,並利用多核並行,實現更強大的併發。

下集預告

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

推薦閱讀

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,中文翻譯版: https://www.jianshu.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

相關文章