深入 Golang channel 基礎用法都在這了!

1. 前言

你好哇!本文是「Golang 併發編程」系列的第 2 篇文章~

現在感覺這個坑開得有點大,沒個一年半載的講不清楚了……

上篇文章我們學習了 Go 語言中的 Goroutine 創建和調度的機制,理解了一條簡單的 go 命令背後的原理。本文開始,將深入研究 go 語言中的 channel,將拆分爲基礎用法、實用場景、反面教材、源碼實現四篇文章來介紹,歡迎關注追更~

本文將介紹 channel 相關的概念、語法和規則,不涉及原理和源碼分析,更深入的內容,後面的更新會覆蓋到,敬請期待~

2. Channel 簡介

Channel 是 go 語言內置的一個非常重要的特性,也是 go 併發編程的兩大基石之一(另一個是 go ,也就是 goroutine )。Channel 也是 go 裏面非常有趣的一個功能,有時候甚至有點燒腦,相對於傳統的 mutex 等同步併發原語,掌握 channel 門檻更高、更容易出錯,因此更值得深入學習。

關於併發編程,Rob Pike 有個名言:

不要通過共享內存來通信,要通過通信來共享內存
Don't (let computations) communicate by sharing memory, (let them) share memory by communicating (through channels)

在 go 語言中,channel 就是 goroutine 之間通過通信來共享內存的手段。可以把 channel 看作 go 程序內部的一個 FIFO (first in, first out) 隊列,一些 goroutine 向其中生產數據,另外一些消費數據。

另外 channel 是 go 語言中的一等公民,不像使用 mutexwaitgroup 等其它併發編程原語需要引入 syncatomic 等包,channel 可以直接使用,不需要引入任何包。

3. Channel 的類型和值

跟 slice 、map 這些內置類型一樣,channel 作爲一種元素類型,也是有具體的類型的,channel 只能傳遞聲明的類型的值。

基礎類型:雙向與單向 channel

對於類型 T,可以聲明三種類型的 channel:

func foo(ch1 <-chan int)  // 只能從 ch1 裏讀

func bar(ch2 chan<- int)  // 只能往 ch2 裏寫

雙向 channel chan T 可以被隱式轉換成 send-only channel chan<- T 或 receive-only channel <-chan T;而 chan<- T<-chan T 兩者則不能互相轉換(顯式轉換也不行)

單向 channel 是一種函數傳參時的安全性約束,在實際使用中幾乎不可能單獨去聲明一個單向的 channel。

buffered channel + unbuffered channel + nil channel

每個 channel 類型的值都會有一個容量(capacity),根據 capacity 大小來區分,可以分爲兩種:

使用 make 創建 channel:

ch1 := make(chan int, 10)  // buffered channel, cap = 10
ch2 := make(chan int) // unbuffered channel, cap = 0 (make chan 函數第二參數默認值爲 0)
var ch3 chan int  // nil 是 chan 的零值(zero value)

注意到上面的例子中還有一種特殊的 channel :nil channel,在只聲明但是並未 make 時,channel 的值是零值 nil。nil channel 無論是寫入還是讀取都會永久阻塞住。

4. channel 的 7 種操作

1. 向 channel 發送值

ch <- v

需要注意:

2. 從 channel 裏讀取結果

<- ch
v = <-ch
v, sentBeforeClosed = <-ch  // 先關閉再發送 v,則返回 false

這種方式與操作 map 時的方式類似,被稱作 ok-idiom,並且,在 close(ch) 函數執行的時候,會對 ch 發送一條消息,這個動作可以用來通知所有的 goroutine 退出,例如:

package main

func main() {
    done := make(chan struct{})
    c := make(chan int)

    go func() {
        defer close(done)
        
        for {
            x, ok := <-c
            
            if !ok { // close 時會收到一條消息,x 值爲 0,ok 爲 false
                return
            }

            println(x)
        }
    }()

    c <- 1
    c <- 2
    c <- 3
    close(c)
    <-done // close 時會收到消息,解除阻塞
}

3. for-range 操作

使用 for-range 遍歷 channel 會比使用 ok-idiom 更簡潔,將上面的例子用 for-range 的方式來實現:

func main() {
    done := make(chan struct{})
    c := make(chan int)

    go func() {
        defer close(done)
        
        for x := range c {
            println(x)
        }
    }()

    c <- 1
    c <- 2
    c <- 3
    close(c)
    <-done // close 時會收到消息,解除阻塞
}

4. select 多路選擇

將在下文專門討論

5. 關閉 channel

close(ch)

close 是個 go 內置函數,只能操作 channel 類型,且 close 的對象不能是 receive-only channel。另外,在前面的例子裏可以看到, close 發生時,會向被關閉的 channel 發送一條消息,解除阻塞,這個特性可以用來做一些一次性的操作。

錯誤的 close 會引發程序的 panic,關於如何優雅關閉 channel,我會在後面的「反面教材:panic 和內存泄漏」主題裏展開,敬請期待~繼續挖坑……

6. 返回 channel 的容量(capacity)

cap(ch)

cap 是 go 的內置函數,會返回一個 int 類型的 channel 容量,可以參考作用在 slice 時的表現

7. 返回 channel buffer 中值的數量

len(ch)

cap 函數類似,len也是 go 語言的內置函數,返回值是已經成功寫入 channel buffer 但是還沒有從 channel 裏讀出來的值的數量。在 channel 的操作中,caplen 函數其實用得都不多。

5. 阻塞場景梳理

針對根據 channel 是否爲空和是否關閉,可以分成以下三類來討論:

  1. 空 channel (nil channel)

  2. 非空已關閉 channel

  3. 非空未關閉 channel

oleFzt

要理解這幾種現象,就要看下 channel 的內部結構了,可以認爲 channel 內部有三個 FIFO 隊列

  1. 接收數據的 goroutine 隊列,是一個無限長的鏈表,這個隊列裏的 goroutine 都處於阻塞狀態,等待數據從 channel 寫入

  2. 發送數據的 goroutine 隊列,也是一個無限長的鏈表,這個隊列裏的 goroutine 都處於阻塞狀態,等待數據向 channel 寫入。每個 goroutine 嘗試發送的值也和 goroutine 一起存在這個隊列裏

  3. 值 buffer 隊列,是一個環形隊列(ringbuffer),它的大小跟 channel 的容量相同。存在這個 buffer 隊列裏的值跟 channel 元素的類型相同。如果當前 buffer 隊列裏儲存的值的數量達到了 channel 的容量,這個 channel 就「滿了」,對於 unbuffered channel 而言,它總是既在「空」狀態,又在「滿」狀態。

更多的原理性的分析,將在本系列的後續「原理與源碼分析」文章展開。

6. 多路選擇操作符 select

select 語句是專門爲 channel 操作而設計的,使用時類似 switch-case 的用法,適用於處理多通道的場景,會通過類似 are-you-ready-polling 的機制來工作。當多個 case 中有一個準備好了,就能執行,無論是收還是發。

阻塞與非阻塞 select

select 默認是阻塞的,當沒有 case 處於激活狀態時,會一直阻塞住,極端的甚至可以這樣用...

select {
    // 啥也不幹,一直阻塞住
}

通過增加 default,可以實現非阻塞的 select

select {
    case x, ok := <-ch1:
        ...
    case ch2 <- y:
        ...
    default:
        fmt.Println("default")

}

多 case 與 default 執行的順序

整體流程如圖所示:需要注意:

func main() {
   var ch chan int
   i := 0
   for {
    select {
    case <-ch:  // nil channel 永遠阻塞
       fmt.Println("never...")
    default:
       fmt.Printf("in default, i = %d\n", i)
    }
      i++
   }
}

小結

本文完整介紹了 channel 的所有基礎用法,包括 <-ok-idiomselect-caseclosefor-range 等,並分析了阻塞與非阻塞的場景。後面三篇文章將分別介紹 channel 在實際的程序世界中的多種實用案例、底層實現及使用不當導致的死鎖、內存泄漏等問題,敬請期待!

翔叔架構筆記 專注 Golang 後端開發、架構、大數據、職場發展等,立志輸出優質原創內容;關於翔叔:鵝廠長大的 T10,5 年互聯網後端服務與架構設計研發經驗,擅長社交領域、數據中臺等系統的設計與實現。希望與你共同成長 :)

參考資料

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/m_Nd_3pW9MkYic6Ds52GMQ