這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
channel作為goroutine間通訊和同步的重要途徑,是Go runtime層實現CSP並行存取模型重要的成員。在不理解底層實現時,經常在使用中對channe相關文法的表現感到疑惑,尤其是select case的行為。因此在瞭解channel的應用前先看一眼channel的實現。
Channel記憶體布局
channel是go的內建類型,它可以被儲存到變數中,可以作為函數的參數或傳回值,它在runtime層對應的資料結構式hchan。hchan維護了兩個鏈表,recvq是因讀這個chan而阻塞的G,sendq則是因寫這個chan而阻塞的G。waitq隊列中每個元素的資料結構為sudog,其中elem用於儲存資料。
type hchan struct { qcount uint // total data in the queue dataqsiz uint // size of the circular queue buf unsafe.Pointer // points to an array of dataqsiz elements elemsize uint16 closed uint32 elemtype *_type // element type sendx uint // send index recvx uint // receive index recvq waitq // list of recv waiters sendq waitq // list of send waiters lock mutex}type sudog struct { g *g selectdone *uint32 next *sudog prev *sudog elem unsafe.Pointer // data element releasetime int64 nrelease int32 // -1 for acquire waitlink *sudog // g.waiting list}
hchan只是channel的頭部,頭部後面的一段記憶體連續的數組將作為channel的緩衝區,即用於存放channel資料的環形隊列。qcount datasize分別描述了緩衝區當前使用量和容量。若channel是無緩衝的,則size是0,就沒有這個環形隊列了。
建立chan需要知道資料類型和緩衝區大小。對應上面的結構圖newarray將產生這個環形隊列。之所以要分開指標類型緩衝區主要是為了區分gc操作,需要將它設定為flagNoScan。並且指標大小固定,可以跟hchan頭部一起分配記憶體,不需要先new(hchan)再newarry。
聲明但不make初始化的chan是nil chan。讀寫nil chan會阻塞,關閉nil chan會panic。
func makechan(t *chantype, size int64) *hchan { elem := t.elem var c *hchan if elem.kind&kindNoPointers != 0 || size == 0 { c = (*hchan)(mallocgc(hchanSize+uintptr(size)*uintptr(elem.size), nil, flagNoScan)) if size > 0 && elem.size != 0 { c.buf = add(unsafe.Pointer(c), hchanSize) } else { c.buf = unsafe.Pointer(c) } } else { c = new(hchan) c.buf = newarray(elem, uintptr(size)) } c.elemsize = uint16(elem.size) c.elemtype = elem c.dataqsiz = uint(size) return c}
Channel操作
從實現中可見讀寫chan都要lock,這跟讀寫共用記憶體一樣都有lock的開銷。
資料在chan中的傳遞方向從chansend開始從入參最終寫入recvq中的goroutine的資料域,這中間如果發生阻塞可能先寫入sendq中goroutine的資料域等待中轉。
從gopark返回後sudog對象可重用。
同步讀寫
寫channel c<-x 調用runtime.chansend。讀channel <-c 調用runtime.chanrecv。總結同步讀寫的過程就是:
- 寫chan時優先檢查recvq中有沒有等待讀chan的goroutine,若有從recvq中出隊sudoG。
syncsend將要寫入chan的資料ep複製給剛出隊的sudoG的elem域。通過goready喚醒接收者G,狀態設定為_Grunnable,之後放進P本地待運行隊列。之後這個讀取到資料的G可以再次被P調度了。
- 寫chan時如果沒有G等待讀,當前G因等待寫而阻塞。這時建立或擷取
acquireSudog,封裝上要寫入的資料進入sendq隊列。同時當前Ggopark休眠等待被喚醒。
- 讀chan時優先喚醒sendq中等待寫的goroutine,並從中擷取資料;若沒人寫則將自己掛到recvq中等待喚醒。
func chansend(t *chantype, c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {... lock(&c.lock) if c.dataqsiz == 0 { // synchronous channel sg := c.recvq.dequeue() if sg != nil { // found a waiting receiver unlock(&c.lock) recvg := sg.g syncsend(c, sg, ep) goready(recvg, 3) return true } // no receiver available: block on this channel. mysg := acquireSudog() mysg.elem = ep c.sendq.enqueue(mysg) goparkunlock(&c.lock, "chan send", traceEvGoBlockSend, 3) // someone woke us up. releaseSudog(mysg) return true }}
func chanrecv(t *chantype, c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) { if c.dataqsiz == 0 { // synchronous channel sg := c.sendq.dequeue() if sg != nil { unlock(&c.lock) typedmemmove(c.elemtype, ep, sg.elem) gp.param = unsafe.Pointer(sg) goready(gp, 3) return true, true } // no sender available: block on this channel. mysg := acquireSudog() mysg.elem = ep c.recvq.enqueue(mysg) goparkunlock(&c.lock, "chan receive", traceEvGoBlockRecv, 3) // someone woke us up releaseSudog(mysg) return recvclosed(c, ep) }}
非同步讀寫
非同步與同步的區別就是讀寫時會優先檢查緩衝區有沒有資料讀或有沒有空間寫。並且真正讀寫chan後會發生緩衝區變化,這時可能之前阻塞的goroutine有機會寫和讀了,所以要嘗試喚醒它們。 總結過程:
- 寫chan時緩衝區已滿,則將當前G和資料封裝好放入sendq隊列中等待寫入,同時掛起
gopark當前goroutine。若緩衝區未滿,則直接將資料寫入緩衝區,並更新緩衝區最新資料的index以及qcount。同時嘗試從recvq中喚醒goready一個之前因為緩衝區無資料可讀而阻塞的等待讀的goroutine。
- 讀chan時首先看緩衝區有沒有資料,若有則直接讀取,並嘗試喚醒一個之前因為緩衝區滿而阻塞的等待寫的goroutine,讓它有機會寫資料。若無資料可讀則入隊recvq。
func chansend(t *chantype, c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool { // asynchronous channel var t1 int64 for futile := byte(0); c.qcount >= c.dataqsiz; futile = traceFutileWakeup { mysg := acquireSudog() c.sendq.enqueue(mysg) goparkunlock(&c.lock, "chan send", traceEvGoBlockSend|futile, 3) // someone woke us up - try again releaseSudog(mysg) } // write our data into the channel buffer typedmemmove(c.elemtype, chanbuf(c, c.sendx), ep) c.sendx++ if c.sendx == c.dataqsiz { c.sendx = 0 } c.qcount++ // wake up a waiting receiver sg := c.recvq.dequeue() if sg != nil { goready(sg.g, 3) } return true}
func chanrecv(t *chantype, c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) { // asynchronous channel for futile := byte(0); c.qcount <= 0; futile = traceFutileWakeup { mysg := acquireSudog() c.recvq.enqueue(mysg) goparkunlock(&c.lock, "chan receive", traceEvGoBlockRecv|futile, 3) // someone woke us up - try again releaseSudog(mysg) } typedmemmove(c.elemtype, ep, chanbuf(c, c.recvx)) memclr(chanbuf(c, c.recvx), uintptr(c.elemsize)) c.recvx++ if c.recvx == c.dataqsiz { c.recvx = 0 } c.qcount-- // ping a sender now that there is space sg := c.sendq.dequeue() if sg != nil { goready(sg.g, 3) } return true, true}
關閉
通過goready喚醒recvq中等待讀的goroutine,之後喚醒所有sendq中等待寫的goroutine。因此close chan相當於解除所有因它阻塞的gouroutine的阻塞。
func closechan(c *hchan) { c.closed = 1 // release all readers for { sg := c.recvq.dequeue() if sg == nil { break }... goready(gp, 3) } // release all writers for { sg := c.sendq.dequeue() if sg == nil { break }... goready(gp, 3) }}
寫closed chan或關閉 closed chan會導致panic。讀closed chan永遠不會阻塞,會返回一個通道資料類型的零值,返回給函數的參數ep。
所以通常在close chan時需要通過讀操作來判斷chan是否關閉。
if v, open := <- c; !open { // chan is closed}
Happens before
在go memory model 裡講了happens-before問題很有意思。其中有一些跟chan相關的同步規則可以解釋一些一直以來的疑問,記錄如下:
- 對帶緩衝chan的寫操作 happens-before相應chan的讀操作
- 關閉chan happens-before 從該chan讀最後的傳回值0
- 不帶緩衝的chan的讀操作 happens-before相應chan的寫操作
var c = make(chan int, 10)var a stringfunc f() { a = "hello, world" //(1) c <- 0 // (2)}func main() { go f() <- c //(3) print(a) //(4)}
(1) happens-before(2) (3) happens-before(4),再根據規則可知(2) happens(3)。因此(1)happens-before(4),這段代碼沒有問題,肯定會輸出hello world。
var c = make(chan int)var a stringfunc f() { a = "hello, world" //(1) <-c // (2)}func main() { go f() c <- 0 //(3) print(a) //(4)}
同樣根據規則三可知(2)happens-before(3) 最終可以保證(1) happens-before(4)。若c改成待緩衝的chan,則結果將不再有任何同步保證使得(2) happens-before(3)。