通俗易懂剖析Go Channel:理解併發通訊的核心機制

發表於2024-02-23

本文來自 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未初始化時,其零值為nilnil 是 chan 的零值,是一種特殊的 chan,對值是 nil 的 chan 的傳送接收呼叫者總是會阻塞。

func main() {  
    var ch chan string  
    fmt.Println(ch) // <nil>  
}  

透過make我們可以初始化一個channel,並且可以設定其容量的大小,如下初始化了一個型別為string,其容量大小為512channel

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內建的函式closecaplen都可以對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:存放元素的迴圈佇列bufferbuf 欄位是一個指向佇列緩衝區的指標,即指向一個dataqsiz元素的陣列。buf 欄位是使用 unsafe.Pointer 型別來表示佇列緩衝區的起始地址。unsafe.Pointer是一種特殊的指標型別,它可以用於指向任何型別的資料。由於佇列緩衝區的型別是動態分配的,所以不能直接使用某個具體型別的指標來表示。
elemtypeelemsizeelemtype表示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,還是makechanmakechan64在實現上底層還是呼叫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函式。如果需要返回兩個返回值,則會轉換成chanrecv2chanrecv1函式和chanrecv2都會呼叫chanrecv函式。chanrecv1chanrecv2傳入的 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=falsec為空的情況,block=false的情況我們可以不做考慮。

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {  
    ...  
    // 檢查未獲得鎖的失敗非阻塞操作。  
    if !block && empty(c) {  
        ...  
    }  
    ...  
}  

第三部分

第三部分的邏輯為判斷當前chan是否被關閉,若當前chan已經被close了,並且快取佇列中沒有緩衝的元素時,返回truefalse

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。若當前的chanunbufferchan,即無緩衝區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  
       }  
    }  
      
    ...  
}  

第五部分

第五部分的主要邏輯是處理傳送佇列中沒有等待的senderbuf中有快取的資料。該段邏輯與外出的互斥鎖共用一把鎖,因此不存在併發問題。當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中接收了資料,或者是chanclose,才會返回。

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函式的主要邏輯如下:
+ 如果當前channil,則直接panic
+ 如果當前chan已關閉,再次close則直接panic
+ 如果chan不為nilchan也沒有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

又出成績啦

我們又出成績啦!大廠Offer集錦!遙遙領先!

這些朋友贏麻了!

這是一個專注程式設計師升職加薪の知識星球

答疑解惑

需要「簡歷最佳化」、「就業輔導」、「職業規劃」的朋友可以聯絡我。

加我微信:wangzhongyang1993

關注我的同名公眾號:王中陽Go

相關文章