深入解讀Golang信道
信道是一個golang goroutine之間很關(guān)鍵的通信媒介。
理解golang的信道很重要,這里記錄平時易忘記的、易混淆的點(diǎn)。
1. 基本使用
剛聲明的信道,零值為nil,無法直接使用,需配合make函數(shù)進(jìn)行初始化
ic := make(chan int)
ic <-22 // 向無緩沖信道寫入數(shù)據(jù)
v := <-ic // 從無緩沖信道讀取數(shù)據(jù)
無緩沖信道:一手交錢,一手交貨, sender、receiver必須同時做好動作,才能完成發(fā)送->接收;否則,先準(zhǔn)備好的一方將會阻塞等待。 有緩沖信道 make(chan int,10):滑軌流水線,因?yàn)榇嬖诰彌_空間,故并不強(qiáng)制sender、receiver必須同時準(zhǔn)備好;當(dāng)通道空或滿時, 有一方會阻塞。
信道存在三種狀態(tài):nil, active, closed
針對這三種狀態(tài),sender、receiver有一些行為,我也不知道如何強(qiáng)行記憶這些行為 ??:
| 動作 | nil | active | closed |
|---|---|---|---|
| close | panic | 成功 | panic |
| ch <- | 死鎖 | 阻塞或成功 | panic |
| <-ch | 死鎖 | 阻塞或成功 | 零值 |
2. 從1個例子看chan的實(shí)質(zhì)
package main
import (
"fmt"
)
func SendDataToChannel(ch chan int, value int) {
fmt.Printf("ch's value:%v, chan's type: %T \n", ch, ch) // %v 顯示struct的值;%T 顯示類型
ch <- value
}
func main() {
var v int
ch := make(chan int)
fmt.Printf("ch's value:%v, chan's type: %T \n", ch, ch)
go SendDataToChannel(ch, 101) // 通過信道發(fā)送數(shù)據(jù)
v = <-ch // 從信道接受數(shù)據(jù)
fmt.Println(v) // 101
}
能正確打印101。
Q1: 剛學(xué)習(xí)golang的時候,一直給我們灌輸golang函數(shù)是值傳遞,那上例在另外一個協(xié)程內(nèi)部對形參的操作,為什么會影響外部的實(shí)參?
請關(guān)注格式化字符的日志輸出:
ch's value:0xc000018180, chan's type: chan int
ch's value:0xc000018180, chan's type: chan int
101
A: 上面的日志顯示傳遞的ch是一個指針值0xc000018180,類型是chan int( 這并不是說ch是指向chan int類型的指針)。
chan int本質(zhì)就是指向hchan結(jié)構(gòu)體的指針。
內(nèi)置函數(shù)make[1]創(chuàng)建信道:func makechan(t *chantype, size int) *hchan返回了指向hchan結(jié)構(gòu)體的指針:
type hchan struct {
qcount uint // 隊(duì)列中已有的緩存元素的長度
dataqsiz uint // 環(huán)形隊(duì)列的長度
buf unsafe.Pointer // 環(huán)形隊(duì)列的地址
elemsize uint16
closed uint32
elemtype *_type // 元素類型
sendx uint // 待發(fā)送的元素索引
recvx uint // 待接受元素索引
recvq waitq // 阻塞等待的goroutine
sendq waitq // 阻塞等待的gotoutine
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}

Q2:緩沖信道內(nèi)部為什么要使用環(huán)形隊(duì)列?
A:golang是使用數(shù)組來實(shí)現(xiàn)信道隊(duì)列,在不移動元素的情況下, 隊(duì)列會出現(xiàn)“假滿”的情況,
在做成環(huán)形隊(duì)列的情況下, 所有的入隊(duì)出隊(duì)操作依舊是 O(1)的時間復(fù)雜度,同時元素空間可以重復(fù)利用。
需要使用sendIndex,receIndex來標(biāo)記實(shí)際的待插入/拉取位置,顯而易見會出現(xiàn) sendIndex<=receIndex 的情況。

recvq,receq是由鏈表實(shí)現(xiàn)的隊(duì)列,用于存儲阻塞等待的goroutine和待發(fā)送/待接收值, 這兩個結(jié)構(gòu)也是阻塞goroutine被喚醒的準(zhǔn)備條件。
3. 發(fā)送/接收的細(xì)節(jié)
① 不要使用共享內(nèi)存來通信,而是使用通信來共享內(nèi)存
元素值從外界進(jìn)入信道會被復(fù)制,也就是說進(jìn)入信道的是元素值的副本,并不是元素本身進(jìn)入信道 (出信道類似)。
金玉良言落到實(shí)處:不同的線程不共享內(nèi)存、不用鎖,線程之間通訊用channel同步也用channel。發(fā)送/接收數(shù)據(jù)的兩個動作(G1,G2,G3)沒有共享的內(nèi)存,底層通過hchan結(jié)構(gòu)體的buf,使用copy內(nèi)存的方式進(jìn)行通信,最后達(dá)到了共享內(nèi)存的目的。
② 根據(jù)第①點(diǎn),發(fā)送操作包括:復(fù)制待發(fā)送值,放置到信道內(nèi);
接收操作包括:復(fù)制元素值, 放置副本到接收方,刪除原值,以上行為在全部完成之前都不會被打斷。所以第①點(diǎn)所說的無鎖,其實(shí)指的業(yè)務(wù)代碼無鎖,信道底層實(shí)現(xiàn)還是靠鎖。
以send操作為例,下面代碼截取自 https://github.com/golang/go/blob/master/src/runtime/chan.go#L216
if c.qcount < c.dataqsiz {
// Space is available in the channel buffer. Enqueue the element to send.
qp := chanbuf(c, c.sendx) // 計(jì)算出buf中待插入位置的地址
if raceenabled {
racenotify(c, c.sendx, nil)
}
typedmemmove(c.elemtype, qp, ep) // 將元素copy進(jìn)指定的qp地址
c.sendx++ // 重新計(jì)算待插入位置的索引
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&c.lock)
return true
}
一個常規(guī)的send動作:
計(jì)算環(huán)形隊(duì)列的待插入位置的地址 將元素copy進(jìn)指定的qp地址 重新計(jì)算待插入位置的索引sendx 如果待插入位置==隊(duì)列長度,說明插入位置已到尾部,需要插入首部。 以上動作加鎖
③ 進(jìn)入等待狀態(tài)的goroutine會進(jìn)入hchan的sendq/recvq列表
調(diào)度器將G1、G2置為waiting狀態(tài),G1、G2進(jìn)入sendq列表,同時與邏輯處理器分離;
直到有G3嘗試讀取信道內(nèi)`recvx`元素[2],之后將喚醒[3]隊(duì)首G1[4]進(jìn)入runnable狀態(tài),加入調(diào)度器的runqueue。
這里面涉及gopark, goready兩個函數(shù)。
如果是無緩沖信道引起的阻塞,將會直接拷貝G1的待發(fā)送值到G2的存儲位置[5]
?? https://github.com/golang/go/blob/master/src/runtime/chan.go#L527
package main
import (
"fmt"
"time"
)
func SendDataToChannel(ch chan int, value int) {
time.Sleep(time.Millisecond * time.Duration(value))
ch <- value
}
func main() {
var v int
var ch chan int = make(chan int)
go SendDataToChannel(ch, 104) // 通過信道發(fā)送數(shù)據(jù)
go SendDataToChannel(ch, 100) // 通過信道發(fā)送數(shù)據(jù)
go SendDataToChannel(ch, 95) // 通過信道發(fā)送數(shù)據(jù)
go SendDataToChannel(ch, 120) // 通過信道發(fā)送數(shù)據(jù)
time.Sleep(time.Second)
v = <-ch // 從信道接受數(shù)據(jù)
fmt.Println(v)
time.Sleep(time.Second * 10)
}
Q3:上述代碼大概率穩(wěn)定輸出95。
A:雖然4個goroutine被啟動的順序不定,但是肯定都阻塞了,阻塞的時機(jī)不一樣,被喚醒的是sendq隊(duì)首的goroutine,基本可認(rèn)為第三個goroutine被首先捕獲進(jìn)sendq ,因?yàn)槭菬o緩沖信道,將會直接拷貝G3的95給到待接收地址。
4. 業(yè)內(nèi)總結(jié)的信道的常規(guī)姿勢
無緩沖、緩沖信道的特征,已經(jīng)在golang領(lǐng)域形成了特定的套路。
當(dāng)容量為0時,說明信道中不能存放數(shù)據(jù),在發(fā)送數(shù)據(jù)時,必須要求立馬有人接收,此時的信道稱之為無緩沖信道。
當(dāng)容量為1時,說明信道只能緩存一個數(shù)據(jù),若信道中已有一個數(shù)據(jù),此時再往里發(fā)送數(shù)據(jù),會造成程序阻塞,利用這點(diǎn)可以利用信道來做鎖。
當(dāng)容量大于1時,信道中可以存放多個數(shù)據(jù),可以用于多個協(xié)程之間的通信管道,共享資源。
Q4:為什么無緩沖信道不適合做鎖?
A:我們先思考一下鎖的業(yè)務(wù)實(shí)質(zhì):獲取獨(dú)占標(biāo)識,并能夠繼續(xù)執(zhí)行;無緩沖信道雖然可以獲取獨(dú)占標(biāo)識,但是他阻塞了自身goroutine的執(zhí)行,所以并不適合實(shí)現(xiàn)業(yè)務(wù)鎖。
參考資料
內(nèi)置函數(shù)make: https://github.com/golang/go/blob/master/src/runtime/chan.go#L7
[2]直到有G3嘗試讀取信道內(nèi)recvx元素: https://github.com/golang/go/blob/1ebc983000ed411a1c06f6b8a61770be1392e707/src/runtime/chan.go#L629
喚醒: https://github.com/golang/go/blob/1ebc983000ed411a1c06f6b8a61770be1392e707/src/runtime/chan.go#L654
[4]隊(duì)首G1: https://github.com/golang/go/blob/1ebc983000ed411a1c06f6b8a61770be1392e707/src/runtime/chan.go#L527
[5]如果是無緩沖信道引起的阻塞,將會直接拷貝G1的待發(fā)送值到G2的存儲位置: https://github.com/golang/go/blob/master/src/runtime/chan.go#L616
