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

NoSay發表於2021-09-27

回顧

在上一篇文章中,我們講述了基本的排程流程。但是我們沒有解決如果協程內部如果存在阻塞的情況下該如何處理。比如某個G中存在對channel的收發等操作會發生阻塞,那麼這個協程就不能一直佔用M的資源,如果一直佔用可能就會導致所有M都被阻塞住了。所以我們需要把當前G暫時掛起,待阻塞返回之後重新排程這個G來執行。

所以,我們需要一種排程機制,及時釋放阻塞G佔用的資源,重新觸發一次排程器的排程邏輯,把當前G先掛起,讓其他未執行過的G來執行,從而實現資源利用率的最大化。

runtime可以攔截的阻塞

什麼是runtime可以攔截的?一般是我們在程式碼中的阻塞,大概有這幾種:

  • channel生產/消費阻塞
  • select
  • lock
  • time.sleep
  • 網路讀寫

在runtime可以攔截的情況下,會先讓G進某種資料結構,待ready後重新排程G來繼續執行,阻塞期間不會繼續持有執行緒M。接下來我們以第一種情況channel為例,看看這個流程具體是如何執行的。

以channel阻塞為例

如剛才channel的那個例子所述,由於阻塞了,所以這個G需要被動的讓出所持有的P和M。我們以channel這個例子過一遍這個流程。假設有這麼一行程式碼:

ch <- 1

這是一個無緩衝的通道,此時往通道里寫入了一個值1。假設此時消費端還沒有去消費,這個時候這個通道寫入的操作就會阻塞。channel的資料結構叫做hchan:

type hchan struct {
    // 通道里元素的數量
    qcount   uint
    // 迴圈佇列的長度
    dataqsiz uint
    // 指標,指向儲存緩衝通道資料的迴圈佇列
    buf      unsafe.Pointer
    // 通道中元素的大小
    elemsize uint16
    // 通道是否關閉的標誌
    closed   uint32
    // 通道中元素的型別
    elemtype *_type
    // 已接收元素在迴圈佇列的索引
    sendx    uint  
    // 已傳送元素在迴圈佇列的索引
    recvx    uint
    // 等待接收的協程佇列
    recvq    waitq
    // 等待傳送的協程佇列
    sendq    waitq
    // 互斥鎖,保護hchan的併發讀寫,下文會講
    lock mutex
}

這裡我們重點關注recvq和sendq這兩個欄位。他們是一個連結串列,儲存阻塞在這個channel的傳送端和接收端的G。以上ch <- 1其實底層實現是一個chansend函式,實現如下:

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
   ...
    // 嘗試從recvq,也就是接收方佇列中出隊一個元素,如果非空,則說明找到了一個正在等待的receiver,可以傳送資料過去了。傳送完資料直接return
    if sg := c.recvq.dequeue(); sg != nil {
        send(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true
    }

    // 程式碼走到這裡,說明沒有接收方,需要阻塞住等待接收方(如果是無緩衝channel的話)
    if !block {
        unlock(&c.lock)
        return false
    }
    
    // 把當前channel和G,打包生成一個sudog結構,後面會講為什麼這樣做
    gp := getg()
    mysg := acquireSudog()
    mysg.g = gp
    mysg.c = c    
    ... 
    // 將sudog放到sendq中
    c.sendq.enqueue(mysg)
    
    // 呼叫gopark,這裡內部實現會講M與G解綁,並觸發一次排程迴圈
    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
    
    return true
}

我們總結一下這個chansend的流程(以無緩衝通道為例):

  • 嘗試從recvq中獲取消費者
  • 若recvq不空,傳送資料;若為空,則需要阻塞
  • 獲取一個sudog結構,給g欄位賦值為當前G
  • 把sudog掛到sendq上等待喚醒
  • 呼叫gopark將M與G解綁,重新觸發一次排程,M去執行其他的G

為什麼是sudog而非G

那麼這裡為什麼要用sudog而非原始的G結構呢。答案在於,一個G可以在多個等待連結串列上。recvq和sendq都是一個waitq結構。是一個雙向連結串列。假如第一個G已經掛到了連結串列上,那麼他必然要儲存下一個G的地址,才能成功的完成雙向連結串列的邏輯,如:

type g struct {
    next *g
    prev *g
}

而g又可能掛在多個等待連結串列上(如select操作,一個G可能會阻塞在多個channel上),所以g裡的next和prev必然會有多個值的情況。即next和prev的地址在多個等待連結串列上的值可能是不一樣的。G和等待連結串列的關係是多對多的關係,所以這個prev和next必然不能在G上直接維護,所以我們就會將G和channel一起打包成sudog結構。它和我們MySQL中多對多的中間表設計有異曲同工之妙,相當於維護了一個g_id和channel_id:

type sudog struct {

    // 原始G結構。相當於g_id
    g *g

    // 等待連結串列上的指標
    next *sudog
    prev *sudog

    // 所屬的channel,相當於channel_id
    c    *hchan
}

最終的效果如下:

gopark

我們知道,在將sudog打包好放到sendq之後,會呼叫go_park執行阻塞邏輯。go_park內部又會呼叫park_m方法,切換到g0棧,解除M與當前G的繫結,重新觸發一次排程,讓M去繫結其他G執行:

// park continuation on g0.
func park_m(gp *g) {
    _g_ := getg()

    // 將G的狀態設定為waiting
    casgstatus(gp, _Grunning, _Gwaiting)
    
    // 解除M與G的繫結
    dropg()

    // 重新執行一次排程迴圈
    schedule()
}

什麼時候喚醒

那麼問題來了,當前G已經阻塞在sendq上了,那麼誰來喚醒這個G讓他繼續執行呢?顯然是channel的接收端,在原始碼中和chansend相對的操作即chanrecv:

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {

    ...
    // 嘗試從sendq中拿一個等待協程出來
    if sg := c.sendq.dequeue(); sg != nil {
        // 如果拿到了,那麼接收資料,剛才我們的channel就屬於這種情況
        recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true, true
    }

    if !block {
        unlock(&c.lock)
        return false, false
    }

    // 同上,打包成一個sudog結構,掛到recvq等待連結串列上
    gp := getg()
    mysg := acquireSudog()
    mysg.g = gp
    mysg.c = c
    c.recvq.enqueue(mysg)

    // 同理,拿不到sendq,呼叫gopark阻塞
    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
    ...
    
    return true, success
}

goready

我們看到,chanrecv和chansend邏輯大體一致,這裡就不詳細展開。由於剛才我們的sendq上有資料,那麼這裡一定會進入recv()方法接收資料。在這裡會呼叫goready()方法:

// Mark gp ready to run.
func ready(gp *g, traceskip int, next bool) {

    ...
    status := readgstatus(gp)

    // 標記G為grunnable狀態
    _g_ := getg()
    casgstatus(gp, _Gwaiting, _Grunnable)
    
    // 放入runq中等待排程迴圈消費
    runqput(_g_.m.p.ptr(), gp, next)
    
    // 喚醒一個閒置的P來執行G
    wakep()
    releasem(mp)
}

goready和gopark是一對操作,gopark是阻塞,goready是喚醒。它會將sudog中繫結的G拿出來,傳入ready()方法,把它從gwaiting置為grunnable的狀態。並再次執行runqput。將G放到P的本地佇列/全域性佇列上等待排程迴圈來消費。這樣整個流程就能跑起來了。

總結一下,

  • sender呼叫gopark掛起,一定是由receiver(或close)通過goready喚醒
  • receiver呼叫gopark掛起,一定是由sender(或close)通過goready喚醒

runtime不能攔截的阻塞

什麼是runtime不能攔截的?即CGO程式碼和系統呼叫。CGO這裡先不講,由於系統呼叫這裡也有可能發生阻塞,且不屬於runtime層面的阻塞,runtime也不能讓G進某一個相關資料結構,runtime無法捕獲到。

那麼這個時候就需要一個後臺監控的特殊執行緒sysmon來監控這種情況。它會定期迴圈不斷的執行。它會申請一個單獨的M,且不需要繫結P就可以執行,優先順序最高。

sysmon的核心是sysmon()方法。監控會在迴圈中呼叫retake()方法搶佔處於長時間阻塞中的P,該函式會遍歷執行時的所有P。retake()的實現如下:

func retake(now int64) uint32 {
    n := 0
    for i := 0; i < len(allp); i++ {
        _p_ := allp[i]
        pd := &_p_.sysmontick
        s := _p_.status
        //當處理器處於_Prunning或者_Psyscall狀態時,如果上一次觸發排程的時間已經過去了10ms,我們會呼叫preemptone()搶佔當前P
        if s == _Prunning || s == _Psyscall {
            t := int64(_p_.schedtick)
            if pd.schedwhen+forcePreemptNS <= now {
                preemptone(_p_)
            }
        }
        // 當處理器處系統呼叫阻塞狀態時,當處理器的執行佇列不為空或者不存在空閒P時,或者當系統呼叫時間超過了10ms,會呼叫handoffp將P從M上剝離
        if s == _Psyscall {
            if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
                continue
            }
            if atomic.Cas(&_p_.status, s, _Pidle) {
                n++
                _p_.syscalltick++
                handoffp(_p_)
            }
        }
    }
    return uint32(n)
}

sysmon通過在後臺監控迴圈中搶佔P,來避免同一個G佔用M太長時間造成長時間阻塞及飢餓問題。

關注我們

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

相關文章