Go 併發編程 - channel 連接一切

在上一篇文章中,我們介紹了 Go 併發編程的基礎—goroutine,同時也介紹 goroutine 的幾種使用方式,但沒有說明 goroutine 之間是如何通信的。

Go 語言中有一句經典的話,不要通過共享內存來通信,而應該通過通信來共享內存。這個原則讓 channel 成爲了 Go 語言中非常重要的一個組件。

goroutine 之間的通信主要是通過 channel 來完成的,這篇文章中,我們來認識一下 channel,以及 channel 的基本使用。

  1. 什麼是通道(channel)

Go 語言中,併發模式有兩種實現方式,一種是傳統的通過鎖和信號量等手段,來實現對個共享變量(內存)的同步訪問,從而實現併發。還有一種通過 goroutine + channel 的組合方式,傳遞值的方式來實現併發。

goroutine + channel 是對 CSP(Communicating Sequential Process)模式的一種實現。CSP 模式中,有兩個核心的概念,process 和 channel,process 對應 groutine,所有的 process 之間的通信通過 channel 來實現。

channel 是可以被單獨創建的,可以用來連接任意兩個 goroutine,channel 也有自己的數據類型,被稱之爲通道的元素類型

創建一個通道很簡單,比如下面創建了傳遞 int 值的通道:

ch := make(chan int)

chan 表示通道,int 表示通道中傳遞的元素類型,使用 make 就可以創建一個新的通道。make 返回的結果是通道的引用,當複製這個通道或者把通道作爲函數參數的時候,傳遞的都是引用,這點很重要,需要重點理解一下。這裏順便說一下,channel 是可比較的,也就是說可以通過 == 來比較。

通道有兩個操作,一個是發送,一個是接收,都使用 <- 來表示,區別在於發送時,通道在前,接收時通道在後。向一個通道中發送數據:

x := 5
ch <- x

從通道中接收一個結果,如果不把結果賦值給一個變量,結果就會被拋棄,這樣也是合法的:

x := <-ch
<-ch // 這樣也是合法的

一個完整的發送和接收的例子如下:

package main
import "fmt"
func main() {
  ch := make(chan int)
  go func() {
    x := 5
    ch <- x
  }()
  y := <-ch
  fmt.Println(y)
}

在使用通道的過程中,可能會出現死鎖,具體的原因我們下文再詳細說。對於通道來說,還有一個操作,就是關閉通道,對於一個已經關閉的 channel,無法再發送數據,否則會發生 panic,但是可以進行接收操作,下面的程序可以正常運行:

package main
import (
  "fmt"
)
func main() {
  ch := make(chan int)
  go func() {
    x := 5
    ch <- x
    close(ch)
  }()
  y := <-ch
  fmt.Println(y)
}
  1.  無緩衝通道

上面用來創建通道的 make 其實還有第二個參數,用來指定通道容量。如果不指定這個參數或者指定的參數是 0,那麼就表示這個通道是無緩衝通道:

// 下面兩種創建方式是等價的
ch := make(chan int)
ch := make(chan int, 0)

在無緩衝通道上的發送操作會阻塞,直到接收端的接收操作完成,然後纔會繼續執行。在上一篇文章中,我們爲了解決主 goroutine 等待子 goroutine 執行完成用的就是這個方法。代碼如下:

func goroutine2(isDone chan bool) {
  fmt.Println("child goroutine begin...")
  time.Sleep(2 * time.Second)
  fmt.Println("child goroutine end...")
  isDone <- true
}
func main() {
  isDone := make(chan bool)
  go goroutine2(isDone)
  <-isDone
  fmt.Println("main goroutine end..")
}

所以對於無緩衝通道來說,不能在同一個 goroutine 中使用,否則會造成死鎖。關於死鎖的問題,下文再詳細討論。

  1. 緩衝通道

在創建緩衝通道時,需要指定通道的容量:

ch := make(chan int, 3)

上面的代碼創建了容量爲 3 的通道,可以直接向通道中發送值,發送的前 3 個操作不會阻塞:

ch <- 1
ch <- 2
ch <- 3

如果在發送的過程中,如果接收端沒有接收,那麼此時通道就是滿的,在發送第 4 個值的時候就會阻塞。

對於緩衝通道,可以使用 cap 方法得到通道的容量,可以使用 len 方法得到當前通道中元素的個數:

cap(ch) // 獲取容量
len(ch) // 獲取元素個數

對於一個緩衝通道,在同一個 goroutine 中使用也有造成死鎖的風險,所以最好不要在同一個 goroutine 中使用通道。

  1. 單向通道

在默認情況下,創建的通道可以發送數據,可以接受數據,但是在一些情況下,我們只需要通道的發送或者接收能力。這個時候,就需要單向通道。

單向通道的表示起來很簡單,把 <- 放在 chan 前,表示只接收,放在 chan 後表示只發送:

sendCh := male(chan<- int) // 表示只發送的通道
recCh := make(<-chan int) // 表示只接收的通道

但實際的使用中,我們不需要去創建這種單向通道,只是在某些情況下,我們把通道轉成單向通道就行。比如下面的代碼中,在 sendData 方法中,我只需要用到通道的發送能力,所以可以通道改成發送的單向通道,其他人閱讀代碼的時候,也更能理解:

func main() {
  ch := make(chan int, 10)
  sendData(ch)
}
func sendData(sendCh chan<- int) {
  for i := 0;i < 10; i++ {
    sendCh <- i
  }
}

雙向通道可以轉成轉成單向通道,但反過來卻不行。

  1. 小結

這篇文章介紹了通道,通道對於 Go 語言來說很重要,是實現高併發的基礎,通道爲 goroutine 之間提供了一種高效安全的通信方式。但在使用通道的時候需要注意死鎖問題。

文 / Raujun

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