golang channel底層結構和實現

2022-10-04 18:00:44

一、介紹

Golang 設計模式: 不要通過共用記憶體來通訊,而要通過通訊實現記憶體共用

channel是基於通訊順序模型(communication sequential processes, CSP)的並行模式,可以讓一個 goroutine 傳送特定值到另一個 goroutine 的通訊機制

channel中的資料遵循先入先出(First In First Out)的規則,保證收發資料的順序

 

二、結構

channel的原始碼在runtime包下的chan.go檔案, 參見chan.go

 以下時channel的部分結構:

type hchan struct {
	qcount   uint
	dataqsiz uint
	buf      unsafe.Pointer
	elemsize uint16
	closed   uint32
	elemtype *_type
	sendx    uint
	recvx    uint
	recvq    waitq
	sendq    waitq
	lock mutex
}


type waitq struct {
	first *sudog
	last  *sudog
}

 

其中:

qcount: 佇列中剩餘的元素個數

dataqsiz: 環形佇列長度,即可以存放的元素個數, make初始化時指定

buf: 快取區,實際上就是環形佇列(有環形佇列就有緩衝區,否則沒有緩衝區),指向環形佇列首部的指標,基於環形佇列實現,大小等於make初始化channel時指定的環形佇列長度,如果make初始化channel時不指定dataqsiz,則buf=0。只有緩衝型的channel才有buf

elemsize: 每個元素的大小

closed: channel關閉標誌

elemtype: 元素型別

sendx: 寫入資料的索引,即從哪個位置開始寫入資料,取值[0, dataqsiz)

recvx: 讀取資料的索引,即從哪個位置開始讀取資料,取值[0, dataqsiz)

recvq: 接收等待佇列,連結串列結構,長度無限長, 讀取資料的goroutine等待佇列, 如果channel的緩衝區為空或者沒有緩衝區,讀取資料的goroutine被阻塞,加入到recvq等待佇列中。因讀阻塞的goroutine會被向channel寫入資料的goroutine喚醒

sendq: 傳送等待佇列,連結串列結構,長度無限長, 寫入資料的goroutine等待佇列, 如果channel的緩衝區為滿或者沒有緩衝區,寫入資料的goroutine被阻塞,加入到sendq等待佇列中。因寫阻塞的goroutine會被從channel讀取資料的goroutine喚醒

lock: 並行控制鎖, 同一時刻,只允許一個, channel不允許並行讀寫

 

1 結構圖

 

 

其中:

環形佇列中的0表示沒有資料,1表示有資料; G表示一個goroutine
dataqsiz表示環形佇列的長度為6, 即可快取6個元素
buf指向環形佇列首部,此時還可以快取2個元素
qcount表示環形佇列中有4個元素
sendx表示下一個傳送的資料在環形佇列index=5的位置寫入,取值[0, 6)
recvx表示從環形佇列index=1的位置讀取資料,取值[0, 6)
sendq, recvq: 虛線表示,此時轉態下的channel可能有等待佇列

 

三、channel的建立

1 宣告channel型別

//同時讀寫的channel
var 變數 chan 型別

//只能寫入資料的channel
var 變數 chan<- 型別

//只能讀取資料的channel
var 變數 <-chan 型別 

其中:

型別:channel內的資料型別,golang支援的合法型別

宣告的channel此時還是nil,需要配合make函數初始化之後才能使用

 

2 建立channel

//無緩衝的channel
變數 := make(chan 資料型別)

//有緩衝的channel
變數 := make(chan 資料型別, dataqsiz)

  

四、向channel傳送資料

1 傳送資料的格式

變數 <- 值

 

2 寫資料的過程

1) 流程圖如下:

 

其中:

G表示一個goroutine
虛線表示sendq中堵塞的G被喚醒的流程,如果G沒有被喚醒,則一直堵塞下去,此時關閉channel,會觸發panic

 

2) 過程描述:

1) 如果channel是nil(沒有初始化), 傳送資料則一直會堵塞,這是一個BUG
2) 如果等待接收佇列recvq 不為空,說明沒有緩衝區或者緩衝區沒有資料,直接從recvq取出一個G資料寫入,把G喚醒,結束傳送過程
3) 如果等待接收佇列recvq為空,且緩衝區有空位,那麼就直接將資料寫入緩衝區sendx位置, sendx++, qcount++, 結束傳送過程
4) 如果等待接收佇列recvq為空,緩衝區沒有空位,將資料寫入G,然後把G放到等待傳送佇列sendq中進行阻塞,等待被喚醒, 結束傳送過程。當被喚醒的時候,需要寫入的資料已經被讀取出來,且已經完成了寫入操作

 

五、從channel接收資料

1 接收資料的格式

1) 阻塞接收資料

程式阻塞直到收到資料並賦值

data := <-ch  

 

2) 非阻塞接收資料

非阻塞的通道接收方法可能造成高的 CPU 佔用

//ok表示是否接收到資料
data, ok := <-ch

  

3) 接收資料並忽略

程式阻塞直到接收到資料,但接收到的資料會被忽略

<-ch

  

4) 迴圈接收

channel是可以進行遍歷的,遍歷的結果就是接收到的資料

for data := range ch {
    //done
}

 

5) SELECT語句接收

select 的特點是隻要其中有一個 case 已經完成,程式就會繼續往下執行,而不會考慮其他 case 的情況
在一個 select 語句中,Go語言會按順序從頭至尾評估每一個傳送和接收的語
如果其中的多條case語句可繼續執行(即沒有被阻塞),那麼就從這些case語句中任意選擇一條
如果沒有case語句可以執行(即所有的通道都被阻塞):
  1) 如果有 default 語句,執行 default 語句,同時程式的執行會從 select 語句後的語句中恢復
  2) 如果沒有 default 語句,那麼 select 語句將被阻塞,直到至少有一個case可以進行下去

 

select {
    case <- chan1:
       //done
    case chan2 <- 2:
       //done
    default:
       //done
}

  

2 讀取資料的流程

1) 流程圖如下:

其中:

G表示一個goroutine
虛線表示recvq中堵塞的G被喚醒的流程,如果G沒有被喚醒,則一直堵塞下去,此時關閉channel,會得到channel型別的零值

 

2) 過程描述:

1 如果等待傳送佇列sendq不為空,且沒有緩衝區,直接從sendq中取出G,讀取資料,最後把G喚醒,結束讀取過程
2 如果等待傳送佇列sendq不為空,有緩衝區(此時緩衝區滿了),從緩衝區中首部讀出資料,把sendq出列的G中資料寫入緩衝區尾部,把G喚醒,結束讀取過程
3 如果等待傳送佇列sendq為空,且環形佇列無元素,將goruntime加入等待接收佇列recvq中進行堵塞,等待被喚醒
4 如果等待傳送佇列sendq為空,環形佇列有元素,直接從緩衝區讀取資料,結束讀取過程

六、關閉channel

1 格式

close(ch)

 

2 過程描述

1) 首先校驗chan是否已被初始化,然後加鎖之後再校驗是否已被關閉過,如果校驗都通過了,那麼將closed欄位設值為1
2) 遍歷recvq和sendq,並將所有的goroutine 加入到glist中
3) 將所有glist中的goroutine加入排程佇列,等待被喚醒
4) recvq中的goroutine接收到對應資料的零值,sendq中的goroutine會直接panic

 

七、channel傳送、接收資料過程可能產生的問題

1 向一個nil的channel傳送/讀取資料會一直堵塞下去?該如何喚醒?

會一直堵塞下去,不會被喚醒,可能會造成洩露,這是一個BUG

 

2 等待傳送佇列(sendq)中有資料,如果一直沒有gouruntine從channel裡面讀資料會不會造成洩漏?

會造成洩露,channel用完了,最好要close

 

3 向已經關閉的channel讀/寫資料會發生什麼?

寫已經關閉的 channel 會觸發panic

讀已經關閉的 channel,能一直讀到資料:

  1) 如果 channel 關閉前,buf內有元素還未讀,會正確讀到 channel 內的值,且返回的第二個 bool 值為 true

  2) 如果 channel 關閉前,buf內有元素已經被讀完,channel 內無值,返回 channel 元素的零值,第二個 bool 值為 false

 

4 觸發 panic 的三種情況

1) 向一個關閉的 channel 進行寫操作

2) 關閉一個為 nil 的 channel

3) 重複關閉一個 channel