徹底搞懂 Channel 實現原理

程式設計師祝融發表於2023-01-17


最近大傢俬信我讓我說說 Go 語言中的 Channel,年末了,有的人已經開始準備面試,真快呀!今天我們就來說說 Channel嗎,日常開發中使用也是比較頻繁的,面試也是高頻。聽我慢慢說來。

Channel (通道) 是 Go 語言高效能併發程式設計中的核心資料結構和與 Goroutine 之前重要的通訊方式。在 Go 語言中通道是一種特殊的型別。通道像一個傳送帶或者佇列,遵循先入先出(First In First Out)的規則,保證收發資料的順序。

1. 應用場景

在很多主流的程式語言中,多個執行緒間基本上都是透過共享記憶體來實現通訊的,如Java。這類語言往往都需要限制一定的執行緒數量從而解決執行緒競爭。用圖的方式簡單表達一下。

Go 語言的設計卻截然不同,在 Go 語言提供了一種新的併發模型,在 Goroutine 中使用 Channel 傳遞資料,從而實現通訊。

Go 語言提倡 “不要透過共享記憶體的方式進行通訊,而是透過 Channel 通訊的方式共享記憶體”。

Don’t communicate by sharing memory, share memory by communicating

我們會結合 chanal 使用場景的 5 大型別來闡述,更好的瞭解 Channel。

  • 資料交流
  • 資料傳遞
  • 訊號通知
  • 任務編排

接下來學一下一下 chanel 的常見用法。

2. 常見用法

我們一開始就說 Go 語言是透過通訊來實現共享記憶體的,故我們可以從 channel 中接受資料,也能傳送資料。下文中會簡稱 channek 為 chan。我們將從一下三種情況展開說下

  • 僅接送資料
  • 僅傳送資料
  • 既能接受也能傳送資料
chan int        // 可以傳送和接收 int 資料
chan <- struct{}   // 只能傳送 struct{}
<-chan string           // 只能從 chan 接收 string 資料

宣告的通道型別變數需要使用內建的 make 函式初始化之後才能使用。格式如下:

make(chan 元素型別, [緩衝區大小])

make(chan int) // 無緩衝通道
make(chan int, 1024) // 有緩衝通道

記住 Go 語言中 chan 沒有型別的限制,其中 chan 的緩衝大小是可選的,未初始化的是一個 nil 值。

  • 指定緩衝區的大小,我們稱其為 緩衝通道”
  • 未指定了緩衝區的大小,我們稱其為 無緩衝通道” 又稱為阻塞的通道。

無緩衝通道

無緩衝的通道又稱為阻塞的通道,如上方第 3 行程式碼,無緩衝的通道只有在有接收方能夠接收值的時候才能傳送成功,否則會一直處於等待傳送的階段。同理,如果對一個無緩衝通道執行接收操作時,沒有任何向通道中傳送值的操作那麼也會導致接收操作阻塞。

緩衝通道

當制定了緩衝區的大小,初始化如上方第 4 行程式碼。若 chan 中有資料時,此時從 chan 中接收資料不會發生阻塞;若 chan 未滿時,此時傳送資料也不會發生阻塞,反之就會出現阻塞。

接下來說下 Channel 的基本用法。

2.1 傳送資料

將一個值傳送到通道(chan) 中:

chan <- 1024 // 將1024 發到 chan 中

2.2 接收資料

從一個通道(chan) 中接收值:

x := <- ch // 從ch中接收值並賦值給變數x

<-ch // 從ch中接收值,並丟棄

2.3 關閉通道

我們透過內建函式 close 函式來關閉通道:

close(chan)

2.4 其他操作

Go 一些內建的函式都可以操作 chan 型別。比如 len、cap

  • len 可以返回 chan 中還未被處理的元素數量
  • cap 可以返回 chan 的容量

注: 目前 Go 語言中並沒有提供一個不對通道進行讀取操作就能判斷通道 chan 是否被關閉的方法。不能簡單的透過 len(ch) 操作來判斷通道 chan 是否被關閉。

用 for range 接收值:

func f(ch chan int) {
    for v := range ch {
        fmt.Println("接收到 chan 值:", v)
    }
}

還有 send 和 recv 可以作為 select 語句的 case:

var ch = make(chan int, 6)
for i := 0; i < 6; i++ {
    select {
    case ch <- i:
    case v := <-ch:
        fmt.Println(v)
    }
}

下面我說下原始碼的角度分析一下 chan 的具體實現,掌握了原理,我們才能真正地用好它,才能在談高薪是有底氣!

3. 實現原理

3.1 chan 資料結構

Go 語言的 Channel 在執行時使用 runtime.hchan 結構體表示。我們在 Go 語言中建立新的 Channel 時,實際上建立的都是如下所示的結構:

type hchan struct {
    qcount   uint              // 迴圈佇列元素的數量
    dataqsiz uint             // 迴圈佇列的大小
    buf      unsafe.Pointer // 迴圈佇列緩衝區的資料指標
    elemsize uint16            // chan中元素的大小
    closed   uint32            // 是否已close
    elemtype *_type            // chan 中元素型別
    sendx    uint            // send 傳送操作在 buf 中的位置
    recvx    uint            // recv 接收操作在 buf 中的位置
    recvq    waitq            // receiver的等待佇列
    sendq    waitq            // senderl的等待佇列

    lock mutex                // 互斥鎖,保護所有欄位
}

runtime.hchan 結構體中的五個欄位 qcount、dataqsiz、buf、sendx、recv 構建底層的迴圈佇列 (channel)。解釋一下上面的欄位含義:

  • qcount:代表迴圈佇列 chan 中已經接收但還沒被取走的元素的個數。
  • datagsiz 迴圈佇列 chan 的大小。選用了一個迴圈佇列來存放元素,類似於佇列的生產者 - 消費者場景
  • buf:存放元素的迴圈佇列的 buffer。
  • elemtype 和 elemsize:迴圈佇列 chan 中元素的型別和 size。chan 一旦宣告,它的元素型別是固定的,即普通型別或者指標型別,元素大小自然也就固定了。
  • sendx:處理傳送資料的指標在 buf 中的位置。一旦接收了新的資料,指標就會加上 elemsize,移向下一個位置。buf 的總大小是 elemsize 的整數倍,而且 buf 是一個迴圈列表。
  • recvx:處理接收請求時的指標在 buf 中的位置。一旦取出資料,指標會移動到下一個位置。
  • recvq:chan 是多生產者多消費者的模式,如果消費者因為沒有資料可讀而被阻塞了,就會被加入到 recvq 佇列中。
  • sendq:如果生產者因為 buf 滿了而阻塞,會被加入到 sendq 佇列中。

3.2 傳送資料(send)

我們接下來繼續介紹 chan 的接收資料。Go 語言中可以使用 ch <- i 向 chan 中傳送資料。

我們看下 chansend 原始碼,Go 編譯器在向 chan 傳送資料時,會將 send 轉換成 chansend1 函式。如下:

chansend1 中呼叫 chansend 並傳入 channel 和需要傳送的資料。一開始會判斷當前 chan 是否為 nil ,是 nil 會阻塞呼叫者 gopark。我們會發現第 11 行是不會被程式執行的。

當 chan 關閉了,此時傳送資料會造成 panic 錯誤。

如果 chan 沒有被關閉並且等待佇列中已經有處於讀等待的 Goroutine,那麼會從接收佇列 recvq 中取出最先陷入等待的 Goroutine 並直接向它傳送資料。

如果建立的 chan 包含緩衝區(chanbuf)並且 chan 中的資料沒有裝滿,會執行下面這段程式碼:

在這裡我們首先會使用緩衝區中計算出下一個可以儲存資料的位置,然後透過 typedmemmove 將傳送的資料複製到緩衝區中並增加 sendx 索引和 qcount 計數器。

3.3 接收資料(recv)

接下來繼續介紹 chan 的接收資料。Go 語言中可以使用兩種不同的方式去接收 chan 中的資料:

i <- ch
i, ok <- ch

從 chan 中接收資料會被轉換成 chanrecv1 和 chanrecv2 兩種函式,但是最後還是會呼叫 chanrecv。

可以看到 chanrecv1 和 chanrecv2 中呼叫 chanrecv 時 block 的值都是 true,在 chanrecv 中 chan 為 nil ,我們從 nil 的 chan 中接收資料,呼叫者會被阻塞主 goroutine park,和傳送一樣,第 15 行也不會被執行。

當緩衝區中沒有資料且當前的 chan 已經 close 了,那麼會清除 ep 指標中的資料,程式碼段會返回 true、false。

當緩衝區滿了,這個時候,如果是 unbuffer 的 chan,就直接將 sender 的資料複製給 receiver,否則就取出佇列頭等待的 Goroutine,並把這個 sender 的值加入到佇列尾部。

3.4 關閉(close)

Go 語言中關閉一個 chan 用自帶的 close 函式,編譯器會轉成呼叫 closechan,我們看下原始碼:

  • close 一個 nil 的 chan 會出現 panic;
  • close 一個 已經 closed 的 chan,會出現 panic;
  • 當 chan 不為 nil 也不為 closed,才能 close 成功,從而把等待佇列中的 sender(writer)和 receiver(reader)從佇列中全部移除並喚醒。

原始碼的部分就到這裡了,我們接下來說下開發中需要注意的點。

4. 總結

我們開發中,chan 的值或者狀態會有很多種情況,此時一定要注意使用方式,一些操作可能會出現 panic。我總結了一下異常場景,如下表:
| | nil channel | 有值 channel | 沒值 channel | 滿 channel |
| ——————- | ———– | ———- | ———- | ——— |
| <- ch (傳送資料) | 阻塞 | 傳送成功 | 傳送成功 | 阻塞 |
| ch <- (接收資料) | 阻塞 | 接收成功 | 阻塞 | 接收成功 |
| close(ch) 關閉channel | panic | 關閉成功 | 關閉成功 | 關閉成功|

歡迎點贊關注,感謝!

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章