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

MingsonZheng發表於2021-10-22

11 | 通道的高階玩法

我們已經討論過了通道的基本操作以及背後的規則。今天,我再來講講通道的高階玩法。

首先來說說單向通道。我們在說“通道”的時候指的都是雙向通道,即:既可以發也可以收的通道。

所謂單向通道就是,只能發不能收,或者只能收不能發的通道。一個通道是雙向的,還是單向的是由它的型別字面量體現的。

還記得我們在上篇文章中說過的接收操作符<-嗎?如果我們把它用在通道的型別字面量中,那麼它代表的就不是“傳送”或“接收”的動作了,而是表示通道的方向。

比如:

var uselessChan = make(chan<- int, 1)

我宣告並初始化了一個名叫uselessChan的變數。這個變數的型別是chan<- int,容量是1。

請注意緊挨在關鍵字chan右邊的那個<-,這表示了這個通道是單向的,並且只能發而不能收。

類似的,如果這個操作符緊挨在chan的左邊,那麼就說明該通道只能收不能發。所以,前者可以被簡稱為傳送通道,後者可以被簡稱為接收通道。

注意,與傳送操作和接收操作對應,這裡的“發”和“收”都是站在操作通道的程式碼的角度上說的。

從上述變數的名字上你也能猜到,這樣的通道是沒用的。通道就是為了傳遞資料而存在的,宣告一個只有一端(傳送端或者接收端)能用的通道沒有任何意義。那麼,單向通道的用途究竟在哪兒呢?

問題:單向通道有什麼應用價值?

你可以先自己想想,然後再接著往下看。

典型回答

概括地說,單向通道最主要的用途就是約束其他程式碼的行為。

問題解析

這需要從兩個方面講,都跟函式的宣告有些關係。先來看下面的程式碼:

func SendInt(ch chan<- int) {
  ch <- rand.Intn(1000)
}

我用func關鍵字宣告瞭一個叫做SendInt的函式。這個函式只接受一個chan<- int型別的引數。在這個函式中的程式碼只能向引數ch傳送元素值,而不能從它那裡接收元素值。這就起到了約束函式行為的作用。

你可能會問,我自己寫的函式自己肯定能確定操作通道的方式,為什麼還要再約束?好吧,這個例子可能過於簡單了。在實際場景中,這種約束一般會出現在介面型別宣告中的某個方法定義上。請看這個叫Notifier的介面型別宣告:

type Notifier interface {
  SendInt(ch chan<- int)
}

在介面型別宣告的花括號中,每一行都代表著一個方法的定義。介面中的方法定義與函式宣告很類似,但是隻包含了方法名稱、引數列表和結果列表。

一個型別如果想成為一個介面型別的實現型別,那麼就必須實現這個介面中定義的所有方法。因此,如果我們在某個方法的定義中使用了單向通道型別,那麼就相當於在對它的所有實現做出約束。

在這裡,Notifier介面中的SendInt方法只會接受一個傳送通道作為引數,所以,在該介面的所有實現型別中的SendInt方法都會受到限制。這種約束方式還是很有用的,尤其是在我們編寫模板程式碼或者可擴充套件的程式庫的時候。

順便說一下,我們在呼叫SendInt函式的時候,只需要把一個元素型別匹配的雙向通道傳給它就行了,沒必要用傳送通道,因為 Go 語言在這種情況下會自動地把雙向通道轉換為函式所需的單向通道。

intChan1 := make(chan int, 3)
SendInt(intChan1)

在另一個方面,我們還可以在函式宣告的結果列表中使用單向通道。如下所示:

func getIntChan() <-chan int {
  num := 5
  ch := make(chan int, num)
  for i := 0; i < num; i++ {
    ch <- i
  }
  close(ch)
  return ch
}

函式getIntChan會返回一個<-chan int型別的通道,這就意味著得到該通道的程式,只能從通道中接收元素值。這實際上就是對函式呼叫方的一種約束了。

另外,我們在 Go 語言中還可以宣告函式型別,如果我們在函式型別中使用了單向通道,那麼就相等於在約束所有實現了這個函式型別的函式。

我們再順便看一下呼叫getIntChan的程式碼:

intChan2 := getIntChan()
for elem := range intChan2 {
  fmt.Printf("The element in intChan2: %v\n", elem)
}

我把呼叫getIntChan得到的結果值賦給了變數intChan2,然後用for語句迴圈地取出了該通道中的所有元素值,並列印出來。

這裡的for語句也可以被稱為帶有range子句的for語句。它的用法我在後面講for語句的時候專門說明。現在你只需要知道關於它的三件事:

  • 上述for語句會不斷地嘗試從通道intChan2中取出元素值。即使intChan2已經被關閉了,它也會在取出所有剩餘的元素值之後再結束執行。
  • 通常,當通道intChan2中沒有元素值時,這條for語句會被阻塞在有for關鍵字的那一行,直到有新的元素值可取。不過,由於這裡的getIntChan函式會事先將intChan2關閉,所以它在取出intChan2中的所有元素值之後會直接結束執行。
  • 倘若通道intChan2的值為nil,那麼這條for語句就會被永遠地阻塞在有for關鍵字的那一行。

這就是帶range子句的for語句與通道的聯用方式。不過,它是一種用途比較廣泛的語句,還可以被用來從其他一些型別的值中獲取元素。除此之外,Go 語言還有一種專門為了操作通道而存在的語句:select語句。

知識擴充套件

問題 1:select語句與通道怎樣聯用,應該注意些什麼?

select語句只能與通道聯用,它一般由若干個分支組成。每次執行這種語句的時候,一般只有一個分支中的程式碼會被執行。

select語句的分支分為兩種,一種叫做候選分支,另一種叫做預設分支。候選分支總是以關鍵字case開頭,後跟一個case表示式和一個冒號,然後我們可以從下一行開始寫入當分支被選中時需要執行的語句。

預設分支其實就是 default case,因為,當且僅當沒有候選分支被選中時它才會被執行,所以它以關鍵字default開頭並直接後跟一個冒號。同樣的,我們可以在default:的下一行寫入要執行的語句。

由於select語句是專為通道而設計的,所以每個case表示式中都只能包含操作通道的表示式,比如接收表示式。

當然,如果我們需要把接收表示式的結果賦給變數的話,還可以把這裡寫成賦值語句或者短變數宣告。下面展示一個簡單的例子。

// 準備好幾個通道。
intChannels := [3]chan int{
  make(chan int, 1),
  make(chan int, 1),
  make(chan int, 1),
}
// 隨機選擇一個通道,並向它傳送元素值。
index := rand.Intn(3)
fmt.Printf("The index: %d\n", index)
intChannels[index] <- index
// 哪一個通道中有可取的元素值,哪個對應的分支就會被執行。
select {
case <-intChannels[0]:
  fmt.Println("The first candidate case is selected.")
case <-intChannels[1]:
  fmt.Println("The second candidate case is selected.")
case elem := <-intChannels[2]:
  fmt.Printf("The third candidate case is selected, the element is %d.\n", elem)
default:
  fmt.Println("No candidate case is selected!")
}

我先準備好了三個型別為chan int、容量為1的通道,並把它們存入了一個叫做intChannels的陣列。

然後,我隨機選擇一個範圍在[0, 2]的整數,把它作為索引在上述陣列中選擇一個通道,並向其中傳送一個元素值。

最後,我用一個包含了三個候選分支的select語句,分別嘗試從上述三個通道中接收元素值,哪一個通道中有值,哪一個對應的候選分支就會被執行。後面還有一個預設分支,不過在這裡它是不可能被選中的。

在使用select語句的時候,我們首先需要注意下面幾個事情。

  • 如果像上述示例那樣加入了預設分支,那麼無論涉及通道操作的表示式是否有阻塞,select語句都不會被阻塞。如果那幾個表示式都阻塞了,或者說都沒有滿足求值的條件,那麼預設分支就會被選中並執行。
  • 如果沒有加入預設分支,那麼一旦所有的case表示式都沒有滿足求值條件,那麼select語句就會被阻塞。直到至少有一個case表示式滿足條件為止。
  • 還記得嗎?我們可能會因為通道關閉了,而直接從通道接收到一個其元素型別的零值。所以,在很多時候,我們需要通過接收表示式的第二個結果值來判斷通道是否已經關閉。一旦發現某個通道關閉了,我們就應該及時地遮蔽掉對應的分支或者採取其他措施。這對於程式邏輯和程式效能都是有好處的。
  • select語句只能對其中的每一個case表示式各求值一次。所以,如果我們想連續或定時地操作其中的通道的話,就往往需要通過在for語句中嵌入select語句的方式實現。但這時要注意,簡單地在select語句的分支中使用break語句,只能結束當前的select語句的執行,而並不會對外層的for語句產生作用。這種錯誤的用法可能會讓這個for語句無休止地執行下去。

下面是一個簡單的示例。

intChan := make(chan int, 1)
// 一秒後關閉通道。
time.AfterFunc(time.Second, func() {
  close(intChan)
})
select {
case _, ok := <-intChan:
  if !ok {
    fmt.Println("The candidate case is closed.")
    break
  }
  fmt.Println("The candidate case is selected.")
}

我先宣告並初始化了一個叫做intChan的通道,然後通過time包中的AfterFunc函式約定在一秒鐘之後關閉該通道。

後面的select語句只有一個候選分支,我在其中利用接收表示式的第二個結果值對intChan通道是否已關閉做了判斷,並在得到肯定結果後,通過break語句立即結束當前select語句的執行。

這個例子以及前面那個例子都可以在 demo24.go 檔案中被找到。你應該執行下,看看結果如何。

上面這些注意事項中的一部分涉及到了select語句的分支選擇規則。我覺得很有必要再專門整理和總結一下這些規則。

問題 2:select語句的分支選擇規則都有哪些?

規則如下面所示。

1、對於每一個case表示式,都至少會包含一個代表傳送操作的傳送表示式或者一個代表接收操作的接收表示式,同時也可能會包含其他的表示式。比如,如果case表示式是包含了接收表示式的短變數宣告時,那麼在賦值符號左邊的就可以是一個或兩個表示式,不過此處的表示式的結果必須是可以被賦值的。當這樣的case表示式被求值時,它包含的多個表示式總會以從左到右的順序被求值。

2、select語句包含的候選分支中的case表示式都會在該語句執行開始時先被求值,並且求值的順序是依從程式碼編寫的順序從上到下的。結合上一條規則,在select語句開始執行時,排在最上邊的候選分支中最左邊的表示式會最先被求值,然後是它右邊的表示式。僅當最上邊的候選分支中的所有表示式都被求值完畢後,從上邊數第二個候選分支中的表示式才會被求值,順序同樣是從左到右,然後是第三個候選分支、第四個候選分支,以此類推。

3、對於每一個case表示式,如果其中的傳送表示式或者接收表示式在被求值時,相應的操作正處於阻塞狀態,那麼對該case表示式的求值就是不成功的。在這種情況下,我們可以說,這個case表示式所在的候選分支是不滿足選擇條件的。

4、僅當select語句中的所有case表示式都被求值完畢後,它才會開始選擇候選分支。這時候,它只會挑選滿足選擇條件的候選分支執行。如果所有的候選分支都不滿足選擇條件,那麼預設分支就會被執行。如果這時沒有預設分支,那麼select語句就會立即進入阻塞狀態,直到至少有一個候選分支滿足選擇條件為止。一旦有一個候選分支滿足選擇條件,select語句(或者說它所在的 goroutine)就會被喚醒,這個候選分支就會被執行。

5、如果select語句發現同時有多個候選分支滿足選擇條件,那麼它就會用一種偽隨機的演算法在這些分支中選擇一個並執行。注意,即使select語句是在被喚醒時發現的這種情況,也會這樣做。

6、一條select語句中只能夠有一個預設分支。並且,預設分支只在無候選分支可選時才會被執行,這與它的編寫位置無關。

7、select語句的每次執行,包括case表示式求值和分支選擇,都是獨立的。不過,至於它的執行是否是併發安全的,就要看其中的case表示式以及分支中,是否包含併發不安全的程式碼了。

我把與以上規則相關的示例放在 demo25.go 檔案中了。你一定要去試執行一下,然後嘗試用上面的規則去解釋它的輸出內容。

package main

import "fmt"

var channels = [3]chan int{
	nil,
	make(chan int),
	nil,
}

var numbers = []int{1, 2, 3}

func main() {
	select {
	case getChan(0) <- getNumber(0):
		fmt.Println("The first candidate case is selected.")
	case getChan(1) <- getNumber(1):
		fmt.Println("The second candidate case is selected.")
	case getChan(2) <- getNumber(2):
		fmt.Println("The third candidate case is selected")
	default:
		fmt.Println("No candidate case is selected!")
	}
}

func getNumber(i int) int {
	fmt.Printf("numbers[%d]\n", i)
	return numbers[i]
}

func getChan(i int) chan int {
	fmt.Printf("channels[%d]\n", i)
	return channels[i]
}

總結

今天,我們先講了單向通道的表示方法,操作符“<-”仍然是關鍵。如果只用一個詞來概括單向通道存在的意義的話,那就是“約束”,也就是對程式碼的約束。

我們可以使用帶range子句的for語句從通道中獲取資料,也可以通過select語句操縱通道。

select語句是專門為通道而設計的,它可以包含若干個候選分支,每個分支中的case表示式都會包含針對某個通道的傳送或接收操作。

當select語句被執行時,它會根據一套分支選擇規則選中某一個分支並執行其中的程式碼。如果所有的候選分支都沒有被選中,那麼預設分支(如果有的話)就會被執行。注意,傳送和接收操作的阻塞是分支選擇規則的一個很重要的依據。

思考題

今天的思考題都由上述內容中的線索延伸而來。

  • 如果在select語句中發現某個通道已關閉,那麼應該怎樣遮蔽掉它所在的分支?
  • 在select語句與for語句聯用時,怎樣直接退出外層的for語句?

知識共享許可協議

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

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

相關文章