正確使用Golang的Channel

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 中沒有任何內容可讀取(下期源碼分析我們可以看代碼實現),直到位置二執行完,喚醒子協程。


發送需要注意幾個問題:

  1. 什麼時候會被阻塞?

    • 向 nil 通道發送數據會被阻塞

    • 向無緩衝 channel 寫數據,如果讀協程沒有準備好,會阻塞

    • 向有緩衝 channel 寫數據,如果緩衝已滿,會阻塞

  2. 什麼時候會 panic

    • closed的 channel,寫數據會 panic

  3. 就算是有緩衝的 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 無法掌控,我們示例中就是該種方式。


接收需要注意幾個問題:

  1. 什麼時候會被阻塞?

    • 從 nil 通道接收數據會被阻塞

    • 從無緩衝 channel 讀數據,如果寫協程沒有準備好,會阻塞

    • 從有緩衝 channel 讀數據,如果緩衝爲空,會阻塞

  2. 讀取的 channel 如果被關閉,並不會影響正在讀的數據,它會將所有數據讀取完畢,並不會立即就失敗或者返回零值

close

對於 channel 的關閉,在什麼地方去關閉呢?因爲上面也講到向 closed 的 channel 寫或者繼續 close 都會導致 panic問題。

一般的建議是誰寫入,誰負責關閉。如果涉及到多個寫入的協程、多個讀取的協程?又該如何關閉?總的來說就是加入一個標記避免重複關閉。不過真的不建議搞的太複雜,否則後續維護代碼會瘋掉。


關閉需要注意幾個問題:

  1. 什麼時候會 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 都被阻塞的情況下執行。


關注的問題

  1. select 只要有默認語句,就不會被阻塞,換句話說,如果沒有 default,然後 case 又都不能讀或者寫,則會被阻塞

  2. nil 的 channel,不管讀寫都會被阻塞

  3. select 不能夠像 for-range 一樣發現 channel 被關閉而終止執行,所以需要結合 multi-valued assignment 來處理

  4. 如果同時有多個 case 滿足了條件,會使用僞隨機選擇一個 case 來執行

  5. select 語句如果不配合 for 語句使用,只會對 case 表達式求值一次

  6. 每次 select 語句的執行,是會掃碼完所有的 case 後才確定如何執行,而不是說遇到合適的 case 就直接執行了。

總結

本文內容很簡單易懂,希望大家徹底掌握了 channel 的使用。一切源碼的研究都是爲了更好的使用,後面的文章將開始研究 channel 的源碼實現。

本文幾個重要問題再次總結下,也是經常面試的常考點。

  1. 向 close 的 channel 寫數據、再次 close 都會觸發 runtime panic

  2. 向 nil channel 寫、讀取數據,都會阻塞,可以利用這點來優化 for + select 的用法。

  3. channel 的關閉最好在寫入方處理,讀的協程不要去關閉 channel,可以通過單向通道來表明 channel 在該位置的功能。

  4. 如果有多個寫協程的 channel 需要關閉,可以使用額外的 channel 來標記,也可以使用 sync.Once 或者 sync.Mutex 來處理。

  5. channel 不管是讀寫都是併發安全的,不會出現多個協程同時讀或者寫的情況,從而實現了 CSP。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章