【Go進階—資料結構】Channel

與昊發表於2021-09-26

channel 是 Golang 提供的 goroutine 間的通訊方式,可以讓一個 goroutine 傳送特定值到另一個 goroutine。

特性

通道沒有緩衝區,或者有緩衝區但緩衝區沒有資料時,從通道讀取資料會阻塞,直到有協程向通道中寫入資料。類似地,通道沒有緩衝區,或者緩衝區已滿時,向通道寫入資料也會阻塞,直到有協程從通道讀取資料。對於值為 nil 的通道,無論讀寫都會阻塞,而且是永久阻塞。

使用內建函式 close 可以關閉通道,嘗試向已關閉的通道傳送資料會觸發 panic,但此時仍然可讀。通道讀取的表示式最多有兩個返回值:

x, ok := <-ch

第一個變數表示讀出的資料,第二個變數表示是否成功讀取了資料,它的值只跟通道緩衝區中是否有資料有關,與通道的關閉狀態無關。

實現原理

資料結構

原始碼 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 等待佇列組成。

環形佇列

channel 內部實現了一個環形佇列作為其緩衝區,佇列的長度是建立 channel 時指定的。下圖展示了一個可快取 6 個元素的 channel 示意圖:

image.png

  • dataqsiz 表明了佇列長度為6,即可快取6個元素;
  • buf 指向佇列的記憶體地址;
  • qcount 表示佇列中還有兩個元素;
  • sendx 表示後續寫入的資料儲存的位置,取值為 [0, 6);
  • recvx 表示讀取資料的位置, 取值為[0, 6)。
型別資訊

一個 channel 只能傳遞一種型別的值:

  • elemtype 代表型別,用於資料傳遞過程中的賦值;
  • elemsize 代表型別大小,用於在buf中定位元素位置。
等待佇列

從 channel 讀取資料時,如果沒有緩衝區或者緩衝區為空,則當前協程會被阻塞,並被加入 recvq 佇列。向 channel 寫入資料時,如果沒有緩衝區或者緩衝區已滿,則當前協程同樣會被阻塞,然後加入到 sendq 的佇列。處於等待佇列中的協程會在其他協程操作 channel 時被喚醒。

下圖展示了一個沒有緩衝區的 channel,並有幾個協程正在阻塞等待讀取資料:

image.png

相關操作

建立通道

建立 channel 的過程實際上就是初始化 hchan 結構,型別資訊和緩衝區長度由 make 語句傳入,buf 的大小則由元素大小和緩衝區長度共同決定。

原始碼 src/runtime/chan.go 中定義了建立 channel 的函式 makechan(),精簡版的程式碼如下所示:

func makechan(t *chantype, size int) *hchan {    
    mem, overflow := math.MulUintptr(elem.size, uintptr(size))

    var c *hchan
    switch {
    case mem == 0:
        c = (*hchan)(mallocgc(hchanSize, nil, true))
        c.buf = c.raceaddr()
    case elem.ptrdata == 0:
        c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
        c.buf = add(unsafe.Pointer(c), hchanSize)
    default:
        c = new(hchan)
        c.buf = mallocgc(mem, elem, true)
    }

    c.elemsize = uint16(elem.size)
    c.elemtype = elem
    c.dataqsiz = uint(size)

    return c
}
傳送資料

傳送資料的操作最終都轉化成了 chansend() 函式,主要程式碼和邏輯如下:

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    // 如果通道為 nil,非阻塞式傳送的話直接返回 false,否則將當前協程掛起
    if c == nil {
        if !block {
            return false
        }
        gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
        throw("unreachable")
    }
 
    // 對於非阻塞式傳送,如果通道未關閉且沒有緩衝空間的話,直接返回 false
    if !block && c.closed == 0 && full(c) {
        return false
    }

    // 加鎖,併發安全
    lock(&c.lock)

    // 如果通道關閉了,直接 panic
    if c.closed != 0 {
        unlock(&c.lock)
        panic(plainError("send on closed channel"))
    }

    // 如果接收佇列不為空,直接將要傳送的資料傳送到隊首的 goroutine
    if sg := c.recvq.dequeue(); sg != nil {
        send(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true
    }

    // 對於緩衝區還有空閒的 channel,拷貝資料到緩衝區,維護相關資訊
    if c.qcount < c.dataqsiz {
        qp := chanbuf(c, c.sendx)
        if raceenabled {
            raceacquire(qp)
            racerelease(qp)
        }
        typedmemmove(c.elemtype, qp, ep)
        c.sendx++
        if c.sendx == c.dataqsiz {
            c.sendx = 0
        }
        c.qcount++
        unlock(&c.lock)
        return true
    }

    // 沒有緩衝空間時,傳送方會掛起,並根據當前 goroutine 構造一個 sudog 結構體新增到 sendq 佇列中
    gp := getg()
    mysg := acquireSudog()
    mysg.releasetime = 0
    if t0 != 0 {
        mysg.releasetime = -1
    }

    mysg.elem = ep
    mysg.waitlink = nil
    mysg.g = gp
    mysg.isSelect = false
    mysg.c = c
    gp.waiting = mysg
    gp.param = nil
    c.sendq.enqueue(mysg)

    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)

    // 省略被喚醒時部分程式碼

    return true
}
讀取資料

讀取資料的操作最終是轉化成了 chanrecv() 函式,主要邏輯如下:

// selected 和 received 返回值分別代表是否可被 select 語句命中以及是否讀取到了資料
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
    // 如果 channel 為 nil,非阻塞式讀取直接返回,否則直接掛起
    if c == nil {
        if !block {
            return
        }
        gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
        throw("unreachable")
    }

    // 非阻塞模式並且沒有訊息可讀(沒有緩衝區或者緩衝區為空),如果 channel 未關閉直接返回
    if !block && empty(c) {
        if atomic.Load(&c.closed) == 0 {
            return
        }

        if empty(c) {
            if raceenabled {
                raceacquire(c.raceaddr())
            }
            if ep != nil {
                typedmemclr(c.elemtype, ep)
            }
            return true, false
        }
    }

    // 加鎖
    lock(&c.lock)

    // channel 已關閉並且沒有訊息可讀(沒有緩衝區或者緩衝區為空),會接收到零值,typedmemclr 會根據型別清理相應地址的記憶體
    if c.closed != 0 && c.qcount == 0 {
        if raceenabled {
            raceacquire(c.raceaddr())
        }
        unlock(&c.lock)
        if ep != nil {
            typedmemclr(c.elemtype, ep)
        }
        return true, false
    }

    // 等待傳送佇列不為空,如果是非緩衝型 channel,直接拷貝傳送者的資料,否則接收隊首的資料,並將傳送者的資料移動到環形佇列尾部
    if sg := c.sendq.dequeue(); sg != nil {
        recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true, true
    }

    // 緩衝型 channel,buf 裡有元素,可以正常接收
    if c.qcount > 0 {
        // Receive directly from queue
        qp := chanbuf(c, c.recvx)
        if raceenabled {
            raceacquire(qp)
            racerelease(qp)
        }
        if ep != nil {
            typedmemmove(c.elemtype, ep, qp)
        }
        typedmemclr(c.elemtype, qp)
        c.recvx++
        if c.recvx == c.dataqsiz {
            c.recvx = 0
        }
        c.qcount--
        unlock(&c.lock)
        return true, true
    }

    // 被阻塞的情況,構造一個 sudog 結構體,儲存到 channel 的等待接收佇列,並將當前 goroutine 掛起
    gp := getg()
    mysg := acquireSudog()
    mysg.releasetime = 0
    if t0 != 0 {
        mysg.releasetime = -1
    }

    mysg.elem = ep
    mysg.waitlink = nil
    gp.waiting = mysg
    mysg.g = gp
    mysg.isSelect = false
    mysg.c = c
    gp.param = nil
    c.recvq.enqueue(mysg)

    atomic.Store8(&gp.parkingOnChan, 1)
    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)

    // 省略被喚醒時部分程式碼
    
    return true, !closed
}
關閉通道

關閉某個 channel,最終會執行函式 closechan(),核心程式碼如下:

func closechan(c *hchan) {
    // 如果 channel 為 nil,直接 panic
    if c == nil {
        panic(plainError("close of nil channel"))
    }

    // 加鎖,如果 channel 已關閉,直接 panic
    lock(&c.lock)
    if c.closed != 0 {
        unlock(&c.lock)
        panic(plainError("close of closed channel"))
    }

    c.closed = 1

    var glist gList

    // 釋放等待接收佇列中,向需要返回值的接收者返回相應的零值
    for {
        sg := c.recvq.dequeue()
        if sg == nil {
            break
        }
        if sg.elem != nil {
            typedmemclr(c.elemtype, sg.elem)
            sg.elem = nil
        }
        if sg.releasetime != 0 {
            sg.releasetime = cputicks()
        }
        gp := sg.g
        gp.param = nil
        if raceenabled {
            raceacquireg(gp, c.raceaddr())
        }
        glist.push(gp)
    }

    // 釋放等待傳送佇列,相關的 goroutine 會觸發panic
    for {
        sg := c.sendq.dequeue()
        if sg == nil {
            break
        }
        sg.elem = nil
        if sg.releasetime != 0 {
            sg.releasetime = cputicks()
        }
        gp := sg.g
        gp.param = nil
        if raceenabled {
            raceacquireg(gp, c.raceaddr())
        }
        glist.push(gp)
    }
    unlock(&c.lock)

    // ...
}

常見應用

定時任務

這種用法需要與 timer 結合,分為兩種:超時控制和定時執行。

如果需要執行某項操作,但又不想它耗費太長時間,想給它一個超時限制,可以這麼做:

select {
    case <-time.After(100 * time.Millisecond):
    case <-s.stopc:
        return false
}

等待 100 ms 後,如果 s.stopc 還沒有讀出資料或者被關閉,就直接結束。

定時執行某個任務也比較簡單,例如每隔 1 秒種,執行一次定時任務:

func worker() {
    ticker := time.Tick(1 * time.Second)
    for {
        select {
        case <- ticker:
            // 執行任務
        }
    }
}

解耦生產者與消費者

使用一個 channel 儲存任務,啟動 n 個 goroutine 作為工作協程池,這些協程工作在一個無限迴圈裡,從該 channel 讀取任務並執行:

func main() {
    taskCh := make(chan int, 100)
    go worker(taskCh)

    for i := 0; i < 10; i++ {
        taskCh <- i
    }

    select {
    case <-time.After(time.Hour):
    }
}
func worker(taskCh <-chan int) {
    const N = 5

    for i := 0; i < N; i++ {
        go func(id int) {
            for {
                task := <- taskCh
                fmt.Printf("finish task: %d by worker %d\n", task, id)
                time.Sleep(time.Second)
            }
        }(i)
    }
}

控制併發數

有時需要定時執行幾百個任務,但是併發數又不能太高,這時就可以通過 channel 來控制併發數。比如下面的例子:

var limit = make(chan int, 3)

func main() {
    // …………
    for _, w := range work {
        go func() {
            limit <- 1
            w()
            <-limit
        }()
    }
    // …………
}

構建一個容量為 3 的 channel,遍歷任務列表,每個任務啟動一個 goroutine,真正執行任務的動作在 w() 中完成。在執行 w() 之前,先要從 limit 中拿“許可證”,拿到許可證之後,才能執行 w(),並且在執行完任務,要將“許可證”歸還。要注意的是,如果 w() 發生 panic,那“許可證”可能就還不回去了,因此需要使用 defer 來保證。

相關文章