圖解Go的channel底層原理

廢話不多說,直奔主題。

channel的整體結構圖

簡單說明:

  • buf是有緩衝的channel所特有的結構,用來存儲緩存數據。是個循環鏈表
  • sendxrecvx用於記錄buf這個循環鏈表中的發送或者接收的index
  • lock是個互斥鎖。
  • recvqsendq分別是接收(<-channel)或者發送(channel <- xxx)的goroutine抽象出來的結構體(sudog)的隊列。是個雙向鏈表

源碼位於/runtime/chan.go中(目前版本:1.11)。結構體爲hchan

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中各部分是如何使用的。

先從創建開始

我們首先創建一個channel。

ch := make(chan int, 3)

創建channel實際上就是在內存中實例化了一個hchan的結構體,並返回一個ch指針,我們使用過程中channel在函數之間的傳遞都是用的這個指針,這就是爲什麼函數傳遞中無需使用channel的指針,而直接用channel就行了,因爲channel本身就是一個指針。

channel中發送send(ch <- xxx)和recv(<- ch)接收

先考慮一個問題,如果你想讓goroutine以先進先出(FIFO)的方式進入一個結構體中,你會怎麼操作?
加鎖!對的!channel就是用了一個鎖。hchan本身包含一個互斥鎖mutex

channel中隊列是如何實現的

channel中有個緩存buf,是用來緩存數據的(假如實例化了帶緩存的channel的話)隊列。我們先來看看是如何實現“隊列”的。
還是剛纔創建的那個channel

ch := make(chan int, 3)

當使用send (ch <- xx)或者recv ( <-ch)的時候,首先要鎖住hchan這個結構體。

然後開始send (ch <- xx)數據。

ch <- 1

ch <- 1

ch <- 1

這時候滿了,隊列塞不進去了
動態圖表示爲:

然後是取recv ( <-ch)的過程,是個逆向的操作,也是需要加鎖。

然後開始recv (<-ch)數據。

<-ch

<-ch

<-ch

圖爲:

注意以上兩幅圖中bufrecvx以及sendx的變化,recvxsendx是根據循環鏈表buf的變動而改變的。
至於爲什麼channel會使用循環鏈表作爲緩存結構,我個人認爲是在緩存列表在動態的sendrecv過程中,定位當前send或者recvx的位置、選擇send的和recvx的位置比較方便吧,只要順着鏈表順序一直旋轉操作就好。

緩存中按鏈表順序存放,取數據的時候按鏈表順序讀取,符合FIFO的原則。

send/recv的細化操作

注意:緩存鏈表中以上每一步的操作,都是需要加鎖操作的!

每一步的操作的細節可以細化爲:

  • 第一,加鎖
  • 第二,把數據從goroutine中copy到“隊列”中(或者從隊列中copy到goroutine中)。
  • 第三,釋放鎖

每一步的操作總結爲動態圖爲:(發送過程)

或者爲:(接收過程)

所以不難看出,Go中那句經典的話:Do not communicate by sharing memory; instead, share memory by communicating.的具體實現就是利用channel把數據從一端copy到了另一端!
還真是符合channel的英文含義:

當channel緩存滿了之後會發生什麼?這其中的原理是怎樣的?

使用的時候,我們都知道,當channel緩存滿了,或者沒有緩存的時候,我們繼續send(ch <- xxx)或者recv(<- ch)會阻塞當前goroutine,但是,是如何實現的呢?

我們知道,Go的goroutine是用戶態的線程(user-space threads),用戶態的線程是需要自己去調度的,Go有運行時的scheduler去幫我們完成調度這件事情。關於Go的調度模型GMP模型我在此不做贅述,如果不瞭解,可以看我另一篇文章(Go調度原理)

goroutine的阻塞操作,實際上是調用send (ch <- xx)或者recv ( <-ch)的時候主動觸發的,具體請看以下內容:

//goroutine1 中,記做G1

ch := make(chan int, 3)

ch <- 1
ch <- 1
ch <- 1

這個時候G1正在正常運行,當再次進行send操作(ch<-1)的時候,會主動調用Go的調度器,讓G1等待,並從讓出M,讓其他G去使用

同時G1也會被抽象成含有G1指針和send元素的sudog結構體保存到hchan的sendq中等待被喚醒。

那麼,G1什麼時候被喚醒呢?這個時候G2隆重登場。

G2執行了recv操作p := <-ch,於是會發生以下的操作:

G2從緩存隊列中取出數據,channel會將等待隊列中的G1推出,將G1當時send的數據推到緩存中,然後調用Go的scheduler,喚醒G1,並把G1放到可運行的Goroutine隊列中。

假如是先進行執行recv操作的G2會怎麼樣?

你可能會順着以上的思路反推。首先:

這個時候G2會主動調用Go的調度器,讓G2等待,並從讓出M,讓其他G去使用。
G2還會被抽象成含有G2指針和recv空元素的sudog結構體保存到hchan的recvq中等待被喚醒

此時恰好有個goroutine G1開始向channel中推送數據 ch <- 1
此時,非常有意思的事情發生了:

G1並沒有鎖住channel,然後將數據放到緩存中,而是直接把數據從G1直接copy到了G2的棧中。
這種方式非常的贊!在喚醒過程中,G2無需再獲得channel的鎖,然後從緩存中取數據。減少了內存的copy,提高了效率。

之後的事情顯而易見:

更多精彩內容,請關注我的微信公衆號 互聯網技術窩 或者加微信共同探討交流:

參考文獻:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章