一份儘可能全面的Go channel介紹

同勉共進發表於2022-01-23

寫在前面

針對目前網路上Go channel知識點較為分散(很難有單獨的一份資料把所有知識點都囊括進來)的情況,在下斗膽站在巨人的肩膀上,總結了前輩的工作,並加入了自己的理解,形成了這篇文章。本文類似於“導航頁”或者“查詢手冊”,旨在幫助讀者對Go channel有一個系統、全面的瞭解,對想要快速上手的讀者提供必要的知識、系統的總結和常見的避坑指南,同時也為想要深入探索的同學提供一些優秀的第三方資料(起碼在下認為是優秀的)。在此,宣告以下幾點:

1. 本文不存在抄襲和剽竊。本文沒有抄襲任何文章的文字、程式碼、插圖,且所有引用都註明了出處。在此,對本文所引用文章的所有作者,表示由衷的感謝和深深的敬佩,您各位的辛勤付出和無私奉獻讓我學到了寶貴的知識,謝謝各位前輩!
2. 本文不是對網路內容的摘抄與堆砌。首先,本文對Go channel相關知識點進行了條理化地總結,力求清晰、全面、易讀、好理解;其次,本文有在下自己的見解和貢獻,如2.6小節的內容(儘管該內容確實無關緊要)、所有插圖和程式碼,以及其它零零散散的內容。
3. 如果您想要通過底層實現和原理來自下而上地瞭解Go channel,那麼您可以直接閱讀資料[6][7],這些前輩的原始碼解讀讓在下收益匪淺。等您充分理解了原始碼,或許就不需要本文了。

正文

一、概覽

顧名思義,管道channel是Go中一種特殊的資料型別,用於在Goroutine間傳遞資料,示意圖如下:

注意:
  1.上圖特地畫成了右進左出的形式,這與channel的讀寫語法在視覺上是一致的(繼續閱讀您就會感受到這一點)。
  2. 僅在一個單獨的Goroutine中使用channel毫無意義,並且容易引發錯誤。

二、基礎知識

2.1 型別宣告

channel的型別由關鍵字"chan"和該channel可以傳送的元素的型別組成,如 chan int 表示一個channel,該channel可以傳送int型別的資料。注意,Go channel的型別宣告是一種獨特的存在,它由兩個分開的“單詞”表示,這種表示方法,就算放眼整個“程式語言界”,也實屬罕見(事實上,在下並沒有在其它地方見過類似的情形,且在下認為C語言中的 struct custom_type 和這裡的 chan int 並不類似)。另外, <-chan elementType 和 chan<- elementType 也是型別宣告,分別表示只能從中讀取資料的channel和只能向其寫入資料的channel,關於channel的方向,2.4小節會詳述。

2.2 變數宣告與初始化

channel變數的宣告有var和make兩種方式:

 1 var chanX chan int // only declare; nil channel
 2 fmt.Printf("%v\n", chanX) // <nil>
 3 chanY := make(chan int) // declare & initialize; unbuffered channel
 4 fmt.Printf("%v\n", chanY) // 0xc000086060
 5 chanZ := make(chan int, 10) // declare & initialize; buffered channel
 6 fmt.Printf("%v\n", chanZ) // 0xc0000d6000
 7 var chanW = make(chan int) // declare & initialize; unbuffered channel
 8 fmt.Printf("%v\n", chanW) // 0xc0000860c0
 9 chanX = make(chan int)
10 fmt.Printf("%v\n", chanX) // 0xc000086120

 注意:
  1. 如第1行程式碼所示,單純的var方式只是宣告,並未初始化,channel的值為其預設零值,即nil。nil channel什麼也做不了,因此,var形式的宣告只是語法上正確,並沒有實際作用,除非它後來又被make形式的宣告重新賦值,如第9行所示[1][2]。
  2. make形式能同時宣告和初始化channel,又細分為buffered channel(第5行)和unbuffered channel(第3、7、9行),後文會詳述。
  3. 顯然,var和make可以一起使用,如第7行所示。
  4. 從輸出可以看出,make建立的channel本質上是一個指標。

2.3 收發操作

  1. channel的讀寫使用術語“接收”(receive)和“傳送”(send)表示。如果您跟我一樣,搞不清“傳送”到底指“傳送到channel”還是“由channel傳送”,那麼,請忘記“接收”和“傳送”,轉而記住“從channel接收”(receive from channel)和“傳送到channel”(send to channel),或者直接記住“讀出”和“寫入”。
  2. 接收和傳送的操作符都是向左指的箭頭("<-")(注意沒有向右指的箭頭)。箭頭由channel指出( data := <-chanX )表示接收(receive from)/讀出;箭頭指向channel( chanX <- data )表示傳送(send to)/寫入。
  3. 讀出操作可以是 data := <-chanX 、 data = <-chanX 、 _ = <-chanX 、 <-chanX、 、 data, ok := <-chanX 幾種,其中 data = <-chanX 中data必須已事先宣告。 data <-chanX 是不可以的,因為 data <-chanX 會被編譯器認為是將變數chanX的值寫入到管道data(請見第2條),而不是從管道chanX中讀出內容到變數data。

2.4 方向(讀寫限定)

2.4.1 單向channel的作用

2.1節所示,可以宣告單向channel(只能從該channel接收資料或只能傳送資料到該channel)。顯然,單向channel是不能用於Goroutine之間通訊的,那麼,單向channel的作用是什麼呢?單向channel主要用於函式形參或函式返回值,用來限制某channel在函式體中是隻讀/只寫的,或者限制某函式返回的channel是隻讀/只寫的。這一點類似於C++中使用const修飾函式形參或返回值。參考資料[2]和[3]對這一點有詳細的介紹。

2.4.2 轉換限制

雙向channel可以轉換為單向channel,但反之不行[2]。

2.5 本質型別

2.1節可以看出,channel是一個指標[4]。

2.6 為什麼不是chan[elementType]

這是一個無聊的問題,也是一個沒有什麼探究價值的問題。這裡僅給出在下毫無根據的猜測。看到 chan[int] ,您會想到什麼?在下想到了Go的 map[string]int 以及C++的 vector<int> 。後者是什麼?資料結構!但channel不是資料結構,也不該被視為資料結構,它是Goroutine間通訊的載體、媒介。在我們由經驗而來的潛意識裡,<>或[]前面的“單詞”往往表示資料結構的型別,而<>或[]中的“單詞”往往表示該資料結構儲存的資料的型別。但channel不是資料結構,它是用來傳遞資料而非儲存資料的,儘管channel中實際上有一個用來快取資料的迴圈佇列,但這只是暫時的快取,目的依然是為了傳遞資料(想想快遞櫃)。因此,為了在形式上提醒程式設計師channel不是資料結構,Go為channel採用了 chan elementType 這樣一種“蹩腳”的型別宣告,而不是容易讓人誤會的 chan[elementType] 。再次強調,這僅是在下的猜測,且毫無根據。

2.7 FIFO性質

channel具有先進先出(FIFO)的性質(早期的Go channel並不嚴格遵循FIFO原則[5]),而且其內部也確實使用了迴圈佇列,然而,正如2.6節所說,channel不是資料結構,也不應被視為佇列。

2.8 len和cap

可以對channel使用 len() 和 cap() 函式, len() 返回當前channel緩衝區中已有元素的個數(快遞櫃中快遞的件數), cap() 返回緩衝區的總容量(快遞櫃格子的總數)。對於nil channel ( var chanX chan int )和unbuffered channel ( chanY := make(chan int) ), len() 和 cap() 的結果都是0,讀者可自行編碼驗證。

2.9 鎖

使用channel不用顯式加鎖了吧?是的,除非你的程式碼中還有其它必須加鎖的邏輯。但請注意,channel的內部實現使用了互斥鎖

三、nil channel

關於nil channel,請了解:

  1. nil channel毫無用處。
  2. nil channel是channel的預設零值。
  3. 向nil channel寫資料,或從nil channel中讀資料,會永久阻塞(block),但不會panic。
  4. 關閉(close)一個nil channel會觸發pannic。

四、buffered channel & unbuffered channel

4.1 概述

buffered channel是指有內部緩衝區(buffer,由迴圈佇列實現)的channel,buffer大小由make的第二個引數指定,如 chanX := make(chan int, 3) 的buffer大小為3,最多能暫存3個資料。當傳送者向channel傳送資料而接收者還沒有就緒時,如果buffer未滿,就會將資料放入buffer;當接收者從channel讀取資料時,如果buffer中有資料,會將buffer中的第一個資料(隊首)取出,給到接收者。利用buffered channel,可以實現Goroutine間的非同步通訊。

unbuffered channel就是內部緩衝區大小為0的channel。由於沒有暫存資料的地方,unbuffered channel的資料傳輸只能是同步的,即只有讀寫雙方都就緒時,通訊才能成功[8],此時,資料直接從傳送者拷貝給接收者(請看原始碼註釋)。只要讀寫雙方中的一方沒有就緒,通訊就一直block。

資料[2]中送快遞的比方很是形象。buffered channel就像是有快遞櫃的快遞系統,快遞員不必等到取件人到達,他只要把快遞放到快遞就可以了,不必關心收件人何時來取快遞。當然,如果快遞櫃已滿,快遞員就必須等到收件人到達,然後直接將快遞交到收件人手上,不必經過快遞櫃。同樣,收件人也不必眼巴巴等著快遞員,他只要到快遞櫃取快遞就行了。這種情況下快遞收發是非同步的。unbuffered channel就像是沒有快遞櫃的快遞系統,只能是收發雙方當面交接。需要注意的是,快遞系統沒有嚴格的先來後到限制,而channel是嚴格FIFO的。第一個接收者必然會得到buffer中的第一個資料,以此類推。

下面是兩種channel的圖示,特別地,unbuffered channel更像是“厚度”為0的“傳送門”。相比於在下的簡明版圖示,資料[2]和[6]中的示意圖更加形象,但沒有突出兩種channel在結構上的差別。

                                                ▲buffered channel

 

                             ▲unbuffered channel

 此外,unbuffered channel可以用於Goroutine間的同步,資料[2]和[9]已經提供了很好的示例程式碼,在下就不獻醜了。

4.2 示例

請看下面的程式碼:

一份儘可能全面的Go channel介紹
 1 package main
 2 
 3 import (
 4 	"fmt"
 5 	"time"
 6 )
 7 
 8 func main() {
 9 	mychnl := make(chan int)
10 
11 	go func() {
12 		fmt.Println("send 100")
13 		mychnl <- 100
14 		fmt.Println("has sent")
15 	}()
16 }
View Code

執行上面程式碼,不會得到任何輸出。因為 go 關鍵字在啟動Goroutine後會立即返回,程式繼續往下走,當主協程(main函式所在的Goroutine)結束後,整個程式結束,不會等待其它Goroutine(參考資料[10]和[11])。因此,還沒等到輸出語句執行,整個程式就結束了。

說句題外話,如果您對主協程中的變數如何“傳遞”到其它協程感到疑惑,可以學習關於“閉包 變數捕獲”的內容,比如參考資料[12]。

我們可以在main函式的最後新增 time.Sleep(1 * time.Second) 以便給輸出語句足夠的時間。

再執行程式碼,可以看到第一句輸出,但看不到第二句輸出,即使增大主協程sleep的時間也不行。原因是:如前所述,unbuffered channel必須在讀寫雙方都就緒時才能傳送資料,否則block,因此, mychnl <- 100 一句導致其所在的Goroutine阻塞了(因為沒有接收者),直到sleep結束,整個程式隨著主協程的退出而結束。

下面,我們使用 sync.WaitGroup 代替sleep,看看會發生什麼(請讀者自行學習 sync.WaitGroup ):

一份儘可能全面的Go channel介紹
 1 package main
 2 
 3 import (
 4 	"fmt"
 5 	"sync"
 6 )
 7 
 8 func main() {
 9 	mychnl := make(chan int)
10 
11 	var wg sync.WaitGroup
12 	wg.Add(1)
13 	go func() {
14 		defer wg.Done()
15 		fmt.Println("send 100")
16 		mychnl <- 100
17 		fmt.Println("has sent")
18 	}()
19 	wg.Wait()
20 }
View Code

這次,我們同樣只得到了第一句輸出,並且緊接著就得到了一個 fatal error: fatal error: all goroutines are asleep - deadlock! 。原因是,這裡 wg.Wait() 會阻塞等待第13行啟動的Goroutine結束,而後者中 mychnl <- 100 阻塞等待一個接收者(快遞員等待收件人),顯然,接收者永遠不會出現,於是,死鎖(deadlock)了。

我們可以新增另一個作為接收者的Goroutine來解決這一問題:

一份儘可能全面的Go channel介紹
 1 package main
 2 
 3 import (
 4 	"fmt"
 5 	"sync"
 6 )
 7 
 8 func main() {
 9 	mychnl := make(chan int)
10 
11 	var wg sync.WaitGroup
12 
13 	wg.Add(1)
14 	go func() {
15 		defer wg.Done()
16 		fmt.Println("send 100")
17 		mychnl <- 100
18 		fmt.Println("has sent")
19 	}()
20 
21 	wg.Add(1)
22 	go func() {
23 		defer wg.Done()
24 		fmt.Println("begin receive")
25 		x := <-mychnl
26 		fmt.Printf("received %v\n", x)
27 	}()
28 
29 	wg.Wait()
30 }
View Code

結果是:

一份儘可能全面的Go channel介紹
begin receive
send 100
has sent
received 100
View Code

 如果您對輸出的順序感到疑惑(第一個輸出的總是 begin receive 而不是 has sent ),那就請學習一下Goroutine的相關知識吧。

4.3 都可能阻塞

需要強調的是,unbuffered channel、buffered channel都有可能block:

  1. 對unbuffered channel,當讀者/寫者未就緒時,寫操作/讀操作會一直block;
  2. 對buffered channel,當buffer已滿且讀者未就緒時,寫操作會一直block;同理,當buffer已空且寫者未就緒時,讀操作會一直block。

基於以上兩點,您需要調動起逆向思維來明確以下三點:

  1. 如果已經有讀者在阻塞了,那麼,buffer一定是空的且沒有寫者就緒;
  2. 如果已經有寫者在阻塞了,那麼,buffer一定是滿的且沒有讀者就緒;
  3. 讀者和寫者不可能同時阻塞。

注意,以上結論對unbuffered channel同樣適用,其緩衝區容量為0,既可以視為恆空,也可以視為恆滿。弄清了以上內容,才能更好地理解下文中channel的傳送/接收邏輯。

4.4 unbuffered channel和nil channel的區別

雖然兩者都是buffer容量為0,但是:

  1. nil channel完全不可用,對它的讀寫操作將無條件block,即便讀寫雙方都就緒也不行。證據:4.2節最後一段程式碼中第9行改為 var mychnl chan int ,再次執行,會得到 fatal error: all goroutines are asleep - deadlock! 錯誤。
  2. 當讀寫雙方都就緒時,unbuffered channel可以用來通訊。可以利用這一點同步多個Goroutine。

五、傳送/接收步驟

這裡只梳理基本步驟,異常檢查及更多細節,請參考[7]和[6]的原始碼解讀。

傳送步驟:
1. 如果存在阻塞等待的接收者(即Goroutine),那麼直接將待傳送的資料交給“等待接收佇列”中的第一個Goroutine。(- 什麼?直接交付?如果此時buffer中還有資料,不就跳過去了嗎?還怎麼滿足FIFO?- 不存在!既然都有接收者在等待了,說明buffer必然早就空了!見4.3節)
2. 如果沒有在阻塞等待的接收者:
  2.1 若buffer還有剩餘空間,則將待傳送的資料送到buffer的隊尾;
  2.2 若buffer已經沒有剩餘空間了,那麼,將傳送者(Goroutine)和要傳送的資料打包成一個struct,加入到“等待傳送佇列”的末尾,同時將該傳送者block。

接收步驟:
1. 如果存在阻塞等待的傳送者(此時要麼buffer已滿,要麼壓根就沒有buffer):
  1.1 若buffer已滿,從buffer中取出隊首元素交給接收者,同時從“等待傳送佇列”中取出隊首元素(Goroutine和其待傳送資料的打包),將其要傳送的資料放入buffer的隊尾,同時將對應的Goroutine喚醒;
  1.2 若沒有buffer,從“等待傳送佇列”中取出隊首元素,將其要傳送的資料直接拷貝給接收者,同時將對應的Goroutine喚醒。
2. 如果沒有在阻塞等待的傳送者:
  2.1 若buffer中還有資料,則取出隊首元素髮給接收者;
  2.2 若buffer已空,那麼,將接收者(Goroutine)和它為要接收的資料準備的地址( data := <-chanX , data 的地址)打包成一個struct,加入到“等待接收佇列”的末尾,同時將該接收者block。

六、for range讀取和select

關於這兩點,資料[9]已經有詳盡的描述了,讀者可前往閱讀。這裡拾人牙慧,強調兩個要點,因為在下認為這兩點確實非常重要:

  1. 如果傳送端不是一直髮資料,且沒有關閉channel,那麼,for range讀取會陷入block,道理很簡單,沒有資料可讀了。所以,要麼您能把控全域性,確保您的for range讀取不會block;要麼,別用for range讀channel。
  2. select不是loop,當它select了一個case執行後,整個select就結束了。所以,如果想要一直select,那就在select外層加上for吧。

七、channel的關閉

您需要知道以下幾點:

1. 關閉nil channel,或者關閉一個已經關閉的channel,會panic。
2. channel的關閉是不可逆的,一旦關閉就不能再“開啟”了,它沒有open函式。
3. 向一個已經關閉的channel寫資料,會panic。
4. 從一個已經關閉的channel讀資料,會先將buffer中的資料(如果有的話)讀出來,然後讀到的就是buffer可快取的資料型別對應的零值。特別注意,即使是unbuffered channel,關閉後也能讀出零值,見下面的程式碼。
  4.1 為什麼不關閉可能阻塞,關閉了反而不阻塞了呢?因為,理論上,不關閉,還是“有念想”的,如果出現寫者,還是可以往channel寫資料的,這樣就有資料可讀了;但一旦關閉,就徹底“沒念想”了(參考第3條),阻塞一萬年也沒用,所以就直接返回零值了。
  4.2 為什麼寫一個已關閉的channel會panic,而讀一個已關閉的channel卻不會panic呢?在下也不知道,這裡僅給出猜測:寫操作要比讀操作“危險”(想想POST請求和GET請求),因此,對寫操作的處理往往要比對讀操作的處理嚴格。對channel而言,讀closed channel只會影響自己(當前Goroutine),而寫操作就不同了(試想,如果可以向closed channel寫入預設零值,接著這些值又被其它Goroutine讀取……)。如果讓寫closed channel的Goroutine阻塞呢?要明白,這種阻塞是不可能被喚醒的,所以,試想一下,有許多個寫channel的Goroutine,然後,某個Goroutine把channel關閉了……那麼,為什麼不讓讀 closed channel的Goroutine也panic呢?哎,得饒人處且饒人,能不panic就不panic吧。另一個重要原因是,讀操作本身是可以判斷讀出的資料是來自未關閉的channel還是已關閉的channel的,見第6條。
  4.3 所謂“讀出預設零值”,其實是將對應資料直接置零了。如 data := <-chanX ,若 chanX 已關閉,則 data 直接被置為0值。可以通過閱讀原始碼瞭解這一點。
5. 利用for range讀channel,如果channel關閉,for loop會退出,不會讀出預設零值。
6. 可以通過 data, ok := <-chanX 的方式判斷channel是否關閉,若關閉, ok 為false,否則, ok 為true。
7. 除非業務需要(如channel被for range讀取),否則channel無需顯式關閉(參考資料[13])。資料[14]總結了關閉channel的原則,而資料[15]和資料[7]介紹了優雅關閉channel的方法。

一份儘可能全面的Go channel介紹
 1 package main
 2 
 3 import "fmt"
 4 
 5 func main() {
 6 	mychnl := make(chan int)
 7 	close(mychnl)
 8 	x := <-mychnl
 9 	fmt.Printf("%v\n", x)
10 }
Code: 讀出預設零值

八、原始碼解讀

資料[6]和[7]已經做了非常精彩的解讀,在下已無需班門弄斧。這裡僅再次強調channel所維護的主要資料結構,以幫助讀者更好地理解原始碼和channel本身。

  1. channel維護一個迴圈佇列,即快取區;
  2. channel維護兩個雙向連結串列,分別儲存等待向channel寫入的Goroutine和等待從channel讀資料的Goroutine。

九、引發panic/block的情形

何時會觸發panic:
1. 關閉nil channel;
2. 關閉已經關閉的channel;
3. 向已經關閉的channel寫資料。

何時會引起block:
1. nil channel的讀寫會恆阻塞;
2. unbuffered channel,在讀寫雙方未同時就緒時,阻塞;
3. buffered channel,buffer已空且沒有等待的寫者時,讀channel會阻塞;
4. buffered channel,buffer已滿且沒有等待的讀者時,寫channel會阻塞;
5. for range讀channel,且該channel既沒被關閉又沒有持續的寫者時,阻塞。

參考

[  1] Initializing channels in Go - Ukiah Smith
[  2] Channel · Go語言中文文件
[  3] Go語言的單向通道到底有什麼用? - 知乎
[  4] Getting Started With Golang Channels! Here’s Everything You Need to Know
[  5] Go 語言 Channel 實現原理精要 | Go 語言設計與實現
[  6] 深入理解Golang之channel - 掘金
[  7] 深入 Go 併發原語 — Channel 底層實現
[  8] go - The differences between channel buffer capacity of zero and one in golang - Stack Overflow
[  9] Go Channel 詳解
[
10] Go 系列教程 —— 21. Go 協程 - Go語言中文網 - Golang中文社群
[11] go - No output from goroutine - Stack Overflow
[12] Go中被閉包捕獲的變數何時會被回收 | Tony Bai
[13] go - Is it OK to leave a channel open? - Stack Overflow
[
14] channel關閉的注意事項 - Go語言中文網 - Golang中文社群
[
15] <譯>如何優雅的關閉channel - SegmentFault 思否

寫在後面
資料[9]還給出了與channel有關的定時、超時等操作,讀者可自行前往學習。

再次感謝本文所有連結對應的作者。在下才疏學淺,錯誤疏漏之處在所難免,懇請廣大讀者批評指正,您的批評是在下前進的不竭動力。

 

相關文章