Go channel實現原理剖析 原 薦

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中寫數據簡單過程如下:

  1. 如果等待接收隊列recvq不爲空,說明緩衝區中沒有數據或者沒有緩衝區,此時直接從recvq取出G,並把數據寫入,最後把該G喚醒,結束髮送過程;
  2. 如果緩衝區中有空餘位置,將數據寫入緩衝區,結束髮送過程;
  3. 將待發送數據寫入G,將當前G加入sendq,進入睡眠,等待被讀goroutine喚醒;

簡單流程圖如下:

3.3 從channel讀數據

從一個channel讀數據簡單過程如下:

  1. 如果等待發送隊列sendq不爲空,且沒有緩衝區,直接從sendq中取出G,把G中數據讀出,最後把G喚醒,結束讀取過程;
  2. 如果等待發送隊列sendq不爲空,此時說明緩衝區已滿,從緩衝區中首部讀出數據,把G中數據寫入緩衝區尾部,把G喚醒,結束讀取過程;
  3. 如果緩衝區中有數據,則從緩衝區取出數據,結束讀取過程;
  4. 將當前goroutine加入recvq,進入睡眠,等待被寫goroutine喚醒;

簡單流程圖如下:

3.4 關閉channel

關閉channel時會把recvq中的G全部喚醒,本該寫入G的數據位置爲nil。把sendq中的G全部喚醒,但這些G會panic。

除此之外,panic出現的常見場景還有:

  1. 關閉值爲nil的channel
  2. 關閉已經被關閉的channel
  3. 向已經關閉的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將會永久阻塞。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章