Go高效併發 08 | 併發基礎:Goroutines 和 Channels 的宣告與使用

Swenson1992發表於2021-02-05

什麼是併發

因為計算機很強大,如果只讓它幹完一件事情再幹另外一件事情就太浪費了。比如一款音樂軟體,使用它聽音樂的時候還想讓它下載歌曲,同一時刻做了兩件事,在程式設計中,這就是併發,併發可以讓你編寫的程式在同一時刻做多幾件事情。

程式和執行緒

講併發就繞不開執行緒,不過在介紹執行緒之前,我先為你介紹什麼是程式。

程式

在作業系統中,程式是一個非常重要的概念。當你啟動一個軟體(比如瀏覽器)的時候,作業系統會為這個軟體建立一個程式,這個程式是該軟體的工作空間,它包含了軟體執行所需的所有資源,比如記憶體空間、檔案控制程式碼,還有下面要講的執行緒等。下面的圖片就是我的電腦上執行的程式:

那麼執行緒是什麼呢?

執行緒

執行緒是程式的執行空間,一個程式可以有多個執行緒,執行緒被作業系統排程執行,比如下載一個檔案,傳送一個訊息等。這種多個執行緒被作業系統同時排程執行的情況,就是多執行緒的併發。

一個程式啟動,就會有對應的程式被建立,同時程式也會啟動一個執行緒,這個執行緒叫作主執行緒。如果主執行緒結束,那麼整個程式就退出了。有了主執行緒,就可以從主線裡啟動很多其他執行緒,也就有了多執行緒的併發。

協程(Goroutine)

Go 語言中沒有執行緒的概念,只有協程,也稱為 goroutine。相比執行緒來說,協程更加輕量,一個程式可以隨意啟動成千上萬個 goroutine。

goroutine 被 Go runtime 所排程,這一點和執行緒不一樣。也就是說,Go 語言的併發是由 Go 自己所排程的,自己決定同時執行多少個 goroutine,什麼時候執行哪幾個。這些對於開發者來說完全透明,只需要在編碼的時候告訴 Go 語言要啟動幾個 goroutine,至於如何排程執行,開發者不用關心。

要啟動一個 goroutine 非常簡單,Go 語言為我們提供了 go 關鍵字,相比其他程式語言簡化了很多,如下面的程式碼所示:

func main() {
   go fmt.Println("golang")
   fmt.Println("我是 main goroutine")
   time.Sleep(time.Second)
}

這樣就啟動了一個 goroutine,用來呼叫 fmt.Println 函式,列印“golang”。所以這段程式碼裡有兩個 goroutine,一個是 main 函式啟動的 main goroutine,一個是通過 go 關鍵字啟動的 goroutine。

從示例中可以總結出 go 關鍵字的語法,如下所示:

go function()

go 關鍵字後跟一個方法或者函式的呼叫,就可以啟動一個 goroutine,讓方法在這個新啟動的 goroutine 中執行。執行以上示例,可以看到如下輸出:

我是 main goroutine
golang

從輸出結果也可以看出,程式是併發的,go 關鍵字啟動的 goroutine 並不阻塞 main goroutine 的執行,所以才會看到如上列印結果。

小提示:示例中的 time.Sleep(time.Second) 表示等待一秒,這裡是讓 main goroutine 等一秒,不然 main goroutine 執行完畢程式就退出了,也就看不到啟動的新 goroutine 中“golang”的列印結果了。

Channel

那麼如果啟動了多個 goroutine,它們之間該如何通訊呢?這就是 Go 語言提供的 channel(通道)要解決的問題。

宣告一個 channel

在 Go 語言中,宣告一個 channel 非常簡單,使用內建的 make 函式即可,如下所示:

ch:=make(chan string)

其中 chan 是一個關鍵字,表示是 channel 型別。後面的 string 表示 channel 裡的資料是 string 型別。通過 channel 的宣告也可以看到,chan 是一個集合型別。

定義好 chan 後就可以使用了,一個 chan 的操作只有兩種:傳送和接收。

  • 接收:獲取 chan 中的值,操作符為 <- chan。
  • 傳送:向 chan 傳送值,把值放在 chan 中,操作符為 chan <-。

小技巧:這裡注意傳送和接收的操作符,都是 <- ,只不過位置不同。接收的 <- 操作符在 chan 的左側,傳送的 <- 操作符在 chan 的右側。

現在把上個示例改造下,使用 chan 來代替 time.Sleep 函式的等待工作,如下面的程式碼所示:

func main() {
   ch:=make(chan string)

   go func() {
      fmt.Println("golang")
      ch <- "goroutine 完成"
   }()

   fmt.Println("我是 main goroutine")

   v:=<-ch
   fmt.Println("接收到的chan中的值為:",v)
}

執行這個示例,可以發現程式並沒有退出,可以看到”golang”的輸出結果,達到了 time.Sleep 函式的效果,如下所示:

我是 main goroutine
golang
接收到的chan中的值為: goroutine 完成

可以這樣理解:在上面的示例中,在新啟動的 goroutine 中向 chan 型別的變數 ch 傳送值;在 main goroutine 中,從變數 ch 接收值;如果 ch 中沒有值,則阻塞等待到 ch 中有值可以接收為止。

相信你應該明白為什麼程式不會在新的 goroutine 完成之前退出了,因為通過 make 建立的 chan 中沒有值,而 main goroutine 又想從 chan 中獲取值,獲取不到就一直等待,等到另一個 goroutine 向 chan 傳送值為止。

channel 有點像在兩個 goroutine 之間架設的管道,一個 goroutine 可以往這個管道里傳送資料,另外一個可以從這個管道里取資料,有點類似於佇列。

無緩衝 channel

上面的示例中,使用 make 建立的 chan 就是一個無緩衝 channel,它的容量是 0,不能儲存任何資料。所以無緩衝 channel 只起到傳輸資料的作用,資料並不會在 channel 中做任何停留。這也意味著,無緩衝 channel 的傳送和接收操作是同時進行的,它也可以稱為同步 channel。

有緩衝 channel

有緩衝 channel 類似一個可阻塞的佇列,內部的元素先進先出。通過 make 函式的第二個引數可以指定 channel 容量的大小,進而建立一個有緩衝 channel,如下面的程式碼所示:

cacheCh:=make(chan int, 5)

建立了一個容量為 5 的 channel,內部的元素型別是 int,也就是說這個 channel 內部最多可以存放 5 個型別為 int 的元素,如下圖所示:

一個有緩衝 channel 具備以下特點:

  1. 有緩衝 channel 的內部有一個緩衝佇列;
  2. 傳送操作是向佇列的尾部插入元素,如果佇列已滿,則阻塞等待,直到另一個 goroutine 執行,接收操作釋放佇列的空間;
  3. 接收操作是從佇列的頭部獲取元素並把它從佇列中刪除,如果佇列為空,則阻塞等待,直到另一個 goroutine 執行,傳送操作插入新的元素。

因為有緩衝 channel 類似一個佇列,可以獲取它的容量和裡面元素的個數。如下面的程式碼所示:

cacheCh:=make(chan int,5)
cacheCh <- 2
cacheCh <- 3
fmt.Println("cacheCh容量為:",cap(cacheCh),",元素個數為:",len(cacheCh))

其中,通過內建函式 cap 可以獲取 channel 的容量,也就是最大能存放多少個元素,通過內建函式 len 可以獲取 channel 中元素的個數。

小提示:無緩衝 channel 其實就是一個容量大小為 0 的 channel。比如 make(chan int,0)。

關閉 channel

channel 還可以使用內建函式 close 關閉,如下面的程式碼所示:

close(cacheCh)

如果一個 channel 被關閉了,就不能向裡面傳送資料了,如果傳送的話,會引起 painc 異常。但是還可以接收 channel 裡的資料,如果 channel 裡沒有資料的話,接收的資料是元素型別的零值。

單向 channel

有時候,有一些特殊的業務需求,比如限制一個 channel 只可以接收但是不能傳送,或者限制一個 channel 只能傳送但不能接收,這種 channel 稱為單向 channel。

單向 channel 的宣告也很簡單,只需要在宣告的時候帶上 <- 操作符即可,如下面的程式碼所示:

onlySend := make(chan<- int)
onlyReceive:=make(<-chan int)

注意,宣告單向 channel <- 操作符的位置和上面講到的傳送和接收操作是一樣的。

在函式或者方法的引數中,使用單向 channel 的較多,這樣可以防止一些操作影響了 channel。

下面示例中的 counter 函式,它的引數 out 是一個只能傳送的 channel,所以在 counter 函式體內使用引數 out 時,只能對其進行傳送操作,如果執行接收操作,則程式不能編譯通過。

func counter(out chan<- int) {
  //函式內容使用變數out,只能進行傳送操作
}

select+channel 示例

假設要從網上下載一個檔案,啟動了 3 個 goroutine 進行下載,並把結果傳送到 3 個 channel 中。其中,哪個先下載好,就會使用哪個 channel 的結果。

在這種情況下,如果嘗試獲取第一個 channel 的結果,程式就會被阻塞,無法獲取剩下兩個 channel 的結果,也無法判斷哪個先下載好。這個時候就需要用到多路複用操作了,在 Go 語言中,通過 select 語句可以實現多路複用,其語句格式如下:

select {
case i1 = <-c1:
     //todo
case c2 <- i2:
    //todo
default:
    // default todo
}

整體結構和 switch 非常像,都有 case 和 default,只不過 select 的 case 是一個個可以操作的 channel。

小提示:多路複用可以簡單地理解為,N 個 channel 中,任意一個 channel 有資料產生,select 都可以監聽到,然後執行相應的分支,接收資料並處理。

有了 select 語句,就可以實現下載的例子了。如下面的程式碼所示:

func main() {
   //宣告三個存放結果的channel
   firstCh := make(chan string)
   secondCh := make(chan string)
   threeCh := make(chan string)
   //同時開啟3個goroutine下載
   go func() {
      firstCh <- downloadFile("firstCh")
   }()
   go func() {
      secondCh <- downloadFile("secondCh")
   }()
   go func() {
      threeCh <- downloadFile("threeCh")
   }()
   //開始select多路複用,哪個channel能獲取到值,
   //就說明哪個最先下載好,就用哪個。
   select {
   case filePath := <-firstCh:
      fmt.Println(filePath)
   case filePath := <-secondCh:
      fmt.Println(filePath)
   case filePath := <-threeCh:
      fmt.Println(filePath)
   }
}
func downloadFile(chanName string) string {
   //模擬下載檔案,可以自己隨機time.Sleep點時間試試
   time.Sleep(time.Second)
   return chanName+":filePath"
}

如果這些 case 中有一個可以執行,select 語句會選擇該 case 執行,如果同時有多個 case 可以被執行,則隨機選擇一個,這樣每個 case 都有平等的被執行的機會。如果一個 select 沒有任何 case,那麼它會一直等待下去。

總結

通過 go 關鍵字啟動一個 goroutine,以及通過 channel 實現 goroutine 間的資料傳遞。

在 Go 語言中,提倡通過通訊來共享記憶體,而不是通過共享記憶體來通訊,其實就是提倡通過 channel 傳送接收訊息的方式進行資料傳遞,而不是通過修改同一個變數。所以在資料流動、傳遞的場景中要優先使用 channel,它是併發安全的,效能也不錯。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
?

相關文章