Go語言核心36講(Go語言進階技術四)--學習筆記

MingsonZheng發表於2021-10-21

10 | 通道的基本操作

作為 Go 語言最有特色的資料型別,通道(channel)完全可以與 goroutine(也可稱為 go 程)並駕齊驅,共同代表 Go 語言獨有的併發程式設計模式和程式設計哲學。

Don’t communicate by sharing memory; share memory by communicating. (不要通過共享記憶體來通訊,而應該通過通訊來共享記憶體。)

這是作為 Go 語言的主要創造者之一的 Rob Pike 的至理名言,這也充分體現了 Go 語言最重要的程式設計理念。而通道型別恰恰是後半句話的完美實現,我們可以利用通道在多個 goroutine 之間傳遞資料。

前導內容:通道的基礎知識

通道型別的值本身就是併發安全的,這也是 Go 語言自帶的、唯一一個可以滿足併發安全性的型別。它使用起來十分簡單,並不會徒增我們的心智負擔。

在宣告並初始化一個通道的時候,我們需要用到 Go 語言的內建函式make。就像用make初始化切片那樣,我們傳給這個函式的第一個引數應該是代表了通道的具體型別的型別字面量。

在宣告一個通道型別變數的時候,我們首先要確定該通道型別的元素型別,這決定了我們可以通過這個通道傳遞什麼型別的資料。

比如,型別字面量chan int,其中的chan是表示通道型別的關鍵字,而int則說明了該通道型別的元素型別。又比如,chan string代表了一個元素型別為string的通道型別。

在初始化通道的時候,make函式除了必須接收這樣的型別字面量作為引數,還可以接收一個int型別的引數。

後者是可選的,用於表示該通道的容量。所謂通道的容量,就是指通道最多可以快取多少個元素值。由此,雖然這個引數是int型別的,但是它是不能小於0的。

當容量為0時,我們可以稱通道為非緩衝通道,也就是不帶緩衝的通道。而當容量大於0時,我們可以稱為緩衝通道,也就是帶有緩衝的通道。非緩衝通道和緩衝通道有著不同的資料傳遞方式,這個我在後面會講到。

一個通道相當於一個先進先出(FIFO)的佇列。也就是說,通道中的各個元素值都是嚴格地按照傳送的順序排列的,先被髮送通道的元素值一定會先被接收。元素值的傳送和接收都需要用到操作符<-。我們也可以叫它接送操作符。一個左尖括號緊接著一個減號形象地代表了元素值的傳輸方向。

package main

import "fmt"

func main() {
  ch1 := make(chan int, 3)
  ch1 <- 2
  ch1 <- 1
  ch1 <- 3
  elem1 := <-ch1
  fmt.Printf("The first element received from channel ch1: %v\n",
    elem1)
}

在 demo20.go 檔案中,我宣告並初始化了一個元素型別為int、容量為3的通道ch1,並用三條語句,向該通道先後傳送了三個元素值2、1和3。

這裡的語句需要這樣寫:依次敲入通道變數的名稱(比如ch1)、接送操作符<-以及想要傳送的元素值(比如2),並且這三者之間最好用空格進行分割。

這顯然表達了“這個元素值將被髮送該通道”這個語義。由於該通道的容量為 3,所以,我可以在通道不包含任何元素值的時候,連續地向該通道傳送三個值,此時這三個值都會被快取在通道之中。

當我們需要從通道接收元素值的時候,同樣要用接送操作符<-,只不過,這時需要把它寫在變數名的左邊,用於表達“要從該通道接收一個元素值”的語義。

比如:<-ch1,這也可以被叫做接收表示式。在一般情況下,接收表示式的結果將會是通道中的一個元素值。

如果我們需要把如此得來的元素值存起來,那麼在接收表示式的左邊就需要依次新增賦值符號(=或:=)和用於存值的變數的名字。因此,語句elem1 := <-ch1會將最先進入ch1的元素2接收來並存入變數elem1。

現在我們來看一道與此有關的題目。今天的問題是:對通道的傳送和接收操作都有哪些基本的特性?

這個問題的背後隱藏著很多的知識點,我們來看一下典型回答。

它們的基本特性如下。

  • 對於同一個通道,傳送操作之間是互斥的,接收操作之間也是互斥的。
  • 傳送操作和接收操作中對元素值的處理都是不可分割的。
  • 傳送操作在完全完成之前會被阻塞。接收操作也是如此。

問題解析

我們先來看第一個基本特性。在同一時刻,Go 語言的執行時系統(以下簡稱執行時系統)只會執行對同一個通道的任意個傳送操作中的某一個。

直到這個元素值被完全複製進該通道之後,其他針對該通道的傳送操作才可能被執行。

類似的,在同一時刻,執行時系統也只會執行,對同一個通道的任意個接收操作中的某一個。

直到這個元素值完全被移出該通道之後,其他針對該通道的接收操作才可能被執行。即使這些操作是併發執行的也是如此。

這裡所謂的併發執行,你可以這樣認為,多個程式碼塊分別在不同的 goroutine 之中,並有機會在同一個時間段內被執行。

另外,對於通道中的同一個元素值來說,傳送操作和接收操作之間也是互斥的。例如,雖然會出現,正在被複制進通道但還未複製完成的元素值,但是這時它絕不會被想接收它的一方看到和取走。

這裡要注意的一個細節是,元素值從外界進入通道時會被複制。更具體地說,進入通道的並不是在接收操作符右邊的那個元素值,而是它的副本。

另一方面,元素值從通道進入外界時會被移動。這個移動操作實際上包含了兩步,第一步是生成正在通道中的這個元素值的副本,並準備給到接收方,第二步是刪除在通道中的這個元素值。

順著這個細節再來看第二個基本特性。這裡的“不可分割”的意思是,它們處理元素值時都是一氣呵成的,絕不會被打斷。

例如,傳送操作要麼還沒複製元素值,要麼已經複製完畢,絕不會出現只複製了一部分的情況。

又例如,接收操作在準備好元素值的副本之後,一定會刪除掉通道中的原值,絕不會出現通道中仍有殘留的情況。

這既是為了保證通道中元素值的完整性,也是為了保證通道操作的唯一性。對於通道中的同一個元素值來說,它只可能是某一個傳送操作放入的,同時也只可能被某一個接收操作取出。

再來說第三個基本特性。一般情況下,傳送操作包括了“複製元素值”和“放置副本到通道內部”這兩個步驟。

在這兩個步驟完全完成之前,發起這個傳送操作的那句程式碼會一直阻塞在那裡。也就是說,在它之後的程式碼不會有執行的機會,直到這句程式碼的阻塞解除。

更細緻地說,在通道完成傳送操作之後,執行時系統會通知這句程式碼所在的 goroutine,以使它去爭取繼續執行程式碼的機會。

另外,接收操作通常包含了“複製通道內的元素值”“放置副本到接收方”“刪掉原值”三個步驟。

在所有這些步驟完全完成之前,發起該操作的程式碼也會一直阻塞,直到該程式碼所在的 goroutine 收到了執行時系統的通知並重新獲得執行機會為止。

說到這裡,你可能已經感覺到,如此阻塞程式碼其實就是為了實現操作的互斥和元素值的完整。

下面我來說一個關於通道操作阻塞的問題。

知識擴充套件

問題 1:傳送操作和接收操作在什麼時候可能被長時間的阻塞?

先說針對緩衝通道的情況。如果通道已滿,那麼對它的所有傳送操作都會被阻塞,直到通道中有元素值被接收走。

這時,通道會優先通知最早因此而等待的、那個傳送操作所在的 goroutine,後者會再次執行傳送操作。

由於傳送操作在這種情況下被阻塞後,它們所在的 goroutine 會順序地進入通道內部的傳送等待佇列,所以通知的順序總是公平的。

相對的,如果通道已空,那麼對它的所有接收操作都會被阻塞,直到通道中有新的元素值出現。這時,通道會通知最早等待的那個接收操作所在的 goroutine,並使它再次執行接收操作。

因此而等待的、所有接收操作所在的 goroutine,都會按照先後順序被放入通道內部的接收等待佇列。

對於非緩衝通道,情況要簡單一些。無論是傳送操作還是接收操作,一開始執行就會被阻塞,直到配對的操作也開始執行,才會繼續傳遞。由此可見,非緩衝通道是在用同步的方式傳遞資料。也就是說,只有收發雙方對接上了,資料才會被傳遞。

並且,資料是直接從傳送方複製到接收方的,中間並不會用非緩衝通道做中轉。相比之下,緩衝通道則在用非同步的方式傳遞資料。

在大多數情況下,緩衝通道會作為收發雙方的中介軟體。正如前文所述,元素值會先從傳送方複製到緩衝通道,之後再由緩衝通道複製給接收方。

但是,當傳送操作在執行的時候發現空的通道中,正好有等待的接收操作,那麼它會直接把元素值複製給接收方。

以上說的都是在正確使用通道的前提下會發生的事情。下面我特別說明一下,由於錯誤使用通道而造成的阻塞。

對於值為nil的通道,不論它的具體型別是什麼,對它的傳送操作和接收操作都會永久地處於阻塞狀態。它們所屬的 goroutine 中的任何程式碼,都不再會被執行。

注意,由於通道型別是引用型別,所以它的零值就是nil。換句話說,當我們只宣告該型別的變數但沒有用make函式對它進行初始化時,該變數的值就會是nil。我們一定不要忘記初始化通道!

你可以去看一下 demo21.go,我在裡面用程式碼羅列了一下會造成阻塞的幾種情況。

package main

func main() {
	// 示例1。
	ch1 := make(chan int, 1)
	ch1 <- 1
	//ch1 <- 2 // 通道已滿,因此這裡會造成阻塞。

	// 示例2。
	ch2 := make(chan int, 1)
	//elem, ok := <-ch2 // 通道已空,因此這裡會造成阻塞。
	//_, _ = elem, ok
	ch2 <- 1

	// 示例3。
	var ch3 chan int
	//ch3 <- 1 // 通道的值為nil,因此這裡會造成永久的阻塞!
	//<-ch3 // 通道的值為nil,因此這裡會造成永久的阻塞!
	_ = ch3
}

問題 2:傳送操作和接收操作在什麼時候會引發 panic?

對於一個已初始化,但並未關閉的通道來說,收發操作一定不會引發 panic。

但是通道一旦關閉,再對它進行傳送操作,就會引發 panic。另外,如果我們試圖關閉一個已經關閉了的通道,也會引發 panic。注意,接收操作是可以感知到通道的關閉的,並能夠安全退出。

更具體地說,當我們把接收表示式的結果同時賦給兩個變數時,第二個變數的型別就是一定bool型別。它的值如果為false就說明通道已經關閉,並且再沒有元素值可取了。

注意,如果通道關閉時,裡面還有元素值未被取出,那麼接收表示式的第一個結果,仍會是通道中的某一個元素值,而第二個結果值一定會是true。

因此,通過接收表示式的第二個結果值,來判斷通道是否關閉是可能有延時的。

由於通道的收發操作有上述特性,所以除非有特殊的保障措施,我們千萬不要讓接收方關閉通道,而應當讓傳送方做這件事。這在 demo22.go 中有一個簡單的模式可供參考。

package main

import "fmt"

func main() {
	ch1 := make(chan int, 2)
	// 傳送方。
	go func() {
		for i := 0; i < 10; i++ {
			fmt.Printf("Sender: sending element %v...\n", i)
			ch1 <- i
		}
		fmt.Println("Sender: close the channel...")
		close(ch1)
	}()

	// 接收方。
	for {
		elem, ok := <-ch1
		if !ok {
			fmt.Println("Receiver: closed channel")
			break
		}
		fmt.Printf("Receiver: received an element: %v\n", elem)
	}

	fmt.Println("End.")
}

總結

今天我們講到了通道的一些常規操作,包括初始化、傳送、接收和關閉。通道型別是 Go 語言特有的,所以你一開始肯定會感到陌生,其中的一些規則和奧妙還需要你銘記於心,並細心體會。

首先是在初始化通道時設定其容量的意義,這有時會讓通道擁有不同的行為模式。對通道的傳送操作和接收操作都有哪些基本特性,也是我們必須清楚的。

這涉及了它們什麼時候會互斥,什麼時候會造成阻塞,什麼時候會引起 panic,以及它們收發元素值的順序是怎樣的,它們是怎樣保證元素值的完整性的,元素值通常會被複制幾次,等等。

最後別忘了,通道也是 Go 語言的併發程式設計模式中重要的一員。

思考題

我希望你能通過試驗獲得下述問題的答案。

  • 通道的長度代表著什麼?它在什麼時候會通道的容量相同?
  • 元素值在經過通道傳遞時會被複制,那麼這個複製是淺表複製還是深層複製呢?

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章