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


寫在前面

針對目前網絡上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 示例

請看下面的代碼:

 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 ):

 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來解決這一問題:

 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

結果是:

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的方法。

 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有關的定時、超時等操作,讀者可自行前往學習。

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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM