Go 中的 channel 怎麼實現的?

ding發表於2021-08-22

相信大家在開發的過程中經常會使用到go中併發利器channelchannelCSP併發模型中最重要的一個元件,兩個獨立的併發實體通過共享的通訊channel進行通訊。大多數人只是會用這麼個結構很少有人討論它底層實現,這篇文章講寫寫channel的底層實現。

channel

channel的底層實現是一個結構體,原始碼如下:

type hchan struct {
    qcount   uint           // total data in the queue
    dataqsiz uint           // size of the circular queue
    buf      unsafe.Pointer // points to an array of dataqsiz elements
    elemsize uint16
    closed   uint32
    elemtype *_type // element type
    sendx    uint   // send index
    recvx    uint   // receive index
    recvq    waitq  // list of recv waiters
    sendq    waitq  // list of send waiters

    // lock protects all fields in hchan, as well as several
    // fields in sudogs blocked on this channel.
    //
    // Do not change another G's status while holding this lock
    // (in particular, do not ready a G), as this can deadlock
    // with stack shrinking.
    lock mutex
}

可能看原始碼不是很好看得懂,這裡我個人畫了一張圖方便大家檢視,我在上面標註了不同顏色,並且註釋其作用。

hchan struct

通道像一個傳送帶或者佇列,總是遵循FIFO的規則,保證收發資料的順序,通道是goroutine間重要通訊的方式,是併發安全的。

buf

hchan結構體中的buf指向一個迴圈佇列,用來實現迴圈佇列,sendx是迴圈佇列的隊尾指標,recvx是迴圈佇列的隊頭指標,dataqsize是快取型通道的大小,qcount是記錄通道內元素個數。

在日常開發過程中用的最多就是ch := make(chan int, 10)這樣的方式建立一個通道,如果這要宣告初始化的話,這個通道就是有緩衝區的,也是圖上紫色的bufbuf是在make的時候程式建立的,它有元素大小*元素個數組成一個迴圈佇列,可以看做成一個環形結構,buf則是一個指標指向這個環。

ring

上圖對應的程式碼那就是ch = make(chan int,6)buf指向這個環在heap上的地址。

func makechan(t *chantype, size int) *hchan {
    elem := t.elem

    // compiler checks this but be safe.
    if elem.size >= 1<<16 {
        throw("makechan: invalid channel element type")
    }
    if hchanSize%maxAlign != 0 || elem.align > maxAlign {
        throw("makechan: bad alignment")
    }

      mem, overflow := math.MulUintptr(elem.size, uintptr(size))
    if overflow || mem > maxAlloc-hchanSize || size < 0 {
        panic(plainError("makechan: size out of range"))
    }

    // Hchan does not contain pointers interesting for GC when elements stored in buf do not contain pointers.
    // buf points into the same allocation, elemtype is persistent.
    // SudoG's are referenced from their owning thread so they can't be collected.
    // TODO(dvyukov,rlh): Rethink when collector can move allocated objects.
    var c *hchan
    switch {
    case mem == 0:
        // Queue or element size is zero.
        c = (*hchan)(mallocgc(hchanSize, nil, true))
        // Race detector uses this location for synchronization.
      c.buf = c.raceaddr()
    case elem.ptrdata == 0:
        // Elements do not contain pointers.
        // Allocate hchan and buf in one call.
        c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
        c.buf = add(unsafe.Pointer(c), hchanSize)
    default:
        // Elements contain pointers.
        c = new(hchan)
        c.buf = mallocgc(mem, elem, true)
    }

    c.elemsize = uint16(elem.size)
    c.elemtype = elem
    c.dataqsiz = uint(size)
    lockInit(&c.lock, lockRankHchan)

    if debugChan {
        print("makechan: chan=", c, "; elemsize=", elem.size, "; dataqsiz=", size, "\n")
    }
    return c
}

上面就是對應的程式碼實現,上來它會檢查你一系列引數是否合法,然後在通過mallocgc在記憶體開闢這塊空間,然後返回。

sendx & recvx

下面我手動模擬一個ring實現的程式碼:

// Queue cycle buffer
type CycleQueue struct {
    data                  []interface{} // 存放元素的陣列,準確來說是切片
    frontIndex, rearIndex int           // frontIndex 頭指標,rearIndex 尾指標
    size                  int           // circular 的大小
}

// NewQueue Circular Queue
func NewQueue(size int) (*CycleQueue, error) {
    if size <= 0 || size < 10 {
      return nil, fmt.Errorf("initialize circular queue size fail,%d not legal,size >= 10", size)
    }
    cq := new(CycleQueue)
    cq.data = make([]interface{}, size)
    cq.size = size
    return cq, nil
}

// Push  add data to queue
func (q *CycleQueue) Push(value interface{}) error {
    if (q.rearIndex+1)%cap(q.data) == q.frontIndex {
        return errors.New("circular queue full")
    }
    q.data[q.rearIndex] = value
    q.rearIndex = (q.rearIndex + 1) % cap(q.data)
    return nil
}

// Pop return queue a front element
func (q *CycleQueue) Pop() interface{} {
    if q.rearIndex == q.frontIndex {
        return nil
    }
    v := q.data[q.frontIndex]
    q.data[q.frontIndex] = nil // 拿除元素 位置就設定為空
    q.frontIndex = (q.frontIndex + 1) % cap(q.data)
    return v
}

迴圈佇列一般使用空餘單元法來解決隊空和隊滿時候都存在font=rear帶來的二義性問題,但這樣會浪費一個單元。golangchannel中是通過增加qcount欄位記錄佇列長度來解決二義性,一方面不會浪費一個儲存單元,另一方面當使用len函式檢視佇列長度時候,可以直接返回qcount欄位,一舉兩得。

對應的原始碼

當我們需要讀取的資料的時候直接從recvx指標上的元素取,而寫就從sendx位置寫入元素,如圖:

讀寫指標

sendq & recvq

當寫入資料的如果緩衝區已經滿或者讀取的緩衝區已經沒有資料的時候,就會發生協程阻塞。

send

如果寫阻塞的時候會把當前的協程加入到sendq的佇列中,直到有一個recvq發起了一個讀取的操作,那麼寫的佇列就會被程式喚醒進行工作。

全部掛起態

當緩衝區滿了所有的g-w則被加入sendq佇列等待g-r有操作就被喚醒g-w,繼續工作,這種設計和作業系統的裡面thread5種狀態很接近了,可以看出go的設計者在可能參考過作業系統的thread設計。

當然上面只是我簡述整個個過程,實際上go還做了其他細節優化,sendq不為空的時候,並且沒有緩衝區,也就是無緩衝區通道,此時會從sendq第一個協程中拿取資料,有興趣的gopher可以去自己檢視原始碼,本文也是最近筆者在看到這塊原始碼的筆記總結。

更多原創文章乾貨分享,請關注公眾號
  • Go 中的 channel 怎麼實現的?
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章