1. 前言
channel是Golang在語言層面提供的goroutine間的通信方式,比Unix管道更易用也更輕便。channel主要用於進程內各goroutine間通信,如果需要跨進程通信,建議使用分佈式系統的方法來解決。
本章從源碼角度分析channel的實現機制,實際上這部分源碼非常簡單易讀。
2. chan數據結構
src/runtime/chan.go:hchan
定義了channel的數據結構:
type hchan struct {
qcount uint // 當前隊列中剩餘元素個數
dataqsiz uint // 環形隊列長度,即可以存放的元素個數
buf unsafe.Pointer // 環形隊列指針
elemsize uint16 // 每個元素的大小
closed uint32 // 標識關閉狀態
elemtype *_type // 元素類型
sendx uint // 隊列下標,指示元素寫入時存放到隊列中的位置
recvx uint // 隊列下標,指示元素從隊列的該位置讀出
recvq waitq // 等待讀消息的goroutine隊列
sendq waitq // 等待寫消息的goroutine隊列
lock mutex // 互斥鎖,chan不允許併發讀寫
}
從數據結構可以看出channel由隊列、類型信息、goroutine等待隊列組成,下面分別說明其原理。
2.1 環形隊列
chan內部實現了一個環形隊列作爲其緩衝區,隊列的長度是創建chan時指定的。
下圖展示了一個可緩存6個元素的channel示意圖:
- dataqsiz指示了隊列長度爲6,即可緩存6個元素;
- buf指向隊列的內存,隊列中還剩餘兩個元素;
- qcount表示隊列中還有兩個元素;
- sendx指示後續寫入的數據存儲的位置,取值[0, 6);
- recvx指示從該位置讀取數據, 取值[0, 6);
2.2 等待隊列
從channel讀數據,如果channel緩衝區爲空或者沒有緩衝區,當前goroutine會被阻塞。
向channel寫數據,如果channel緩衝區已滿或者沒有緩衝區,當前goroutine會被阻塞。
被阻塞的goroutine將會掛在channel的等待隊列中:
- 因讀阻塞的goroutine會被向channel寫入數據的goroutine喚醒;
- 因寫阻塞的goroutine會被從channel讀數據的goroutine喚醒;
下圖展示了一個沒有緩衝區的channel,有幾個goroutine阻塞等待讀數據:
注意,一般情況下recvq和sendq至少有一個爲空。只有一個例外,那就是同一個goroutine使用select語句向channel一邊寫數據,一邊讀數據。
2.3 類型信息
一個channel只能傳遞一種類型的值,類型信息存儲在hchan數據結構中。
- elemtype代表類型,用於數據傳遞過程中的賦值;
- elemsize代表類型大小,用於在buf中定位元素位置。
2.4 鎖
一個channel同時僅允許被一個goroutine讀寫,爲簡單起見,本章後續部分說明讀寫過程時不再涉及加鎖和解鎖。
3. channel讀寫
3.1 創建channel
創建channel的過程實際上是初始化hchan結構。其中類型信息和緩衝區長度由make語句傳入,buf的大小則與元素大小和緩衝區長度共同決定。
創建channel的僞代碼如下所示:
func makechan(t *chantype, size int) *hchan {
var c *hchan
c = new(hchan)
c.buf = malloc(元素類型大小*size)
c.elemsize = 元素類型大小
c.elemtype = 元素類型
c.dataqsiz = size
return c
}
3.2 向channel寫數據
向一個channel中寫數據簡單過程如下:
- 如果等待接收隊列recvq不爲空,說明緩衝區中沒有數據或者沒有緩衝區,此時直接從recvq取出G,並把數據寫入,最後把該G喚醒,結束髮送過程;
- 如果緩衝區中有空餘位置,將數據寫入緩衝區,結束髮送過程;
- 將待發送數據寫入G,將當前G加入sendq,進入睡眠,等待被讀goroutine喚醒;
簡單流程圖如下:
3.3 從channel讀數據
從一個channel讀數據簡單過程如下:
- 如果等待發送隊列sendq不爲空,且沒有緩衝區,直接從sendq中取出G,把G中數據讀出,最後把G喚醒,結束讀取過程;
- 如果等待發送隊列sendq不爲空,此時說明緩衝區已滿,從緩衝區中首部讀出數據,把G中數據寫入緩衝區尾部,把G喚醒,結束讀取過程;
- 如果緩衝區中有數據,則從緩衝區取出數據,結束讀取過程;
- 將當前goroutine加入recvq,進入睡眠,等待被寫goroutine喚醒;
簡單流程圖如下:
3.4 關閉channel
關閉channel時會把recvq中的G全部喚醒,本該寫入G的數據位置爲nil。把sendq中的G全部喚醒,但這些G會panic。
除此之外,panic出現的常見場景還有:
- 關閉值爲nil的channel
- 關閉已經被關閉的channel
- 向已經關閉的channel寫數據
4. 常見用法
4.1 單向channel
顧名思義,單向channel指只能用於發送或接收數據,這種channel是沒有實際用途的,實際上也沒有單向channel。
我們知道channel可以通過參數傳遞,所謂單向channel只是對channel的一種使用限制,這跟C語言使用const修飾函數參數爲只讀是一個道理。
- func readChan(chanName <-chan int): 通過形參限定函數內部只能從channel中讀取數據
- func writeChan(chanName chan<- int): 通過形參限定函數內部只能向channel中寫入數據
一個簡單的示例程序如下:
func readChan(chanName <-chan int) {
<- chanName
}
func writeChan(chanName chan<- int) {
chanName <- 1
}
func main() {
var mychan = make(chan int, 10)
writeChan(mychan)
readChan(mychan)
}
mychan是個正常的channel,而readChan()參數限制了傳入的channel只能用來讀,writeChan()參數限制了傳入的channel只能用來寫。
4.2 select
使用select可以監控多channel,比如監控多個channel,當其中某一個channel有數據時,就從其讀出數據。
一個簡單的示例程序如下:
package main
import (
"fmt"
"time"
)
func addNumberToChan(chanName chan int) {
for {
chanName <- 1
time.Sleep(1 * time.Second)
}
}
func main() {
var chan1 = make(chan int, 10)
var chan2 = make(chan int, 10)
go addNumberToChan(chan1)
go addNumberToChan(chan2)
for {
select {
case e := <- chan1 :
fmt.Printf("Get element from chan1: %d\n", e)
case e := <- chan2 :
fmt.Printf("Get element from chan2: %d\n", e)
default:
fmt.Printf("No element in chan1 and chan2.\n")
time.Sleep(1 * time.Second)
}
}
}
程序中創建兩個channel: chan1和chan2。函數addNumberToChan()函數會向兩個channel中週期性寫入數據。通過select可以監控兩個channel,任意一個可讀時就從其中讀出數據。
程序輸出如下:
D:\SourceCode\GoExpert\src>go run main.go
Get element from chan1: 1
Get element from chan2: 1
No element in chan1 and chan2.
Get element from chan2: 1
Get element from chan1: 1
No element in chan1 and chan2.
Get element from chan2: 1
Get element from chan1: 1
No element in chan1 and chan2.
從輸出可見,從channel中讀出數據的順序是隨機的,事實上select語句的多個case執行順序是隨機的,關於select的實現原理會有專門章節分析。
通過這個示例想說的是:select的case語句讀channel不會阻塞,儘管channel中沒有數據。這是由於case語句編譯後調用讀channel時會明確傳入不阻塞的參數,此時讀不到數時不會將當前goroutine加入到等待隊列,而是真接返回。
4.3 range
通過range可以持續從channel中讀出數據,好像在遍歷一個數組一樣,當channel中沒有數據時會阻塞當前goroutine,與讀channel時阻塞處理機制一樣。
func chanRange(chanName chan int) {
for e := range chanName {
fmt.Printf("Get element from chan: %d\n", e)
}
}
注意:如果向此channel寫數據的goroutine退出時,系統檢測到這種情況後會panic,否則range將會永久阻塞。