第三章 Goroutine排程策略(16)

愛寫程式的阿波張發表於2019-05-15

本文是《Go語言排程器原始碼情景分析》系列的第16篇,也是第三章《Goroutine排程策略》的第1小節。


 

在排程器概述一節我們提到過,所謂的goroutine排程,是指程式程式碼按照一定的演算法在適當的時候挑選出合適的goroutine並放到CPU上去執行的過程。這句話揭示了排程系統需要解決的三大核心問題:

  1. 排程時機:什麼時候會發生排程?

  2. 排程策略:使用什麼策略來挑選下一個進入執行的goroutine?

  3. 切換機制:如何把挑選出來的goroutine放到CPU上執行?

對這三大問題的解決構成了排程器的所有工作,因而我們對排程器的分析也必將圍繞著它們所展開。

第二章我們已經詳細的分析了排程器的初始化以及goroutine的切換機制,本章將重點討論排程器如何挑選下一個goroutine出來執行的策略問題,而剩下的與排程時機相關的內容我們將在第4~6章進行全面的分析。

再探schedule函式

在討論main goroutine的排程時我們已經見過schedule函式,因為當時我們的主要關注點在於main goroutine是如何被排程到CPU上執行的,所以並未對schedule函式如何挑選下一個goroutine出來執行做深入的分析,現在是重新回到schedule函式詳細分析其排程策略的時候了。

runtime/proc.go : 2467

// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
    _g_ := getg()   //_g_ = m.g0

    ......

    var gp *g

    ......
   
    if gp == nil {
    // Check the global runnable queue once in a while to ensure fairness.
    // Otherwise two goroutines can completely occupy the local runqueue
    // by constantly respawning each other.
       //為了保證排程的公平性,每個工作執行緒每進行61次排程就需要優先從全域性執行佇列中獲取goroutine出來執行,
       //因為如果只排程本地執行佇列中的goroutine,則全域性執行佇列中的goroutine有可能得不到執行
        if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
            lock(&sched.lock) //所有工作執行緒都能訪問全域性執行佇列,所以需要加鎖
            gp = globrunqget(_g_.m.p.ptr(), 1) //從全域性執行佇列中獲取1個goroutine
            unlock(&sched.lock)
        }
    }
    if gp == nil {
        //從與m關聯的p的本地執行佇列中獲取goroutine
        gp, inheritTime = runqget(_g_.m.p.ptr())
        if gp != nil && _g_.m.spinning {
            throw("schedule: spinning with local work")
        }
    }
    if gp == nil {
        //如果從本地執行佇列和全域性執行佇列都沒有找到需要執行的goroutine,
        //則呼叫findrunnable函式從其它工作執行緒的執行佇列中偷取,如果偷取不到,則當前工作執行緒進入睡眠,
        //直到獲取到需要執行的goroutine之後findrunnable函式才會返回。
        gp, inheritTime = findrunnable() // blocks until work is available
    }

    ......

    //當前執行的是runtime的程式碼,函式呼叫棧使用的是g0的棧空間
    //呼叫execte切換到gp的程式碼和棧空間去執行
    execute(gp, inheritTime)  
}

schedule函式分三步分別從各執行佇列中尋找可執行的goroutine:

第一步,從全域性執行佇列中尋找goroutine。為了保證排程的公平性,每個工作執行緒每經過61次排程就需要優先嚐試從全域性執行佇列中找出一個goroutine來執行,這樣才能保證位於全域性執行佇列中的goroutine得到排程的機會。全域性執行佇列是所有工作執行緒都可以訪問的,所以在訪問它之前需要加鎖。

第二步,從工作執行緒本地執行佇列中尋找goroutine。如果不需要或不能從全域性執行佇列中獲取到goroutine則從本地執行佇列中獲取。

第三步,從其它工作執行緒的執行佇列中偷取goroutine。如果上一步也沒有找到需要執行的goroutine,則呼叫findrunnable從其他工作執行緒的執行佇列中偷取goroutine,findrunnable函式在偷取之前會再次嘗試從全域性執行佇列和當前執行緒的本地執行佇列中查詢需要執行的goroutine。

下面我們先來看如何從全域性執行佇列中獲取goroutine。

從全域性執行佇列中獲取goroutine

從全域性執行佇列中獲取可執行的goroutine是通過globrunqget函式來完成的,該函式的第一個引數是與當前工作執行緒繫結的p,第二個引數max表示最多可以從全域性佇列中拿多少個g到當前工作執行緒的本地執行佇列中來。

runtime/proc.go : 4663

// Try get a batch of G's from the global runnable queue.
// Sched must be locked.
func globrunqget(_p_ *p, max int32) *g {
    if sched.runqsize == 0 {  //全域性執行佇列為空
        return nil
    }

    //根據p的數量平分全域性執行佇列中的goroutines
    n := sched.runqsize / gomaxprocs + 1
    if n > sched.runqsize { //上面計算n的方法可能導致n大於全域性執行佇列中的goroutine數量
        n = sched.runqsize
    }
    if max > 0 && n > max {
        n = max   //最多取max個goroutine
    }
    if n > int32(len(_p_.runq)) / 2 {
        n = int32(len(_p_.runq)) / 2  //最多隻能取本地佇列容量的一半
    }

    sched.runqsize -= n

    //直接通過函式返回gp,其它的goroutines通過runqput放入本地執行佇列
    gp := sched.runq.pop()  //pop從全域性執行佇列的佇列頭取
    n--
    for ; n > 0; n-- {
        gp1 := sched.runq.pop()  //從全域性執行佇列中取出一個goroutine
        runqput(_p_, gp1, false)  //放入本地執行佇列
    }
    return gp
}

globrunqget函式首先會根據全域性執行佇列中goroutine的數量,函式引數max以及_p_的本地佇列的容量計算出到底應該拿多少個goroutine,然後把第一個g結構體物件通過返回值的方式返回給呼叫函式,其它的則通過runqput函式放入當前工作執行緒的本地執行佇列。這段程式碼值得一提的是,計算應該從全域性執行佇列中拿走多少個goroutine時根據p的數量(gomaxprocs)做了負載均衡。

如果沒有從全域性執行佇列中獲取到goroutine,那麼接下來就在工作執行緒的本地執行佇列中尋找需要執行的goroutine。

從工作執行緒本地執行佇列中獲取goroutine

從程式碼上來看,工作執行緒的本地執行佇列其實分為兩個部分,一部分是由p的runq、runqhead和runqtail這三個成員組成的一個無鎖迴圈佇列,該佇列最多可包含256個goroutine;另一部分是p的runnext成員,它是一個指向g結構體物件的指標,它最多隻包含一個goroutine。

從本地執行佇列中尋找goroutine是通過runqget函式完成的,尋找時,程式碼首先檢視runnext成員是否為空,如果不為空則返回runnext所指的goroutine,並把runnext成員清零,如果runnext為空,則繼續從迴圈佇列中查詢goroutine。

runtime/proc.go : 4825

// Get g from local runnable queue.
// If inheritTime is true, gp should inherit the remaining time in the
// current time slice. Otherwise, it should start a new time slice.
// Executed only by the owner P.
func runqget(_p_ *p) (gp *g, inheritTime bool) {
    // If there's a runnext, it's the next G to run.
    //從runnext成員中獲取goroutine
    for {
        //檢視runnext成員是否為空,不為空則返回該goroutine
        next := _p_.runnext  
        if next == 0 {
            break
        }
        if _p_.runnext.cas(next, 0) {
            return next.ptr(), true
        }
    }

    //從迴圈佇列中獲取goroutine
    for {
        h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers
        t := _p_.runqtail
        if t == h {
            return nil, false
        }
        gp := _p_.runq[h%uint32(len(_p_.runq))].ptr()
        if atomic.CasRel(&_p_.runqhead, h, h+1) { // cas-release, commits consume
            return gp, false
        }
    }
}

這裡首先需要注意的是不管是從runnext還是從迴圈佇列中拿取goroutine都使用了cas操作,這裡的cas操作是必需的,因為可能有其他工作執行緒此時此刻也正在訪問這兩個成員,從這裡偷取可執行的goroutine。

其次,程式碼中對runqhead的操作使用了atomic.LoadAcq和atomic.CasRel,它們分別提供了load-acquire和cas-release語義。

對於atomic.LoadAcq來說,其語義主要包含如下幾條

  1. 原子讀取,也就是說不管程式碼執行在哪種平臺,保證在讀取過程中不會有其它執行緒對該變數進行寫入;

  2. 位於atomic.LoadAcq之後的程式碼,對記憶體的讀取和寫入必須在atomic.LoadAcq讀取完成後才能執行,編譯器和CPU都不能打亂這個順序;

  3. 當前執行緒執行atomic.LoadAcq時可以讀取到其它執行緒最近一次通過atomic.CasRel對同一個變數寫入的值,與此同時,位於atomic.LoadAcq之後的程式碼,不管讀取哪個記憶體地址中的值,都可以讀取到其它執行緒中位於atomic.CasRel(對同一個變數操作)之前的程式碼最近一次對記憶體的寫入。

對於atomic.CasRel來說,其語義主要包含如下幾條

  1. 原子的執行比較並交換的操作;

  2. 位於atomic.CasRel之前的程式碼,對記憶體的讀取和寫入必須在atomic.CasRel對記憶體的寫入之前完成,編譯器和CPU都不能打亂這個順序;

  3. 執行緒執行atomic.CasRel完成後其它執行緒通過atomic.LoadAcq讀取同一個變數可以讀到最新的值,與此同時,位於atomic.CasRel之前的程式碼對記憶體寫入的值,可以被其它執行緒中位於atomic.LoadAcq(對同一個變數操作)之後的程式碼讀取到。

因為可能有多個執行緒會併發的修改和讀取runqhead,以及需要依靠runqhead的值來讀取runq陣列的元素,所以需要使用atomic.LoadAcq和atomic.CasRel來保證上述語義。

我們可能會問,為什麼讀取p的runqtail成員不需要使用atomic.LoadAcq或atomic.load?因為runqtail不會被其它執行緒修改,只會被當前工作執行緒修改,此時沒有人修改它,所以也就不需要使用原子相關的操作。

最後,由p的runq、runqhead和runqtail這三個成員組成的這個無鎖迴圈佇列非常精妙,我們會在後面的章節對這個迴圈佇列進行分析。

CAS操作與ABA問題

我們知道使用cas操作需要特別注意ABA的問題,那麼runqget函式這兩個使用cas的地方會不會有問題呢?答案是這兩個地方都不會有ABA的問題。原因分析如下:

首先來看對runnext的cas操作。只有跟_p_繫結的當前工作執行緒才會去修改runnext為一個非0值,其它執行緒只會把runnext的值從一個非0值修改為0值,然而跟_p_繫結的當前工作執行緒正在此處執行程式碼,所以在當前工作執行緒讀取到值A之後,不可能有執行緒修改其值為B(0)之後再修改回A。

再來看對runq的cas操作。當前工作執行緒操作的是_p_的本地佇列,只有跟_p_繫結在一起的當前工作執行緒才會因為往該佇列裡面新增goroutine而去修改runqtail,而其它工作執行緒不會往該佇列裡面新增goroutine,也就不會去修改runqtail,它們只會修改runqhead,所以,當我們這個工作執行緒從runqhead讀取到值A之後,其它工作執行緒也就不可能修改runqhead的值為B之後再第二次把它修改為值A(因為runqtail在這段時間之內不可能被修改,runqhead的值也就無法越過runqtail再回繞到A值),也就是說,程式碼從邏輯上已經杜絕了引發ABA的條件。

到此,我們已經分析完工作執行緒從全域性執行佇列和本地執行佇列獲取goroutine的程式碼,由於篇幅的限制,我們下一節再來分析從其它工作執行緒的執行佇列偷取goroutine的流程。

相關文章