本文來自 Go就業訓練營 小韜同學的投稿。
也強烈安利大家多寫部落格,不僅能倒逼自己學習總結,也能作為簡歷的加分項,提高求職面試的競爭力。
你想想看:面試官看到你簡歷中的部落格主頁有幾十篇文章,幾千粉絲是什麼感覺。要比你空洞洞的寫一句“熱愛技術”強太多啦!
正文
我們在學習與使用Go語言的過程中,對channel
並不陌生,channel
是Go語言與眾不同的特性之一,也是非常重要的一環,深入理解Channel
,相信能夠在使用的時候更加的得心應手。
一、Channel基本用法
1、channel類別
channel
在型別上,可以分為兩種:
+ 雙向channel:既能接收又能傳送的channel
+ 單向channel:只能傳送或只能接收的channel
,即單向channel
可以為分為:
+ 只寫channel
+ 只讀channel
宣告並初始化如下如下:
func main() {
// 宣告並初始化
var ch chan string = make(chan string) // 雙向channel
var readCh <-chan string = make(<-chan string) // 只讀channel
var writeCh chan<- string = make(chan<- string) // 只寫channel
}
上述定義中,<-
表示單向的channel
。如果箭頭指向chan
,就表示只寫channel
,可以往chan
裡邊寫入資料;如果箭頭遠離chan
,則表示為只讀channel
,可以從chan
讀資料。
在定義channel時,可以定義任意型別的channel,因此也同樣可以定義chan型別的channel。例如:
a := make(chan<- chan int) // 定義型別為 chan int 的寫channel
b := make(chan<- <-chan int) // 定義型別為 <-chan int 的寫channel
c := make(<-chan <-chan int) // 定義型別為 <-chan int 的讀channel
d := make(chan (<-chan int)) // 定義型別為 (<-chan int) 的讀channel
當channel
未初始化時,其零值為nil
。nil 是 chan 的零值,是一種特殊的 chan,對值是 nil 的 chan 的傳送接收呼叫者總是會阻塞。
func main() {
var ch chan string
fmt.Println(ch) // <nil>
}
透過make
我們可以初始化一個channel,並且可以設定其容量的大小,如下初始化了一個型別為string
,其容量大小為512
的channel
:
var ch chan string = make(chan string, 512)
當初始化定義了channel
的容量,則這樣的channel
叫做buffered chan
,即有緩衝channel
。如果沒有設定容量,channel
的容量為0,這樣的channel
叫做unbuffered chan
,即無緩衝channel
。
有緩衝channel
中,如果channel
中還有資料,則從這個channel
接收資料時不會被阻塞。如果channel
的容量還未滿,那麼向這個channel
傳送資料也不會被阻塞,反之則會被阻塞。
無緩衝channel
則只有當讀寫操作都準備好後,才不會阻塞,這也是unbuffered chan
在使用過程中非常需要注意的一點,否則可能會出現常見的bug。
channel的常見操作:
1. 傳送資料
往channel傳送一個資料使用ch <-
func main() {
var ch chan int = make(chan int, 512)
ch <- 2000
}
上述的ch
可以是chan int
型別,也可以是單向chan <-int
。
2. 接收資料
從channel接收一條資料可以使用<-ch
func main() {
var ch chan int = make(chan int, 512)
ch <- 2000 // 傳送資料
data := <-ch // 接收資料
fmt.Println(data) // 2000
}
ch 型別是 chan T
,也可以是單向<-chan T
在接收資料時,可以返回兩個返回值。第一個返回值返回channel
中的元素,第二個返回值為bool
型別,表示是否成功地從channel
中讀取到一個值。
如果第二個引數是false
,則表示channel
已經被close
而且channel
中沒有快取的資料,這個時候第一個值返回的是零值。
func main() {
var ch chan int = make(chan int, 512)
ch <- 2000 // 傳送資料
data1, ok1 := <-ch // 接收資料
fmt.Printf("data1 = %d, ok1 = %t\n", data1, ok1) // data1 = 2000, ok1 = true
close(ch) // 關閉channel
data2, ok2 := <-ch // 接收資料
fmt.Printf("data2 = %d, ok2 = %t", data2, ok2) // data2 = 0, ok2 = false
}
所以,如果從channel
讀取到一個零值,可能是傳送操作真正傳送的零值,也可能是closed
關閉channel
並且channel
沒有快取元素產生的零值,這是需要注意判別的一個點。
3. 其他操作
Go內建的函式close
、cap
、len
都可以對chan
型別進行操作。
+ close
:關閉channel。
+ cap
:返回channel的容量。
+ len
:返回channel快取中還未被取走的元素數量。
func main() {
var ch chan int = make(chan int, 512)
ch <- 100
ch <- 200
fmt.Println("ch len:", len(ch)) // ch len: 2
fmt.Println("ch cap:", cap(ch)) // ch cap: 512
}
傳送操作與接收操作可以作為select
語句中的case clause
,例如:
func main() {
var ch = make(chan int, 512)
for i := 0; i < 10; i++ {
select {
case ch <- i:
case v := <-ch:
fmt.Println(v)
}
}
}
for-range
語句同樣可以在chan
中使用,例如:
func main() {
var ch = make(chan int, 512)
ch <- 100
ch <- 200
ch <- 300
for v := range ch {
fmt.Println(v)
}
}
// 執行結果
100
200
300
2、select介紹
在Go語言中,select
語句用於監控一組case
語句,根據特定的條件執行相對應的case
語句或default
語句,與switch
類似,但不同之處在於select
語句中所有case
中的表示式都必須是channel
的傳送或接收操作。select
使用示例程式碼如下:
select {
case <-ch1:
fmt.Println("ch1")
case ch2 <- 1:
fmt.Println("ch2")
}
上述程式碼中,select
關鍵字讓當前goroutine
同時等待ch1
的可讀和ch2
的可寫,在滿足任意一個case
分支之前,select
會一直阻塞下去,直到其中的一個 channel
轉為就緒狀態時執行對應case
分支的程式碼。如果多個channel
同時就緒的話則隨機選擇一個case
執行。
當使用空select
時,空的 select
語句會直接阻塞當前的goroutine
,使得該goroutine
進入無法被喚醒的永久休眠狀態。空select
,即select
內不包含任何case
。
select{
}
另外當select
語句內只有一個case
分支時,如果該case
分支不滿足,那麼當前select
就變成了一個阻塞的channel
讀/寫操作。
select {
case <-ch1:
fmt.Println("ch1")
}
上述select
中,當ch1
可讀時,會執行列印操作,反之則阻塞當前goroutine
。
當select
語句內包含default
分支時,如果select
內的所有case
都不滿足,則會執行default
分支的邏輯,用於當其他case
都不滿足時執行一些預設操作。
select {
case <-ch1:
fmt.Println("ch1")
case ch2 <- 1:
fmt.Println("ch2")
default:
fmt.Println("default")
}
上述程式碼中,當ch1
可讀或ch2
可寫時,會執行相應的列印操作,否則就執行default
語句中的程式碼,相當於一個非阻塞的channel
讀取操作。
select
的使用可以總結為:
+ select
不存在任何的case
且沒有default
分支:永久阻塞當前 goroutine;
+ select
只存在一個case
且沒有default
分支:阻塞的傳送/接收;
+ select
存在多個case
:隨機選擇一個滿足條件的case
執行;
+ select
存在default
,其他case
都不滿足時:執行default
語句中的程式碼;
二、Channel實現原理
從程式碼的角度剖析channel
的實現,能夠讓我們更好的去使用channel
。
我們可以從chan
型別的資料結構、初始化以及三個操作傳送、接收和關閉這幾個方面來瞭解channel
。
1、chan資料結構
chan型別的資料結構定義位於runtime.hchan,其結構體定義如下:
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 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
}
解釋一下上述各個欄位的意義:
+ qcount
:表示chan
中已經接收到的資料且還未被取走的元素個數。內建函式len
可以返回這個欄位的值。
+ datasiz
:迴圈佇列的大小。chan
在實現上使用一個迴圈佇列來存放元素的個數,迴圈佇列適用於生產者-消費者的場景。
+ buf
:存放元素的迴圈佇列buffer
,buf
欄位是一個指向佇列緩衝區的指標,即指向一個dataqsiz
元素的陣列。buf
欄位是使用 unsafe.Pointer
型別來表示佇列緩衝區的起始地址。unsafe.Pointer
是一種特殊的指標型別,它可以用於指向任何型別的資料。由於佇列緩衝區的型別是動態分配的,所以不能直接使用某個具體型別的指標來表示。
+ elemtype
、elemsize
:elemtype
表示chan中元素的資料型別,elemsize
表示其大小。當chan定義後,它的元素型別是固定的,即普通型別或者指標型別,因此元素大小也是固定的。
+ sendx
:處理傳送資料操作的指標在buf佇列中的位置。當channel接收到了新的資料時,該指標就會加上elemsize
,移動到下一個位置。buf
的總大小是elemsize
的整數倍且buf
是一個迴圈列表。
+ recvx
:處理接收資料操作的指標在buf
佇列中的位置。當從buf中取出資料,此指標會移動到下一個位置。
+ recvq
:當接收操作發現channel
中沒有資料可讀時,會被則色,此時會被加入到recvq
佇列中。
+ sendq
:當傳送操作發現buf
佇列已滿時,會被進行阻塞,此時會被加入到sendq
佇列中。
<p align=center><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ddc1b911e2ac40b9ad73bff9647e4dc0~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=395&h=524&s=37043&e=png&b=fbf7f6" alt="image.png" /></p>
2、chan初始化
channel
在進行初始化時,Go編譯器會根據是否傳入容量的大小,來選擇呼叫makechan64
,還是makechan
。makechan64
在實現上底層還是呼叫makechan
來進行初始化,makechan64
只是對size
做了檢查。
makechan
函式根據chan
的容量的大小和元素的型別不同,初始化不同的儲存空間。省略一些檢查程式碼,makechan
函式的主要邏輯如下:
func makechan(t *chantype, size int) *hchan {
elem := t.elem
...
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
...
var c *hchan
switch {
case mem == 0:
// 佇列或元素大小為零,不必建立buf
c = (*hchan)(mallocgc(hchanSize, nil, true))
c.buf = c.raceaddr()
case elem.ptrdata == 0:
// 元素不包含指標,分配一塊連續的記憶體給hchan資料結構和buf
// hchan資料結構後面緊接著就是buf,在一次呼叫中分配hchan和buf
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// 元素包含指標,單獨分配buf
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
// 記錄元素大小、型別、容量
c.elemsize = uint16(elem.size)
c.elemtype = elem
c.dataqsiz = uint(size)
lockInit(&c.lock, lockRankHchan)
...
return c
}
3、send傳送操作
Go在編譯傳送資料給channel
時,會把傳送操作send
轉換成chansend1
函式,而chansend1
函式會呼叫chansend
函式。
func chansend1(c *hchan, elem unsafe.Pointer) {
chansend(c, elem, true, getcallerpc())
}
我們可以來分段分析chansend
函式的實現邏輯。
第一部分:
主要是對chan
進行判斷,判斷chan
是否為nil
,若為nil
,則判斷是否需要將當前goroutine
進行阻塞,阻塞透過gopark
來對呼叫者goroutine park
(阻塞休眠)。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
// 第一部分
if c == nil { // 判斷chan是否為nil
if !block { // 判斷是否需要阻塞當前goroutine
return false
}
// 呼叫這goroutine park,進行阻塞休眠
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
throw("unreachable")
}
...
}
第二部分
第二部分的邏輯判斷是當你往一個容量已滿的chan
例項傳送資料,且不想當前呼叫的goroutine
被阻塞時(chan
未被關閉),那麼處理的邏輯是直接返回。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 第二部分
if !block && c.closed == 0 && full(c) {
return false
}
...
}
第三部分
第三部分的邏輯判斷是首先進行互斥鎖加鎖,然後判斷當前chan
是否關閉,如果chan
已經被close
了,則釋放互斥鎖並panic
,即對已關閉的chan
傳送資料會panic
。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 第三部分
lock(&c.lock) // 開始加鎖
if c.closed != 0 { // 判斷channel是否關閉
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
...
}
第四部分
第四部分的邏輯主要是判斷接收佇列中是否有正在等待的接收方receiver
。如果存在正在等待的receiver
(說明此時buf
中沒有快取的資料),則將他從接收佇列中彈出,直接將需要傳送到channel
的資料交給這個receiver
,而無需放入到buf
中,讓傳送操作速度更快一些。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 第四部分
if sg := c.recvq.dequeue(); sg != nil {
// 找到了一個正在等待的接收者。我們傳遞我們想要傳送的值
// 直接傳遞給receiver接收者,繞過channel buf快取區(如果receiver有的話)
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
...
}
第五部分
當等待佇列中並沒有正在等待的receiver
,則說明當前buf
還沒有滿,此時將傳送的資料放入到buf
中。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 第五部分
if c.qcount < c.dataqsiz { // 判斷buf是否滿了
// channel buf還有可用的空間. 將傳送資料入buf迴圈佇列.
qp := chanbuf(c, c.sendx)
if raceenabled {
racenotify(c, c.sendx, nil)
}
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&c.lock)
return true
}
...
}
第六部分
當邏輯走到第六部分,說明正在處理buf
已滿的情況。如果buf
已滿,則傳送操作的goroutine
就會加入到傳送者的等待佇列,直到被喚醒。當goroutine
被喚醒時,資料或者被取走了,或者chan
已經被關閉了。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 第六部分
// chansend1函式呼叫不會進入if塊裡,因為chansend1的block=true
if !block {
unlock(&c.lock)
return false
}
...
c.sendq.enqueue(mysg) // 加入傳送佇列
...
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2) // 阻塞
...
}
4、recv接收操作
從channel
中接收資料時,Go會將程式碼轉換成chanrecv1
函式。如果需要返回兩個返回值,則會轉換成chanrecv2
,chanrecv1
函式和chanrecv2
都會呼叫chanrecv
函式。chanrecv1
和chanrecv2
傳入的 block
引數的值是true
,兩種呼叫都是阻塞方式,因此在分析chanrecv
函式的實現時,可以不考慮 block=false
的情況。
// 從已編譯程式碼中進入 <-c 的入口點
func chanrecv1(c *hchan, elem unsafe.Pointer) {
chanrecv(c, elem, true)
}
func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {
_, received = chanrecv(c, elem, true)
return
}
同樣,省略一些檢查類的程式碼,我們也可以分段分析chanrecv
函式的邏輯。
第一部分
第一部分主要判斷當前進行接收操作的chan
例項是否為nil
,若為nil
,則從nil chan
中接收資料的呼叫這goroutine
會被阻塞。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
// 第一部分
if c == nil { // 判斷chan是否為nil
if !block { // 是否阻塞,預設為block=true
return
}
// 進行阻塞
gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
throw("unreachable")
}
...
}
第二部分
這一部分只要是考慮block=false
且c
為空的情況,block=false
的情況我們可以不做考慮。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
// 檢查未獲得鎖的失敗非阻塞操作。
if !block && empty(c) {
...
}
...
}
第三部分
第三部分的邏輯為判斷當前chan
是否被關閉,若當前chan
已經被close
了,並且快取佇列中沒有緩衝的元素時,返回true
、false
。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
lock(&c.lock) // 加鎖,返回時釋放鎖
// 第三部分
if c.closed != 0 { // 當chan已被關閉時
if c.qcount == 0 { // 且 buf區 沒有快取的資料了
...
unlock(&c.lock) // 解鎖
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
}
...
}
第四部分
第四部分是處理通道未關閉且buf
快取佇列已滿的情況。只有當快取佇列已滿時,才能夠從傳送等待佇列獲取到sender
。若當前的chan
為unbuffer
的chan
,即無緩衝區channel
時,則直接將sender
的傳送資料傳遞給receiver
。否則就從快取佇列的頭部讀取一個元素值,並將獲取的sender
攜帶的值加入到buf
迴圈佇列的尾部。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
if c.closed != 0 { // 當chan已被關閉時
} else { // 第四部分,通道未關閉
// 如果sendq佇列中有等待傳送的sender
if sg := c.sendq.dequeue(); sg != nil {
// 存在正在等待的sender,如果快取區的容量為0則直接將傳送方的值傳遞給接收方
// 反之,則從快取佇列的頭部獲取資料,並將獲取的sender的傳送值加入到快取佇列尾部
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
}
...
}
第五部分
第五部分的主要邏輯是處理傳送佇列中沒有等待的sender
且buf
中有快取的資料。該段邏輯與外出的互斥鎖共用一把鎖,因此不存在併發問題。當buf
快取區有快取元素時,則取出該元素傳遞給receiver
,同時移動接收指標。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
// 第五部分
if c.qcount > 0 { // 傳送佇列中沒有等待的sender,且buf中有快取資料
// 直接從快取佇列中獲取資料
qp := chanbuf(c, c.recvx)
if raceenabled {
racenotify(c, c.recvx, nil)
}
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
typedmemclr(c.elemtype, qp)
c.recvx++ // 移動接收指標
if c.recvx == c.dataqsiz { // 指標若已到末尾則進行重置(迴圈佇列)
c.recvx = 0
}
c.qcount-- // 獲取資料後,buf快取區元素個數減一
unlock(&c.lock) // 解鎖
return true, true
}
if !block { // block=true
unlock(&c.lock)
return false, false
}
...
}
第六部分
第六部分的邏輯主要是處理buf
快取區中沒有快取資料的情況。當buf
快取區沒有快取資料時,那麼當前的receiver
就會被阻塞,直到它從sender
中接收了資料,或者是chan
被close
,才會返回。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
c.recvq.enqueue(mysg) // 將當前接收操作入接收佇列
...
// 進行阻塞,等待喚醒
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
...
}
5、close關閉
close
函式主要用於channel
的關閉,Go編譯器會替換成closechan
函式的呼叫。省略一些檢查下的程式碼後,closechan
函式的主要邏輯如下:
+ 如果當前chan
為nil
,則直接panic
+ 如果當前chan
已關閉,再次close
則直接panic
+ 如果chan
不為nil
,chan
也沒有closed
,就把等待佇列中的 sender(writer)
和 receiver(reader)
從佇列中全部移除並喚醒。
func closechan(c *hchan) {
if c == nil { // 若當前chan未nil,則直接panic
panic(plainError("close of nil channel"))
}
lock(&c.lock) // 加鎖
if c.closed != 0 { // 若當前chan已經關閉,則直接panic
unlock(&c.lock)
panic(plainError("close of closed channel"))
}
...
c.closed = 1 // 設定當前channel的狀態為已關閉
var glist gList
// 釋放接收佇列中所有的reader
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 = unsafe.Pointer(sg)
sg.success = false
if raceenabled {
raceacquireg(gp, c.raceaddr())
}
glist.push(gp)
}
// 釋放傳送佇列中所有的writer (它們會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 = unsafe.Pointer(sg)
sg.success = false
if raceenabled {
raceacquireg(gp, c.raceaddr())
}
glist.push(gp)
}
unlock(&c.lock)
for !glist.empty() {
gp := glist.pop()
gp.schedlink = 0
goready(gp, 3)
}
}
三、總結
透過學習channel
的基本使用,瞭解其操作背後的實現原理,可以幫助我們更好的使用channel
,避免一些操作不當而導致的panic
或者說是bug
,讓我們在使用channel
時能夠更加的得心應手。
channel
的值和狀態有多種情況,而不同的操作(send、recv、close)
又可能得到不同的結果,這是使用 channel
型別時需要經常注意的點,我們可以將不同channel
值下的不同操作進行一個總結,特別注意操作channel
時會產生panic
的情況,已經可能會導致執行緒阻塞的情況,都是有可能導致死鎖與goroutine
洩漏的罪魁禍首。
| channel執行操作\channel狀態 | channel為nil | channel buf為空 | channel buf已滿 | channel buf未滿且不為空 | channel已關閉 |
| --------------------------- | ------------ | ------------------------------ | ----------------------------- | ----------------------------- | ------------------- |
| receive
接收操作 | 阻塞 | 阻塞 | 讀取資料 | 讀取資料 | 返回buf中快取的資料 |
| send
傳送操作 | 阻塞 | 寫入資料 | 阻塞 | 寫入資料 | panic |
| close
關閉 | panic | 關閉channel,buf中沒有快取資料 | 關閉channel,保留已快取的資料 | 關閉channel,保留已快取的資料 | panic
又出成績啦
答疑解惑
需要「簡歷最佳化」、「就業輔導」、「職業規劃」的朋友可以聯絡我。
加我微信:wangzhongyang1993
關注我的同名公眾號:王中陽Go