【深入理解Go】協程設計與排程原理(上)

NoSay發表於2021-09-25
協程是更輕量的使用者態執行緒,是Go語言的核心。那麼如何去排程這些協程何時去執行、如何更合理的分配作業系統資源,需要一個設計良好的排程器來支援。
什麼才是一個好的排程器?能在適當的時機將合適的協程分配到合適的位置,保證公平和效率。

從go func說起

func main() {
    for i := 0; i < 10; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
    time.Sleep(1 * time.Second)
}

這段程式碼中,我們開啟了10個協程,每個協程列印去列印i這個變數。由於這10個協程的排程時機並不固定,所以等到協程被排程執行的時候才會去取迴圈中變數i的值。

我們寫的這段程式碼,每個我們開啟的協程都是一個計算任務,這些任務會被提交給go的runtime。如果計算任務非常多,有成千上萬個,那麼這些任務是不可能同時被立刻執行的,所以這個計算任務一定會被先暫存起來,一般的做法是放到記憶體的佇列中等待被執行。

而消費端則是一個go runtime維護的一個排程迴圈。排程迴圈簡單來說,就是不斷從佇列中消費計算任務並執行。這裡本質上就是一個生產者-消費者模型,實現了使用者任務與排程器的解耦。

這裡圖中的G就代表我們的一個goroutine計算任務,M就代表作業系統執行緒

排程策略

接下來我們詳細講解一下排程策略。

生產端

生產端1.0

接上面的例子,我們生產了10個計算任務,我們一定是要在記憶體中先把它存起來等待排程器去消費的。那麼很顯然,最合適的資料結構就是佇列,先來先服務。但是這樣做是有問題的。現在我們都是多核多執行緒模型,消費者肯定不止有一個,所以如果多個消費者去消費同一個佇列,會出現執行緒安全的問題,必須加鎖。所有計算任務G都必須在M上來執行。
G-M

生產端2.0

在Go中,為了解決加鎖的問題,將全域性佇列拆成了多個本地佇列,而這個本地佇列由一個叫做P的結構來管理
G-M-P

這樣一來,每個M只需要去先找到一個P結構,和P結構繫結,然後執行P本地佇列裡的G即可,完美的解決了加鎖的問題。

但是每個P的本地佇列長度不可能無限長(目前為256),想象一下有成千上萬個go routine的場景,這樣很可能導致本地佇列無法容納這麼多的Goroutine,所以Go保留了全域性佇列,用以處理上述情況。

那麼為什麼本地佇列是陣列,而全域性佇列是連結串列呢?由於全域性佇列是本地佇列的兜底策略,所以全域性佇列大小必須是無限的,所以必須是一個連結串列。

全域性佇列被分配在全域性的排程器結構上,只有一份:

type schedt struct {
    ...
    // Global runnable queue.
    runq     gQueue // 全域性佇列
    runqsize int32  // 全域性佇列大小
    ...
}

那麼本地佇列為什麼做成陣列而不是連結串列呢?因為作業系統記憶體管理會將連續的儲存空間提前讀入快取(區域性性原理),所以陣列往往會被都讀入到快取中,對快取友好,效率較高;而連結串列由於在記憶體中分佈是分散的,往往不會都讀入到快取中,效率較低。所以本地佇列綜合考慮效能與擴充套件性,還是選擇了陣列作為最終實現。

而Go又為了實現區域性性原理,在P中又加了一個runnext的結構,這個結構大小為1,在runnext中的G永遠會被最先排程執行。接下來會講為什麼需要這個runnext結構。完整的生產端資料結構如下:

P結構的定義:

type p struct {
    ...
    // Queue of runnable goroutines. Accessed without lock.
    runqhead uint32 // 本地佇列隊頭
    runqtail uint32 // 本地佇列隊尾
    runq     [256]guintptr // 本地佇列,大小256
    runnext guintptr // runnext,大小為1
    ...
}

完整的生產流程

  • 我們執行go func的時候,主執行緒m0會呼叫newproc()生成一個G結構體,這裡會先選定當前m0上的P結構
  • 每個協程G都會被嘗試先放到P中的runnext,若runnext為空則放到runnext中,生產結束
  • 若runnext滿,則將原來runnext中的G踢到本地佇列中,將當前G放到runnext中。生產結束
  • 若本地佇列也滿了,則將本地佇列中的G拿出一半,加上當前協程G,這個拼成的結構在原始碼中叫batch,會將batch一起放到全域性佇列中,生產結束。這樣一來本地佇列的空間就不會滿了,接下來的生產流程不會被本地佇列滿而阻塞

所以我們看到,最終runnext中的G一定是最後生產出來的G,也會被優先被排程去執行。這裡是考慮到區域性性原理,最近建立出來的協程一定會被最先執行,優先順序是最高的。

runqput的邏輯:

func runqput(_p_ *p, gp *g, next bool) {

    // 先嚐試放到runnext中
    if randomizeScheduler && next && fastrand()%2 == 0 {
        next = false
    }

    if next {
    retryNext:
        // 拿到老的runnext值。
        oldnext := _p_.runnext
        // 交換當前runnext的老的G和當前G的地址,相當於將當前G放入了runnext
        if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
            goto retryNext
        }
        // 老的runnext為空,生產結束
        if oldnext == 0 {
            return
        }
        // 老的runnext不空,則將被替換掉的runnext賦值給gp,然後下面會set到本地佇列的尾部
        gp = oldnext.ptr()
    }

retry:
    // 嘗試放到本地佇列
    h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
    t := _p_.runqtail
     // 本地佇列沒有滿,那麼set進去
    if t-h < uint32(len(_p_.runq)) {
        _p_.runq[t%uint32(len(_p_.runq))].set(gp)
        atomic.StoreRel(&_p_.runqtail, t+1) // store-release, makes the item available for consumption
        return
    }
    // 如果本地佇列不滿剛才會直接return;若已滿會走到這裡,會將本地佇列的一半G放到全域性佇列中
    if runqputslow(_p_, gp, h, t) {
        return
    }
    // the queue is not full, now the put above must succeed
    goto retry
}

消費端

消費端就是一個排程迴圈,不斷的從本地佇列和全域性佇列消費G、給G繫結一個M、執行G,然後再次消費G、給G繫結一個M、執行G...那麼執行這個排程迴圈的人是誰呢?答案是g0,每個M上,都有一個g0,控制自己執行緒上面的排程迴圈:

type m struct {
    g0      *g     // goroutine with scheduling stack
    ...
}

g0是一個特殊的協程。為了給接下來M執行計算任務G做準備,g0需要先幫忙獲取一個執行緒M,根據隨機演算法給M繫結一個P,讓P上的計算任務G得到執行,然後正式進入排程迴圈。整體的排程迴圈分為四個步驟:

  • schedule:g0來執行,處理具體的排程策略,如從P的runnext/本地或者全域性佇列中獲取一個G,然後會呼叫execute()
  • execute:把G和M繫結,初始化一些欄位,呼叫gogo()
  • gogo:和作業系統架構相關,會將待執行的G排程到執行緒M上來執行,完成棧的切換
  • goexit:執行一些清理邏輯,並呼叫schedule()重新開始一輪排程迴圈

即每次排程迴圈,都會完成g0 -> G -> g0的上下文切換。

schedule

schedule是排程迴圈的核心。由於P中的G分佈在runnext、本地佇列和全域性佇列中,則需要挨個判斷是否有可執行的G,大體邏輯如下:

  • 先到P上的runnext看一下是否有G,若有則直接返回
  • runnext為空,則去本地佇列中查詢,找到了則直接返回
  • 本地佇列為空,則去阻塞的去全域性佇列、網路輪詢器、以及其他P中查詢,一直阻塞直到獲取到一個可用的G為止

原始碼實現如下:

func schedule() {
    _g_ := getg()
    var gp *g
    var inheritTime bool
    ...
    if gp == nil {
        // 每執行61次排程迴圈會看一下全域性佇列。為了保證公平,避免全域性佇列一直無法得到執行的情況,當全域性執行佇列中有待執行的G時,通過schedtick保證有一定機率會從全域性的執行佇列中查詢對應的Goroutine;
        if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
            lock(&sched.lock)
            gp = globrunqget(_g_.m.p.ptr(), 1)
            unlock(&sched.lock)
        }
    }
    if gp == nil {
        // 先嚐試從P的runnext和本地佇列查詢G
        gp, inheritTime = runqget(_g_.m.p.ptr())
    }
    if gp == nil {
        // 仍找不到,去全域性佇列中查詢。還找不到,要去網路輪詢器中查詢是否有G等待執行;仍找不到,則嘗試從其他P中竊取G來執行。
        gp, inheritTime = findrunnable() // blocks until work is available
        // 這個函式是阻塞的,執行到這裡一定會獲取到一個可執行的G
    }
    ...
    // 呼叫execute,繼續排程迴圈
    execute(gp, inheritTime)
}

其中schedtick這裡,每執行61次的排程迴圈,就需要去全域性佇列嘗試獲取一次。為什麼要這樣做呢?假設有十萬個G源源不斷的加入到P的本地佇列中,那麼全域性佇列中的G可能永遠得不到執行被餓死,所以必須要在從本地佇列獲取之前有一個判斷邏輯,定期從全域性佇列獲取G以保證公平。

與此同時,排程器會將全域性佇列中的一半G都拿過來,放到當前P的本地佇列中。這樣做的目的是,如果下次排程迴圈到來的時候,就不必去加鎖到全域性佇列中在獲取一次G了,效能得到了很好的保障。

這裡去其他P中查詢可用G的邏輯也叫work stealing,即工作竊取。這裡也是會使用隨機演算法,隨機選擇一個P,偷取該P中一半的G放入當前P的本地佇列,然後取本地佇列尾部的一個G拿來執行。

GMP模型

到這裡相信大家已經瞭解了GMP的概念,我們最終來總結一下:

  • G:goroutine,代表一個計算任務,由程式碼和上下文(如當前程式碼執行的位置、棧資訊、狀態等)組成
  • M:machine,系統執行緒,想要在CPU上執行程式碼必須有執行緒,通過系統呼叫clone建立
  • P:processor,虛擬處理器。M必須獲得P才能執行P佇列中的G程式碼,否則會陷入休眠

阻塞處理

以上只是假設G正常執行的情況。如果G存在阻塞等待(如channel、系統呼叫)等,那麼需要將此時此刻的M與P上的G進行解綁,讓M執行其他P上的G,從而最大化提升CPU利用率。以及從系統呼叫中陷入、恢復需要觸發排程器排程的時機,這部分邏輯會在下一篇文章中做出講解。

關注我們

歡迎對本系列文章感興趣的讀者訂閱我們的公眾號,關注博主下次不迷路~
image.png

相關文章