走進Golang之Channel的使用

2021-03-03 大愚Talk

對於 Golang 語言應用層面的知識,先講如何正確的使用,然後再講它的實現。

channel 是什麼

Don't communicate by sharing memory, share memory by communicating.

相信寫過 Go 的同學都知道這句名言,可以說 channel 就是後邊這句話的具體實現。我們來看一下到底 channel 是什麼?

channel 是一個類型安全的隊列(循環隊列),能夠控制 groutine 在它上面讀寫消息的行為,比如:阻塞某個 groutine ,或者喚醒某個 groutine。

不同的 groutine 可以通過 channel 交換任意的資源,由於 channel 能夠控制 groutine 的行為,所以 CSP 模型才能在 Golang 中順利實現,它確保了不同 groutine 之間的數據同步機制。

上面的話是不是聽起來非常的不舒服?

好吧,簡單說人話就是,channel 是用來在 不同的 的 goroutine 中交換數據的。一定要注意這裡 不同的 三個字。千萬不要把 channel 拿來在不同函數(同一個 goroutine 中)間交換數據。

使用

知道了定義,我們來看具體如何使用。

如何定義一個 channel 類型呢?

var ch1 chan int // 定義了一個 int 類型的 channel,沒有初始化,是 nil

ch2 := make(chan int) // 定義+初始化了一個無緩衝的 int 類型 channel
ch3 := make(chan int) // 定義+初始化了一個有緩衝的 int 類型 channel

上面的定義方法我們都是定義的雙向通道,對應的還有單向通道,但是單向通道我們一般只是做為函數參數來進行一些限制,並不會在定義、初始化時就搞一個單向通道出來。因為你定義一個單向通道沒有任何實際價值,通道的存在本來就是用來交換數據的,單向通道只能滿足發或者收。

下面我們一起來看一下具體的使用,以及使用中注意的一些點。

send

不管是有緩衝的通道還是無緩衝的通道都是用來交換數據的,既然是交換數據,無非就是寫入、讀取。我們先從發送開始。

無緩衝 channel
ch := make(chan int)
defer close(ch)

//ch<-5 // 位置一

go func(ch chan int) {
    num := <-ch
    fmt.Println(num)
}(ch)

// ch<-5 // 位置二

如果我們打開 位置一 的注釋,程序是無法獲得預期執行的,由於該 channel 是無緩衝的,位置一的代碼會陷入阻塞,下一行的 goroutine 根本沒有機會執行。整個代碼會陷入死鎖。

正確的操作是,打開 位置二 的注釋,因為上一行 goroutine 先行啟動,他是一個獨立的協程,不會阻塞主 groutine 的執行。但它內部會阻塞在 num := <-ch 這行代碼,直到主協程執行完 ch<-5 ,才會執行列印。所以這裡也有一個非常重要的問題,主協程如果不等待子協程執行完就退出的話,會看不到執行結果。

這裡先提一點,無緩衝的 channel 並不會用到內部結構體的 buf ,這部分具體會在源碼部分講解他們的數據存取、交換的方式。

有緩衝 channel
ch := make(chan int, 1) // 注意這裡
defer close(ch)

//ch<-5 // 位置一

go func(ch chan int) {
    num := <-ch
    fmt.Println(num)
}(ch)

// ch<-5 // 位置二

代碼基本沒有改變,唯一的區別是 make 函數傳入了第二個參數,這個值的含義是緩衝的大小。那麼此時 位置一位置二 都能夠正常執行嗎?

答案是肯定的,此時的代碼,無論是那個位置,打開注釋後都能夠正常執行。原因就在於由於 channel 有了緩存區域,位置一 寫入數據不會造成主協程的阻塞,那麼下一行代碼的子協程就可以正常啟動,並直接將位置一寫入 buf 的數據讀取出來列印。

對於 位置二 ,由於子協程先啟動,但是會被阻塞在 num := <-ch 這一行,因為此時 buf 中沒有任何內容可讀取(下期源碼分析我們可以看代碼實現),直到位置二執行完,喚醒子協程。

發送需要注意幾個問題:

向無緩衝 channel 寫數據,如果讀協程沒有準備好,會阻塞向有緩衝 channel 寫數據,如果緩衝已滿,會阻塞closed的 channel,寫數據會 panic

就算是有緩衝的 channel ,也不是每次發送、接收都要經過緩存,如果發送的時候,剛好有等待接收的協程,那麼會直接交換數據。

receive

有寫入,必然後讀取。

還是上面的代碼, num := <-ch 就是從 channel 讀取數據。對於讀取就不按照有緩衝與無緩衝來講解了,它們的主要問題是什麼時候阻塞。通過上面寫的例子自己再想想即可。

這裡說下讀取的兩種形式。

形式一

multi-valued assignment

v, ok := <-ch

ok 是一個 bool 類型,可以通過它來判斷 channel 是否已經關閉,如果關閉該值為 true ,此時 v 接收到的是 channel 類型的零值。比如:channel 是傳遞的 int, 那麼 v 就是 0 ;如果是結構體,那麼 v 就是結構體內部對應欄位的零值。

形式二

v := <-ch

該方式對於關閉的 channel 無法掌控,我們示例中就是該種方式。

接收需要注意幾個問題:

從無緩衝 channel 讀數據,如果寫協程沒有準備好,會阻塞從有緩衝 channel 讀數據,如果緩衝為空,會阻塞

讀取的 channel 如果被關閉,並不會影響正在讀的數據,它會將所有數據讀取完畢,並不會立即就失敗或者返回零值

close

對於 channel 的關閉,在什麼地方去關閉呢?因為上面也講到向 closed 的 channel 寫或者繼續 close 都會導致 panic問題。

一般的建議是誰寫入,誰負責關閉。如果涉及到多個寫入的協程、多個讀取的協程?又該如何關閉?總的來說就是加入一個標記避免重複關閉。不過真的不建議搞的太複雜,否則後續維護代碼會瘋掉。

關閉需要注意幾個問題:

closed 的 channel,再次關閉 close 會 panicfor-range

我們常常會用 for-range 來讀取 channel的數據。


ch := make(chan int, 1)

go func(ch chan int) {
    for i := 0; i < 10; i++ {
        ch <- i
    }
    close(ch)
}(ch)

for val := range ch {
    fmt.Println(val)
}

該語句的一個特色是如果 channel 已經被關閉,它還是會繼續執行,直到所有值被取完,然後退出執行。而如果通道沒有關閉,但是channel沒有可讀取的數據,它則會阻塞在 range 這句位置,直到被喚醒。但是如果 channel 是 nil,那麼同樣符合我們上面說的的原則,讀取會被阻塞,也就是會一直阻塞在 range 位置。

select

select 是跟 channel 關係最親密的語句,它是被專門設計出來處理通道的,因為每個 case 後面跟的都是通道表達式,可以是讀,也可以是寫。

ch := make(chan int)
q := make(chan int)

go func(ch, q chan int) {
    for i := 0; i < 10; i++ {
        num := <-ch
        fmt.Println(num)
    }
    q <- 1
}(ch, q)

fibonacci := func(ch, q chan int) {
    x, y := 0, 1
    for {
        select {
        case ch <- x: // 寫入
            x, y = y, x+y
            break // 你覺得是否會影響 for 語句的循環?
        case <-q: // 讀取
            fmt.Println("quit")
            return
        }
    }
}
fibonacci(ch, q)

上面的代碼是利用 channel 實現的一個斐波拉契數列。select 還可以有 default 語句,該語句會在其它 case 都被阻塞的情況下執行。

關注的問題

select 只要有默認語句,就不會被阻塞,換句話說,如果沒有 default,然後 case 又都不能讀或者寫,則會被阻塞select 不能夠像 for-range 一樣發現 channel 被關閉而終止執行,所以需要結合 multi-valued assignment 來處理如果同時有多個 case 滿足了條件,會使用偽隨機選擇一個 case 來執行select 語句如果不配合 for 語句使用,只會對 case 表達式求值一次每次 select 語句的執行,是會掃碼完所有的 case 後才確定如何執行,而不是說遇到合適的 case 就直接執行了。總結

本文內容很簡單易懂,希望大家徹底掌握了 channel 的使用。一切源碼的研究都是為了更好的使用,後面的文章將開始研究 channel 的源碼實現。

本文幾個重要問題再次總結下,也是經常面試的常考點。

向 close 的 channel 寫數據、再次 close 都會觸發 runtime panic。向 nil channel 寫、讀取數據,都會阻塞,可以利用這點來優化 for + select 的用法。channel 的關閉最好在寫入方處理,讀的協程不要去關閉 channel,可以通過單向通道來表明 channel 在該位置的功能。如果有多個寫協程的 channel 需要關閉,可以使用額外的 channel 來標記,也可以使用 sync.Once 或者 sync.Mutex 來處理。channel 不管是讀寫都是並發安全的,不會出現多個協程同時讀或者寫的情況,從而實現了 CSP。

參考資料

相關焦點

  • 【Golang】圖解channel之數據結構
    channel被設計用來實現goroutine間的通信,按照golang的設計思想:以通信的方式共享內存。
  • 走進Golang之Channel的數據結構
    上篇文章講了 channel 的基本使用,講了一些使用時需要注意的事項,本文將重點介紹 channel 中的兩個數據結構:循環隊列 與 雙端鍊表 。其實有緩衝的 channel,就是把同步的通信變為了異步的通信。寫的 channel 不需要關注讀 channel,只要有空間它就寫;而讀也一樣,只要有數據就正常讀就可以,如果沒有就掛起到隊列中,等待被喚醒。下圖形象的展示了有緩衝 channel 是如何交換數據的。
  • golang開發:channel使用
    ,如下make(chan Type, [buffer])chan Type 通道的類型buffer 是可選參數,代表通道緩衝區的大小(省略則代表無緩衝)向channel裡面寫入數據使用 <- 符號q := make(chan bool)q<-true從channel裡面讀取數據也是使用 <- 符號,只不過寫入的channel
  • Golang並發:再也不愁選channel還是選鎖
    在Golang裡,channel也不是萬能的,這是由channel的特性和局限造成的。下面就給大家介紹channel的特點、核心方法和缺點。channel解決並發問題的思路和示例channel的核心是數據流動,關注到並發問題中的數據流動,把流動的數據放到channel中,就能使用channel解決這個並發問題。
  • Golang 入門筆記 - Channel
    原文如下:Go 語言使用 goroutine 可以輕鬆地實現並發。大家應該都知道,goroutine 是通過 channel 實現相互通信的。channel 確保 goroutine 和主線程時間可以相互通信。在這篇文章中,將會與大家討論如何創建 channel 和實現數據共享。
  • 走進Golang之Context的使用
    看到這裡可能有人要叫了,完全可以用 channel 來搞啊!那麼我們看看 channel 是否可以滿足。想一個問題,如果是微服務架構,channel 怎麼實現跨進程的邊界呢?另外一個問題,就算不跨進程,如果嵌套很多個分支,想一想這個消息傳遞的複雜度。
  • golang之context使用
    背景golang中並發編程的三種實現方式:chan管道、waitGroup和Context。本篇將重點介紹context的使用,告訴大家基本的使用方式,做到會用。Context概念介紹context譯為上下文,golang在1.6.2的時候還沒有自己的context,在1.7的版本中就把golang.org/x/net/context包被加入到了官方的庫中。golang 的 Context包,是專門用來處理多個goroutine之間與請求域的數據、取消信號、截止時間等相關操作。
  • 「Golang」for range 使用方法及避坑指南
    Go給我們提供了一個新關鍵字range來進行遍歷,可以把它理解為一個三段式循環的語法糖,它不光可以遍歷map和channel,同樣的也可以遍歷數組,切片等數據結構,但是與傳統循環不同的是,他不可以進行普通的次數循環,那麼接下來我們來看一下其遍歷數組,切片,map和channel的相關操作以及所能碰到的坑。
  • 使用Golang快速構建WEB應用
    如果發現問題或者有好的建議請回復我我回及時更正。 1.Abstract在學習web開發的過程中會遇到很多困難,因此寫了一篇類似綜述類的文章。作為路線圖從web開發要素的index出發來介紹golang開發的學習流程以及Example代碼。在描述中多是使用代碼來描述使用方法不會做過多的說明。最後可以方便的copy代碼來實現自己的需求。
  • golang下文件鎖的使用
    前言題目是golang下文件鎖的使用,但本文的目的其實是通過golang下的文件鎖的使用方法,來一窺文件鎖背後的機制。
  • Golang context你必須要知道的
    go1.6 及之前版本請使用 golang.org/x/net/context。go1.7 及之後已移到標準庫 context。go vet 工具檢查所有流程控制路徑上使用 CancelFuncs。如果不知道用哪種 Context,可以使用 context.TODO()。使用 context 的 Value 相關方法只應該用於在程序和接口中傳遞的和請求相關的元數據,不要用它來傳遞一些可選的參數。
  • Golang 性能分析工具簡要介紹
    是因為在上面 flag 這個 bool 變量並沒有賦值,永遠為 false,因此 ch 這個 channel 並沒有被讀取,因此 37 行代碼會一直阻塞,這個程序比較簡單,但是實際的產品代碼要複雜的多,因此在查找問題的時候並不能一眼就能找到問題,還需要大家多多練習,多多積累。
  • Golang入門教程——面向對象篇
    今天是golang專題的第9篇文章,我們一起來看看golang當中的面向對象的部分。在現在高級語言當中,面向對象幾乎是不可或缺也是一門語言最重要的部分之一。在golang當中type關鍵字的含義是定義一個新的類型。
  • Golang入門教程——map篇
    今天是golang專題的第7篇文章,我們來聊聊golang當中map的用法。map這個數據結構我們經常使用,存儲的是key-value的鍵值對。在C++/java當中叫做map,在Python中叫做dict。
  • 【Golang】Context基礎篇
    一個接口golang的context包定義了Context類型,根據官方文檔的說法,該類型被設計用來在API邊界之間以及過程之間傳遞截止時間
  • Golang 需要避免踩的 50 個坑(二)
    最近準備寫一些關於golang的技術博文,本文是之前在GitHub上看到的golang技術譯文,感覺很有幫助,先給各位讀者分享一下。
  • Golang入門教程——基本操作篇
    首先是func關鍵字,我們使用這個關鍵字定義一個函數,之後跟著的是函數名,然後是函數的傳參,最後是函數的返回值。這個順序可能和我們之前普遍接觸的語法不太一樣,例如C++當中是把函數返回類型寫在最前面,然後是函數名和傳參。再比如Python當中則是沒有返回值的任何信息,只有def關鍵字和函數名以及傳入的參數。
  • gRPC 實操指南(golang)
    •C/S架構的傳輸業務,如股票軟體,每天需要用戶登陸的時候去伺服器拉取最新的數據,或者較簡單的文件傳輸業務,登陸驗證業務,證書業務都可以使用rpc的方式•跨語言開發的項目,比如web業務使用golang進行開發,底層使用cpp或c,部分腳本使用py,跨語言通信可以通過RPC提供的不同語言的開發機制進行實現。
  • 理解 Golang Context 機制
    【導讀】golang編程中經常用到Context,理解context設計和掌握其機制,對實現我們需要的功能有很大幫助。本文詳細介紹了go Context。1.golang.org/x/net/context包就是這種機制的實現。context包實現的主要功能為:其一,在程序單元之間共享狀態變量。
  • Golang之nil解密
    type Type int// nil is a predeclared identifier representing the zero value for a// pointer, channel, func, interface, map, or slice type.