go 并發之 channel

CSP 并發模型

CSP(Communicating Sequential Processes),是用于描述兩個獨立的并發實體通過共享 channel(管道)進行通信的并發模型。

go 并發模型

Go 語言中有兩種并發編程模型,除了普遍認知的多線程共享內存模型,還把 CSP 的思想融入到語言的核心里,基于 goroutine 和 channel 實現了其特有的 CSP 并發模型,使并發編程成為 Go 的一個獨特優勢。

goroutine 和 channel 是一對組合。goroutine 是執行并發的實體,而每個實體之間通過 channel 通信來實現數據共享。

go 并發原則

Do not communicate by sharing memory; instead, share memory by communicating.

不要通過共享內存來通信,而要通過通信來實現內存共享。

即不推薦使用 sync 包里的 mutex 等組件,而是使用 channel 進行并發編程。可以使用原子函數、互斥鎖等 ,但使用 channel 更優雅。

但兩者其實都是必要且有效的。實際上 channel 的底層就是通過 mutex 來控制并發的,只是 channel 是更高一層次的并發編程原語,封裝了更多的功能。

是選擇 sync 包里的底層并發編程原語還是 channel,參考如下決策樹:

圖片

channel

什么是 channel

channel 是 goroutine 之間通信的管道。channel 是線程安全的,并提供“先進先出”的特性。

使用

聲明和初始化

var c chan int        // 聲明了一個 nil 通道
c = make(chan int)    // 初始化了一個無緩沖通道,其值是一個地址,類型是 chan int
c = make(chan int, 100) // 初始化了一個緩沖區大小為100的通道

發送和接收

go func() {c <- 100}()  // 發送數據到通道中
i := <-c                // 從通道中接收數據

實現原理

數據結構

type hchan struct {
    // chan 里元素數量
    qcount   uint
    // chan 底層循環數組的長度
    dataqsiz uint
    // 指向底層循環數組的指針,只針對有緩沖的 channel
    buf      unsafe.Pointer
    // chan 中元素大小
    elemsize uint16
    // chan 是否被關閉的標志
    closed   uint32
    // chan 中元素類型
    elemtype *_type // element type
    // 已發送元素在循環數組中的索引
    sendx    uint   // send index
    // 已接收元素在循環數組中的索引
    recvx    uint   // receive index
    // 等待接收的 goroutine 隊列
    recvq    waitq  // list of recv waiters
    // 等待發送的 goroutine 隊列
    sendq    waitq  // list of send waiters

    // 保護 hchan 中所有字段
    lock mutex
}

buf 指向底層循環數組,只有緩沖型的 channel 才有。

sendxrecvx 均指向底層循環數組,表示當前可以發送和接收的元素位置索引值(相對于底層數組)。

sendqrecvq 分別表示被阻塞的 goroutine,這些 goroutine 由于嘗試讀取 channel 或向 channel 發送數據而被阻塞。

waitqsudog 的一個雙向鏈表,而 sudog 實際上是對 goroutine 的一個封裝:

type waitq struct {    
    first *sudog    
    last  *sudog
}

lock 用來保證每個讀 channel 或寫 channel 的操作都是原子的。

例如,創建一個容量為 6 的,元素為 int 型的 channel 數據結構如下 :

圖片

創建

當使用 make 函數創建通道時,底層創建函數如下:

func makechan(t *chantype, size int64) *hchan

從函數原型來看,創建的 chan 是一個指針,和 map 相似。

接收和發送

對 channel 的發送和接收操作都會在編譯期間轉換成為底層的發送接收函數。

channel 的發送和接收操作本質上都是 “值的拷貝”。

緩沖通道

對于無緩沖通道,讀寫通道會立馬阻塞當前協程。一個協程被通道操作阻塞后,Go 調度器會去調用其他可用的協程,這樣程序就不會一直阻塞。

而對于緩沖通道,寫不會阻塞當前通道,直到通道滿了,同理,讀操作也不會阻塞當前通道,除非通道沒數據。

也可以說無緩沖的 channel 是同步的,而有緩沖的 channel 是異步的。

創建一個緩沖通道:

ch := make(chan type, capacity)

capacity 是緩沖大小,必須大于 0。內置函數 len()、cap() 可以計算通道的長度和容量。

如果緩沖通道是關閉狀態但有數據,仍然可以讀取數據。

單向通道

主要用在通道作為參數傳遞的時候,Go 提供了自動轉化,雙向轉單向。

使用單向通道主要是可以提高程序的類型安全性,程序不容易出錯。

關閉通道

使用內置函數close(ch)可以關閉通道。

判斷通道關閉狀態

  • 數據接收方可以通過返回狀態判斷通道是否已關閉:

    val, ok := <- ch
    

    ok 用于判斷 ch 是否已關閉且沒有緩沖值可以讀取。為 true,該通道還可以進行讀寫操作;為 false,通道已關閉且沒有緩沖數據,不能再進行數據傳輸,返回的對應類型的零值。

  • for range 讀取通道,通道關閉,for range 自動退出

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

    應注意,使用 for range 讀取一個通道,數據寫入完畢后必須關閉通道,否則協程阻塞。

如何優雅地關閉 channel

關于 channel 的使用,有幾點不方便的地方:

  1. 在不改變 channel 自身狀態的情況下,無法獲知一個 channel 是否關閉。
  2. 關閉一個 closed channel 會導致 panic。所以,如果關閉 channel 的一方在不知道 channel 是否處于關閉狀態時就去貿然關閉 channel 是很危險的事情。
  3. 向一個 closed channel 發送數據會導致 panic。所以,如果向 channel 發送數據的一方不知道 channel 是否處于關閉狀態時就去貿然向 channel 發送數據是很危險的事情。

應該如何優雅地關閉 channel?

根據 sender 和 receiver 的個數,分下面幾種情況:

  1. 一個 sender,一個 receiver
  2. 一個 sender, M 個 receiver
  3. N 個 sender,一個 reciver
  4. N 個 sender, M 個 receiver

對于 1,2,只有一個 sender 的情況就不用說了,直接從 sender 端關閉就好了。

第 3 種情形下,優雅關閉 channel 的方法是增加一個傳遞關閉信號的 channel,receiver 通過信號 channel 下達關閉數據 channel 的命令。senders 監聽到關閉信號后,停止發送數據。

func main() {
    rand.Seed(time.Now().UnixNano())

    const Max = 100000
    const NumSenders = 1000

    dataCh := make(chan int, 100)
    stopCh := make(chan struct{})

    var wg sync.WaitGroup
    wg.Add(NumSenders)

    for i := 0; i < NumSenders; i++ {
        go func() {
            for {
                select {
                case <-stopCh:
                    wg.Done()
                    return
                case dataCh <- rand.Intn(Max):
                }
            }
        }()
    }

    go func() {
        for value := range dataCh {
            if value == Max-1 {
                fmt.Println("send stop signal to senders.")
                close(stopCh)
                return
            }
            fmt.Println(value)
        }
    }()

    wg.Wait()
    fmt.Println("main exit")
}

stopCh 就是信號 channel,receiver 關閉 stopCh 來通知 senders 停止發送數據。上面的代碼沒有明確關閉 dataCh,在 Go 語言中,對于一個 channel,如果最終沒有任何 goroutine 引用它,不管 channel 有沒有被關閉,最終都會被 gc 回收。所以,在這種情形下,所謂的優雅地關閉 channel 就是不關閉 channel,讓 gc 代勞。

第四種情況和第三種情況不同,這里有 M 個 receiver,如果還是采用第三種方案,由 receiver 直接關閉 stopCh 的話,就會重復關閉一個 channel,導致 panic。因此需要增加一個中間人,M 個 receiver 都向他發送關閉 dataCh 的請求,中間人收到第一個請求后下達關閉 dataCh 的指令(關閉 stopCh)。當然,這里的 N 個 sender 也可以向中間人發送關閉 dataCh 的請求。

func main() {
    rand.Seed(time.Now().UnixNano())

    const Max = 100000
    const NumSenders = 1000
    const NumReceivers = 10

    dataCh := make(chan int, 100)
    stopCh := make(chan struct{})
    toStop := make(chan string, NumSenders + NumReceivers)
    exitCh := make(chan struct{})

    go func() {
        s := <-toStop
        fmt.Println("toStop s:", s)
        close(stopCh)
        exitCh <- struct{}{}
    }()

    for i := 0; i < NumSenders; i++ {
        go func(id string) {
            for {
                value := rand.Intn(Max)
                if value == 0 {
                    toStop <- "sender#" + id
                    return
                }
                select {
                case <-stopCh:
                    return
                case dataCh <- value:
                }
            }
        }(strconv.Itoa(i))
    }

    for i := 0; i < NumReceivers; i++ {
        go func(id string) {
            for {
                select {
                case <- stopCh:
                    return
                case value := <-dataCh:
                    if value == Max-1 {
                        toStop <- "receiver#" + id
                        return
                    }
                    fmt.Println(value)
                }
            }
        }(strconv.Itoa(i))
    }

    select {
    case <-exitCh:
        time.Sleep(time.Second)
    }
    fmt.Println("main exit")
}

代碼里 toStop 就是中間人的角色,使用它來接收 senders 和 receivers 發送過來的關閉 dataCh 請求。這里同樣沒有真正關閉 dataCh,讓 gc 代勞。

常見異常操作

操作 nil 或 closed channel

  • close 關閉一個 nil channel,會引起 panic。

  • close 關閉一個 closed channel,會引起 panic。

  • 往一個 nil channel 發送數據,會造成阻塞。

  • 從一個 nil channel 接收數據,會造成阻塞。

  • 往一個 closed channel 發送數據,會引起 panic。

  • 從一個 closed channel 接收數據,返回已緩沖數據或者零值。

阻塞、死鎖

  • 無緩沖通道寫或者讀數據,當前協程阻塞。
  • 有緩沖通道已滿,再往通道中寫數據,當前協程阻塞。
  • 使用 for range 讀取一個通道,數據寫入端寫入完畢后必須關閉通道,否則 for range 語句所在協程阻塞。
  • 空 select 語句select {},沒有 case 分支,當前協程阻塞。
  • 當前協程阻塞又沒有其他可用協程時,死鎖。

內存泄漏

  • channel 可能會引發 goroutine 泄漏,原因是 goroutine 操作 channel 后,處于發送或接收阻塞狀態,而 channel 處于滿或空的狀態,一直得不到改變。同時,垃圾回收器也不會回收此類資源,進而導致 goroutine 一直處于等待隊列中。

通道應用

停止信號

channel 多用于停止信號的場景,關閉 channel 或者向 channel 發送一個元素,使得接收 channel 的那一方獲知此信息,進而做后續操作。

任務定時

與 timer 結合,實現超時控制。

// 等待 1 分鐘后,如果 dataCh 還沒有讀出數據或者被關閉,就直接結束。
select {
    case <-time.After(time.Minute):
    case <-dataCh:
        fmt.Println("do something")
}

或定期執行任務。

// 每隔一秒執行任務
ticker := time.Tick(time.Second)
for {
    select {
        case <- ticker:
        fmt.Println("do something")
    }
}

解耦生產方和消費方

生產方往 taskCh 塞任務,消費方循環從 channel 中拿任務,解耦。

控制并發數

某些場景因為資源限制等原因,需要控制并發數量。

limit := make(chan int, 3)
for _, w := range work {
    go func() {
        limit <- 1
        w()
        <-limit
    }()
}

真正執行任務,訪問第三方的動作在 w() 中完成,在執行 w() 之前,先要從 limit 中拿“許可證”,拿到許可證之后,才能執行 w(),并且在執行完任務,要將“許可證”歸還。這樣就可以控制同時運行的 goroutine 數。

還有一點要注意的是,如果 w() 發生 panic,那“許可證”可能就還不回去了,因此需要使用 defer 來保證。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,836評論 6 540
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,275評論 3 428
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,904評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,633評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,368評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,736評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,740評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,919評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,481評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,235評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,427評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,968評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,656評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,055評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,348評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,160評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,380評論 2 379

推薦閱讀更多精彩內容