最近大傢俬信我讓我說說 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 協議》,轉載必須註明作者和本文連結