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 示意圖:
- dataqsiz 表明了佇列長度為6,即可快取6個元素;
- buf 指向佇列的記憶體地址;
- qcount 表示佇列中還有兩個元素;
- sendx 表示後續寫入的資料儲存的位置,取值為 [0, 6);
- recvx 表示讀取資料的位置, 取值為[0, 6)。
型別資訊
一個 channel 只能傳遞一種型別的值:
- elemtype 代表型別,用於資料傳遞過程中的賦值;
- elemsize 代表型別大小,用於在buf中定位元素位置。
等待佇列
從 channel 讀取資料時,如果沒有緩衝區或者緩衝區為空,則當前協程會被阻塞,並被加入 recvq 佇列。向 channel 寫入資料時,如果沒有緩衝區或者緩衝區已滿,則當前協程同樣會被阻塞,然後加入到 sendq 的佇列。處於等待佇列中的協程會在其他協程操作 channel 時被喚醒。
下圖展示了一個沒有緩衝區的 channel,並有幾個協程正在阻塞等待讀取資料:
相關操作
建立通道
建立 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 來保證。