channel 是什麼
Don't communicate by sharing memory, share memory by communicating.
相信寫過 Go 的同學都知道這句名言,可以說 channel 就是後邊這句話的具體實現。我們來看一下到底 channel 是什麼?
channel 是一個類型安全的隊列(循環隊列),能夠控制 groutine 在它上面讀寫消息的行爲,比如:阻塞某個 groutine ,或者喚醒某個 groutine。
不同的 groutine 可以通過 channel 交換任意的資源,由於 channel 能夠控制 groutine 的行爲,所以 CSP 模型才能在 Golang 中順利實現,它確保了不同 groutine 之間的數據同步機制。
上面的話是不是聽起來非常的不舒服?
好吧,簡單說人話就是,channel 是用來在 不同的 的 goroutine 中交換數據的。一定要注意這裏 不同的 三個字。千萬不要把 channel 拿來在不同函數(同一個 goroutine 中)間交換數據。
使用
知道了定義,我們來看具體如何使用。
如何定義一個 channel 類型呢?
var ch1 chan int // 定義了一個 int 類型的 channel,沒有初始化,是 nil
ch2 := make(chan int) // 定義+初始化了一個無緩衝的 int 類型 channel
ch3 := make(chan int) // 定義+初始化了一個有緩衝的 int 類型 channel
上面的定義方法我們都是定義的雙向通道,對應的還有單向通道,但是單向通道我們一般只是做爲函數參數來進行一些限制,並不會在定義、初始化時就搞一個單向通道出來。因爲你定義一個單向通道沒有任何實際價值,通道的存在本來就是用來交換數據的,單向通道只能滿足發或者收。
下面我們一起來看一下具體的使用,以及使用中注意的一些點。
send
不管是有緩衝的通道還是無緩衝的通道都是用來交換數據的,既然是交換數據,無非就是寫入、讀取。我們先從發送開始。
無緩衝 channel
ch := make(chan int)
defer close(ch)
//ch<-5 // 位置一
go func(ch chan int) {
num := <-ch
fmt.Println(num)
}(ch)
// ch<-5 // 位置二
如果我們打開 位置一 的註釋,程序是無法獲得預期執行的,由於該 channel 是無緩衝的,位置一的代碼會陷入阻塞,下一行的 goroutine 根本沒有機會執行。整個代碼會陷入死鎖。
正確的操作是,打開 位置二 的註釋,因爲上一行 goroutine 先行啓動,他是一個獨立的協程,不會阻塞主 groutine 的執行。但它內部會阻塞在 num := <-ch
這行代碼,直到主協程執行完 ch<-5
,纔會執行打印。所以這裏也有一個非常重要的問題,主協程如果不等待子協程執行完就退出的話,會看不到執行結果。
這裏先提一點,無緩衝的 channel 並不會用到內部結構體的 buf
,這部分具體會在源碼部分講解他們的數據存取、交換的方式。
有緩衝 channel
ch := make(chan int, 1) // 注意這裏
defer close(ch)
//ch<-5 // 位置一
go func(ch chan int) {
num := <-ch
fmt.Println(num)
}(ch)
// ch<-5 // 位置二
代碼基本沒有改變,唯一的區別是 make 函數傳入了第二個參數,這個值的含義是緩衝的大小。那麼此時 位置一 與 位置二 都能夠正常執行嗎?
答案是肯定的,此時的代碼,無論是那個位置,打開註釋後都能夠正常執行。原因就在於由於 channel 有了緩存區域,位置一 寫入數據不會造成主協程的阻塞,那麼下一行代碼的子協程就可以正常啓動,並直接將位置一寫入 buf
的數據讀取出來打印。
對於 位置二 ,由於子協程先啓動,但是會被阻塞在 num := <-ch
這一行,因爲此時 buf
中沒有任何內容可讀取(下期源碼分析我們可以看代碼實現),直到位置二執行完,喚醒子協程。
發送需要注意幾個問題:
-
什麼時候會被阻塞?
-
向
nil
通道發送數據會被阻塞 -
向無緩衝 channel 寫數據,如果讀協程沒有準備好,會阻塞
-
向有緩衝 channel 寫數據,如果緩衝已滿,會阻塞
-
-
什麼時候會
panic
?-
closed的 channel,寫數據會 panic
-
-
就算是有緩衝的 channel ,也不是每次發送、接收都要經過緩存,如果發送的時候,剛好有等待接收的協程,那麼會直接交換數據。
receive
有寫入,必然後讀取。
還是上面的代碼, num := <-ch
就是從 channel 讀取數據。對於讀取就不按照有緩衝與無緩衝來講解了,它們的主要問題是什麼時候阻塞。通過上面寫的例子自己再想想即可。
這裏說下讀取的兩種形式。
形式一
multi-valued assignment
v, ok := <-ch
ok
是一個 bool 類型,可以通過它來判斷 channel 是否已經關閉,如果關閉該值爲 true ,此時 v 接收到的是 channel 類型的零值。比如:channel 是傳遞的 int, 那麼 v 就是 0 ;如果是結構體,那麼 v 就是結構體內部對應字段的零值。
形式二
v := <-ch
該方式對於關閉的 channel 無法掌控,我們示例中就是該種方式。
接收需要注意幾個問題:
-
什麼時候會被阻塞?
-
從
nil
通道接收數據會被阻塞 -
從無緩衝 channel 讀數據,如果寫協程沒有準備好,會阻塞
-
從有緩衝 channel 讀數據,如果緩衝爲空,會阻塞
-
-
讀取的 channel 如果被關閉,並不會影響正在讀的數據,它會將所有數據讀取完畢,並不會立即就失敗或者返回零值
close
對於 channel 的關閉,在什麼地方去關閉呢?因爲上面也講到向 closed 的 channel 寫或者繼續 close 都會導致 panic問題。
一般的建議是誰寫入,誰負責關閉。如果涉及到多個寫入的協程、多個讀取的協程?又該如何關閉?總的來說就是加入一個標記避免重複關閉。不過真的不建議搞的太複雜,否則後續維護代碼會瘋掉。
關閉需要注意幾個問題:
-
什麼時候會
panic
?-
closed 的 channel,再次關閉 close 會 panic
-
for-range
我們常常會用 for-range
來讀取 channel的數據。
ch := make(chan int, 1)
go func(ch chan int) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}(ch)
for val := range ch {
fmt.Println(val)
}
該語句的一個特色是如果 channel 已經被關閉,它還是會繼續執行,直到所有值被取完,然後退出執行。而如果通道沒有關閉,但是channel沒有可讀取的數據,它則會阻塞在 range 這句位置,直到被喚醒。但是如果 channel 是 nil,那麼同樣符合我們上面說的的原則,讀取會被阻塞,也就是會一直阻塞在 range
位置。
select
select
是跟 channel 關係最親密的語句,它是被專門設計出來處理通道的,因爲每個 case 後面跟的都是通道表達式,可以是讀,也可以是寫。
ch := make(chan int)
q := make(chan int)
go func(ch, q chan int) {
for i := 0; i < 10; i++ {
num := <-ch
fmt.Println(num)
}
q <- 1
}(ch, q)
fibonacci := func(ch, q chan int) {
x, y := 0, 1
for {
select {
case ch <- x: // 寫入
x, y = y, x+y
break // 你覺得是否會影響 for 語句的循環?
case <-q: // 讀取
fmt.Println("quit")
return
}
}
}
fibonacci(ch, q)
上面的代碼是利用 channel 實現的一個斐波拉契數列。select 還可以有 default 語句,該語句會在其它 case 都被阻塞的情況下執行。
關注的問題
-
select 只要有默認語句,就不會被阻塞,換句話說,如果沒有 default,然後 case 又都不能讀或者寫,則會被阻塞
-
nil 的 channel,不管讀寫都會被阻塞
-
select 不能夠像
for-range
一樣發現 channel 被關閉而終止執行,所以需要結合multi-valued assignment
來處理 -
如果同時有多個 case 滿足了條件,會使用僞隨機選擇一個 case 來執行
-
select 語句如果不配合 for 語句使用,只會對 case 表達式求值一次
-
每次 select 語句的執行,是會掃碼完所有的 case 後才確定如何執行,而不是說遇到合適的 case 就直接執行了。
總結
本文內容很簡單易懂,希望大家徹底掌握了 channel 的使用。一切源碼的研究都是爲了更好的使用,後面的文章將開始研究 channel 的源碼實現。
本文幾個重要問題再次總結下,也是經常面試的常考點。
-
向 close 的 channel 寫數據、再次 close 都會觸發
runtime panic
。 -
向 nil channel 寫、讀取數據,都會阻塞,可以利用這點來優化 for + select 的用法。
-
channel 的關閉最好在寫入方處理,讀的協程不要去關閉 channel,可以通過單向通道來表明 channel 在該位置的功能。
-
如果有多個寫協程的 channel 需要關閉,可以使用額外的 channel 來標記,也可以使用
sync.Once
或者sync.Mutex
來處理。 -
channel 不管是讀寫都是併發安全的,不會出現多個協程同時讀或者寫的情況,從而實現了 CSP。