深入解析Go Channel各狀態下的操作結果

2023.06.20

深入解析Go Channel各狀態下的操作結果


golang中的通道就是用來在協程間進行通信的。我們從源碼級別推導了針對通道的各個狀態下的操作所產生的結果。

大家好,我是漁夫子。

channel是golang中獨有的特性,也是面試中經常被問到的。相信大家都看到過下面這張圖,對於不同狀態下通道,在操作時會有什麼結果。

圖片

這張圖總結的非常好。但我們不能死記硬背這些結果。要了解其底層的基本原理,就能理解這些結果是怎麼來的。

我們分三部分來講。先是channel的基礎使用,基礎使用提現了channel有哪些特性。再引出channel的底層數據結構。底層數據結構就是圍繞這些特性而建立的。最後再看go是如何基於底層數據結構來實現這些特性的。

channel的基礎使用

通道的定義和初始化

通過var定義通道

通過var定義一個通道變量ch,這個變量能夠接收整型的數據。當然也可以指定其他任何數據類型。

var ch chan int
  • 1.
  • ch 代表變量名
  • chan固定值。代表ch是通道類型
  • int代表在通道ch中存儲的是整型數據。
  • ch變量的默認值是nil。對於nil通道在操作時會有特殊的場景,一會我們也會講解。

通過make初始化通道

通過make可以初始化無緩衝區通道和緩衝區通道。區別就在於make中是否指定了緩衝區的大小。如下:

var ch = make(chan int) //初始化无缓冲通道

var ch = make(chan int, 10) //缓冲区通道,缓冲区可以存10个元素
  • 1.
  • 2.
  • 3.

無緩衝通道和有緩衝通道的區別可以從屬性上和行為兩方面來體現:

  • 從屬性上區別:通道是否有一段緩衝區來暫存元素。
  • 從行為上區別:發送者和接收者是否同步的還是異步的。
  • 從底層數據結構上區別:是否有一塊緩衝區來暫存數據。這個後面會詳細講解。

通道的操作

golang中對於通道有三種操作:往通道中發送元素、從通道中接收元素、關閉通道。如下:往通道中發送元素:

var ch chan int = make(chan int, 10)

2 ->ch //发送元素

var item int
item <-ch //接收元素

close(ch) //关闭元素
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

總結一下:

  • 通道有三種操作:發送、接收和關閉。
  • 通道有三種類型:nil通道、無緩衝通道和有緩衝通道。
  • 通道有2種狀態:關閉狀態和未關閉狀態。
  • 緩衝通道的未關閉狀態又可以分為緩衝區滿、緩衝區未滿狀態。

那麼,通道是基於怎樣的數據結構來完成這些行為的呢?

channel的數據結構

我們先給出channel的底層數據結構,如下:

type hchan struct {
 qcount   uint           // total data in the queue
 dataqsiz uint           // size of the circular queue
 buf      unsafe.Pointer // points to an array of dataqsiz elements
 elemsize uint16
 closed   uint32
 elemtype *_type // element type
 sendx    uint   // send index
 recvx    uint   // receive index
 recvq    waitq  // list of recv waiters
 sendq    waitq  // list of send waiters

 // lock protects all fields in hchan, as well as several
 // fields in sudogs blocked on this channel.
 //
 // Do not change another G's status while holding this lock
 // (in particular, do not ready a G), as this can deadlock
 // with stack shrinking.
 lock mutex
}

type waitq struct {
 first *sudog
 last  *sudog
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.

根據上面的結構定義,依次解釋下各個字段的含義:

  • buf:指向一個數組,代表的是一個隊列,結合sendx和recvx字段實現了環形隊列。緩存對應的元素。緩衝區通道就是利用這個字段實現的。
  • qcount:在buf隊列中當前有多少個元素。
  • dataqsiz:代表隊列buf的容量。在使用make進行初始化時,指定的元素個數就存在該字段中。
  • elemsize:一個元素的字節大小。根據該元素的大小,可以初始化buf的容量的大小。通過elemsize*容量就能知道該給buf分配多少字節的空間了。
  • closed:代表該通道是否被關閉。其值只有0和1。1代表該通道已經關閉了。0代表未關閉。
  • elemtype:代表元素的類型。
  • sendx:代表的是發送下一個元素應該存儲的位置
  • recvx:代表的是下一個接收元素的位置。
  • recvq:代表的是等待接收元素的協程隊列
  • sendq:代表的是發送元素的協程隊列。

根據以上結果,繪製成圖會容易理解點,如下:

圖片

緩衝通道和非緩衝通道的區別

從定義上,緩衝通道和非緩衝通道都是通過make來初始化的。不同點在於是否在make函數上指定了通道的容量大小。如下:

unbufferCh := make(chan int) //初始化非缓冲区通道

bufferCh := make(chan int, 10) //初始化一个能缓冲10个元素的通道
  • 1.
  • 2.
  • 3.

從通道的底層數據結構上來說,非緩衝渠道不會初始化結構體中的buf字段。而緩衝渠道則會初始化buf字段。該字段指向一塊內存區域。如下圖:

圖片

通道的發送、接收流程

通過源碼我們梳理出來了給通道發送數據和從通道中接收數據的流程圖。這張流程圖將緩衝通道和無緩衝通道兩種狀態下的發送和接收流程都包含了,所以看起來會比較複雜。但是沒關係,下面我們會分解這張圖。

圖片

通過上面的流程,大家需要注意的一點就是,無論是在發送還是接收操作時,都是優先從等待隊列中獲取對應的線程,如果有,則直接接收或發送;如果等待隊列沒有協程,然後再看是否有緩衝區。這一點需要大家額外注意。

圖片

各狀態通道的操作

無緩衝通道

根據上述無緩衝通道其實本質上就是沒有緩衝區。在初始化時不指定make的容量即可。實際上這也叫做同步發送和接收。針對這種狀態的通道,當發送數據時,如果接收隊列中有等待的接收協程,那麼就能發送成功;否則,進入阻塞狀態。反之,亦然。其流程圖就是圖中的紅色箭頭部分,如下:

圖片

再簡化一下就是:

  • 往無緩衝區中發送數據時,如果有等待接收的協程,則發送成功;否則,發送協程進入阻塞狀態。
  • 從無緩衝區接收數據時,如果有等待發送的協程,則接收成功;否則,接收協程進入阻塞狀態。

那麼,上面的圖可以簡化成如下:

圖片

另外需要額外注意一點,對於非緩衝區通道的發送和接收操作。如果是在main函數中進行發送和接收,那麼會造成死鎖。如下:

func main() {
 var ch = make(chan int)
 <-ch
 fmt.Println("the End")
}

//或
func main() {
 var ch = make(chan int)
 ch <- 2
 fmt.Println("the End")
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

圖片

所以,對於非緩衝區通道的發送和接收操作,最主要的問題就是可能會造成阻塞。除非,兩個發送和接收協程都存在,而且要在不同的協程裡。

有緩衝通道

有緩衝區通道就是在通道中有一塊緩衝區,發送和接收都可以針對緩衝區進行操作。也稱為異步發送和接收。在有緩衝通道的狀態下,j對於發送操作來說,有緩衝通道的狀態分為緩衝區滿和未滿兩種狀態。根據上面的發送流程圖來說,當緩衝區滿了,自然就不能再發送了,就會進入等待發送隊列。同時阻塞,等待被接收協程喚醒。

對於接收操作來說,有緩衝通道的狀態分為緩衝區空和未滿兩種狀態。同樣,如果當緩衝區空時,無數據可接收,自然就進入到接收等待隊列。同時進入阻塞,等待被發送協程喚醒。

圖片

已關閉狀態的通道

關閉通道是通過**close**函數進行的。本質上關閉一個通道,就是將通道結構中的closed字段置為1。從源代碼中可以獲知:

  • 關閉nil通道:panic
  • 關閉已經關閉了的通道:panic。這一點可以這樣理解,關閉一個已經關閉的通道是沒有任何意義的。

圖片

發送消息到已關閉的通道

給已經關閉了的通道發送消息會引發panic。這個很好理解,因為通道已經關閉,就是為了不讓發消息了。如下代碼:

圖片

從已關閉的通道接收消息

從已關閉的通道中接收消息時,都能操作成功。但會根據通道中是否有元素有以下不同:

  • 如果通道中已經沒有元素了,則會返回一個false的狀態。
  • 如果通道中有元素,則會繼續接收通道中的元素,直到接收完,並返回false。

圖片

你看,其實代碼也很簡單。我們將代碼拆解一下,就是右側的流程圖。

nil通道

通過以下方式定義的通道類型的變量,其默認值就是nil。

var ch chan int
  • 1.

nil通道相當於沒有分配通道的底層結構

如下是從源代碼中截取的各個操作以及對應操作結果。通過源代碼可獲知:

  • 關閉nil通道會panic
  • 從nil通道接收、發送消都會阻塞

圖片

總結

golang中的通道就是用來在協程間進行通信的。我們從源碼級別推導了針對通道的各個狀態下的操作所產生的結果。最後總結一下:緩衝區通道:

  • 只要有緩衝空間就能發送成功。除非緩衝空間滿了,則產生阻塞。
  • 只要緩衝空間中有元素就能接收成功。除非沒有元素,則產生阻塞。

nil通道:

  • nil通道是沒有初始化底層數據結構的通道。因為沒有空間可存儲任何元素,所以發送和接收都會產生阻塞。關閉nil通道,則會引發panic。

已關閉的通道:

  • 往已關閉的通道中發送消息,會引發panic。
  • 從已關閉通道中接收消息,會成功。
  • 關閉已關閉的通道,也會引發panic。