GO通道和 sync 包的分享

小魔童哪吒發表於2021-06-13
[TOC]

我們一起回顧一下上次分享的內容:

  • GO協程同步若不做限制的話,會產生資料競態的問題
  • 我們用鎖的方式來解決如上問題,根據使用場景選擇使用互斥鎖 和 讀寫鎖
  • 比使用鎖更好的方式是原子操作,但是使用go的 sync/atomic需要小心使用,因為涉及記憶體

要是對GO的鎖和原子操作還感興趣的話,歡迎檢視文章GO的鎖和原子操作分享

上次我們分享到鎖和原子操作,都可以保證共享資料的讀寫

可是,他們還是會影響效能,不過,Go 為開發這提供了 通道 這個神器

今天我們來分享一下Go中推薦使用的其他同步方法,通道和 sync 包

通道是什麼?

是一種特殊的型別,是連線併發goroutine的管道

channel 通道是可以讓一個 goroutine 協程傳送特定值到另一個 goroutine 協程的通訊機制

通道像一個傳送帶或者佇列,總是遵循先入先出(First In First Out)的規則,保證收發資料的順序,這一點和管道是一樣的

一個協程從通道的一頭放入資料,另一個協程從通道的另一頭讀出資料

每一個通道都是一個具體型別的導管,宣告 channel 的時候需要為其指定元素型別。

通道能做什麼?

控制協程的同步,讓程式有序執行

GO 中提倡 不要通過共享記憶體來通訊,而通過通訊來共享記憶體

goroutine協程 是 Go 程式併發的執行體,channel 通道就是它們之間的連線,他們之間的橋樑,他們的交通樞紐

通道有哪幾種?

大致可分為如下三種:

  • 無緩衝通道
  • 有緩衝的通道
  • 單向通道

無緩衝通道

無緩衝的通道又稱為阻塞的通道

無緩衝通道上的傳送操作會阻塞,直到另一個goroutine在該通道上執行接收操作,這時值才能傳送成功

兩個 goroutine 協程將繼續執行

我們反過來看,如果接收操作先執行,接收方的goroutine將阻塞,直到另一個 goroutine 協程在該通道上傳送一個資料

因此,無緩衝通道也被稱為同步通道,因為我們可以使用無緩衝通道進行通訊,利用傳送和接收的 goroutine 協程同步化

有緩衝的通道

還是上述提到的,有緩衝通道,就是在初始化 / 建立通道 的 make 函式的第 2 個引數填上我們所期望的緩衝區大小 , 例如:

ch1 := make(chan int , 4)

此時,該通道的容量為4,傳送方可以一直向通道中傳送資料,直到通道滿,且通道資料未被讀走時,傳送方就會阻塞

只要通道的容量大於零,那麼該通道就是有緩衝的通道

通道的容量表示通道中能存放元素的數量

我們可以使用內建的 len函式 獲取通道內元素的數量,使用 cap函式 獲取通道的容量

單向通道

通道預設是既可以讀有可以寫的,但是單向通道就是要麼只能讀,要麼只能寫

  • chan <- int

是一個只能傳送的通道,可以傳送但是不能接收

  • <- chan int

是一個只能接收的通道,可以接收但是不能傳送

如何建立和宣告一個通道

宣告通道

在 Go 裡面,channel是一種型別,預設就是一種引用型別

簡單解釋一下什麼是引用:

在我們寫C++的時候,用到引用會比較多

引用,顧名思義是某一個變數或物件的別名,對引用的操作與對其所繫結的變數或物件的操作完全等價

在C++裡面是這樣用的:

型別 &引用名=目標變數名;

宣告一個通道

var 變數名 chan 元素型別

var ch1 chan string               // 宣告一個傳遞字串資料的通道
var ch2 chan []int                 // 宣告一個傳遞int切片資料的通道
var ch3 chan bool                  // 宣告一個傳遞布林型資料的通道
var ch4 chan interface{}          // 宣告一個傳遞介面型別資料的通道

看,宣告一個通道就是這麼簡單

對於通道來說,關宣告瞭還不能使用,宣告的通道預設是其對應型別的零值,例如

  • int 型別 零值 就是 0
  • string 型別 零值就是個 空串
  • bool 型別 零值就是 false
  • 切片的 零值 就是 nil

我們還需要對通道進行初始化才可以正常使用通道哦

初始化通道

一般是使用 make 函式初始化之後才能使用通道,也可以直接使用make函式 建立通道

例如:

ch5 := make(chan string)
ch6 := make(chan []int)
ch7 := make(chan bool)
ch8 := make(chan interface{})

make 函式的第二個引數是可以設定緩衝的大小的,我們來看看原始碼的說明

// The make built-in function allocates and initializes an object of type
// slice, map, or chan (only). Like new, the first argument is a type, not a
// value. Unlike new, make's return type is the same as the type of its
// argument, not a pointer to it. The specification of the result depends on
// the type:
// Slice: The size specifies the length. The capacity of the slice is
// equal to its length. A second integer argument may be provided to
// specify a different capacity; it must be no smaller than the
// length. For example, make([]int, 0, 10) allocates an underlying array
// of size 10 and returns a slice of length 0 and capacity 10 that is
// backed by this underlying array.
// Map: An empty map is allocated with enough space to hold the
// specified number of elements. The size may be omitted, in which case
// a small starting size is allocated.
// Channel: The channel's buffer is initialized with the specified
// buffer capacity. If zero, or the size is omitted, the channel is
// unbuffered.
func make(t Type, size ...IntegerType) Type

如果 make 函式的第二個引數不填,那麼就預設是無緩衝的通道

現在我們來看看如何操作 channel 通道,都可以怎麼玩

如何操作 channel

通道的操作有如下三種操作:

  • 傳送(send)
  • 接收(receive)
  • 關閉(close)

對於傳送和接收通道里面的資料,寫法就比較形象,使用 <- 來指向是從通道里面讀取資料,還是從通道中傳送資料

向通道傳送資料

// 建立一個通道
ch := make(chan int)
// 傳送資料給通道
ch <- 1

我們看到箭頭的方向是,1 指向了 ch 通道,所以不難理解,這是將1 這個資料,放入通道中

從通道中接收資料

num := <-ch

不難看出,上述程式碼是 ch 指向了一個需要初始化的變數,也就是說,從 ch 中讀出一個資料,賦值給 num

我們從通道中讀出資料,也可以不進行賦值,直接忽略也是可以的,如:

<-ch

關閉通道

Go中提供了 close 函式來關閉通道

close(ch)

對於關閉通道非常需要注意,用不好直接導致程式崩潰

  • 只有在通知接收方 goroutine 協程所有的資料都傳送完畢的時候才需要關閉通道

  • 通道是可以被垃圾回收機制回收的,它和關閉檔案是不一樣的,在結束操作之後關閉檔案是必須要做的,但關閉通道不是必須的

關閉後的通道有以下 4 個特點:

  • 對一個關閉的通道再傳送值就會導致 panic
  • 對一個關閉的通道進行接收會一直獲取值直到通道為空
  • 對一個關閉的並且沒有值的通道執行接收操作會得到對應型別的零值
  • 關閉一個已經關閉的通道會導致 panic

通道異常情況梳理

我們來整理一下對於通道會存在的異常:

channel 狀態 未初始化的通道(nil) 通道非空 通道是空的 通道滿了 通道未滿
接收資料 阻塞 接收資料 阻塞 接收資料 接收資料
傳送資料 阻塞 傳送資料 傳送資料 阻塞 傳送資料
關閉 panic 關閉通道成功
待資料讀取完畢後
返回零值
關閉通道成功
直接返回零值
關閉通道成功
待資料讀取完畢後
返回零值
關閉通道成功
待資料讀取完畢後
返回零值

每一種通道的DEMO實戰

無緩衝通道

func main() {
   // 建立一個無緩衝的,資料型別 為 int 型別的通道
   ch := make(chan int)
   // 向通道中寫入 數字 1
   ch <- 1
   fmt.Println("send successfully ... ")
}

執行上述程式碼我們可以檢視到效果

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
        F:/my_channel/main.go:9 +0x45
exit status 2

出現上述報錯 deadlock 錯誤的原因,細心的小夥伴應該能夠知道為什麼,我上述有提到

我們使用 ch := make(chan int) 建立的是無緩衝的通道

無緩衝的通道只有在有接收方接收值的時候才能傳送資料成功

我們可以想一下我們生活中的案例一樣:

你在某東上買了一個稍微貴重一點的物品,某東快遞人員給你寄快遞的時候,打電話給你,必須要送到你的手上,不然不敢簽收,這個時候,你不方便,或者你不簽收,那麼這個快遞就是算作沒有寄送成功

因此,上述問題原因是,建立了一個無緩衝通道,傳送方一直在阻塞,通道中一直未有協程讀取資料,導致死鎖

我們的解決辦法就是建立另外一個協程,將資料從通道中讀出來即可

package main

import "fmt"

func recvData(c chan int) {
    ret := <-c
    fmt.Println("recvData successfully ... data = ", ret)
}

func main() {
    // 建立一個無緩衝的,資料型別 為 int 型別的通道
    ch := make(chan int)
    go recvData(ch)
    // 向通道中寫入 數字 1
    ch <- 1
    fmt.Println("send successfully ... ")
}

這裡需要注意,如果 go recvData(ch) 放在了 ch <- 1 之後,那麼結果還是一樣的死鎖,原因還是因為 ch <- 1 會一直阻塞,根本不會執行到 他之後的語句

實際效果

recvData successfully ... data =  1
send successfully ...

有緩衝通道

func main() {
   // 建立一個無緩衝的,資料型別 為 int 型別的通道
   ch := make(chan int , 1)
   // 向通道中寫入 數字 1
   ch <- 1
   fmt.Println("send successfully ... ")
}

還是同樣的案例,同樣的程式碼,我們只是把無緩衝通道,換成了有緩衝的通道, 我們仍然不專門開協程讀取通道的資料

實際效果 , 傳送成功
$$

$$

send successfully ...

因為此時通道中的緩衝是1,第一次向通道中傳送資料,不會阻塞,

可是如果,在通道中資料還未讀取出去之前,又向通道中寫入資料,則此處會阻塞,

若一直沒有協程從通道中讀取資料,則結果與上述一樣,會死鎖

單向通道

package main

import "fmt"

func OnlyWriteData(out chan<- int) {
   // 單向 通道 , 只寫 不能讀
   for i := 0; i < 10; i++ {
      out <- i
   }
   close(out)
}

func CalData(out chan<- int, in <-chan int) {
   // out 單向 通道 , 只寫 不能讀
   // int 單向 通道 , 只讀 不能寫

   // 遍歷 讀取in 通道,若 in通道 資料讀取完畢,則阻塞,若in 通道關閉,則退出迴圈
   for i := range in {
      out <- i + i
   }
   close(out)
}
func myPrinter(in <-chan int) {
   // 遍歷 讀取in 通道,若 in通道 資料讀取完畢,則阻塞,若in 通道關閉,則退出迴圈
   for i := range in {
      fmt.Println(i)
   }
}

func main() {
   // 建立2 個無緩衝的通道
   ch1 := make(chan int)
   ch2 := make(chan int)


   go OnlyWriteData(ch1)
   go CalData(ch2, ch1)


   myPrinter(ch2)
}

我們模擬 2 個通道,

  • 一個 只寫 不能讀
  • 一個 只讀 不能寫

實際效果

0
2
4
6
8
10
12
14
16
18

關閉通道

package main

import "fmt"

func main() {
   c := make(chan int)

   go func() {
      for i := 0; i < 10; i++ {
         // 迴圈向無緩衝的通道中寫入資料, 只有當上一個資料被讀走之後,下一個資料才能往通道中放
         c <- i
      }
      // 關閉通道
      close(c)
   }()
   for {
      // 讀取通道中的資料,若通道中無資料,則阻塞,若讀到 ok 為false, 則通道關閉,退出迴圈
      if data, ok := <-c; ok {
         fmt.Println(data)
      } else {
         break
      }
   }
   fmt.Println("channel over")
}

再次強調一下關閉通道,demo 的模擬方式與上述的案例基本一致,感興趣的可以自己執行看看效果

看到這裡,細心的小夥伴應該可以總結出,判斷通道是否關閉的 2種 方式了吧?

  • 讀取通道的時候,判斷bool型別的變數是否為false

例如上述程式碼

if data, ok := <-c; ok {
    fmt.Println(data)
} else {
    break
}

判斷 ok 為true,則正常讀取到資料, 若為false ,則通道關閉

  • 通過 for range 的方式來遍歷通道,若退出迴圈,則是因為通道關閉

sync 包

Go 的 sync 包也是用作實現併發任務的同步

還記得嗎,在分享 文章GO的鎖和原子操作分享的時候,我們就用到過 sync 包

用法大同訊息,這裡列舉一下 sync 包涉及的資料結構和方法

  • sync.WaitGroup
  • sync.Once
  • sync.Map

sync.WaitGroup

他是一個結構體,傳遞的時候要傳遞指標 ,這裡需要注意

他是併發安全的,內部有維護一個計數器

涉及的方法:

  • (wg * WaitGroup) Add(delta int)

引數中 傳入的 delta ,表示 sync.WaitGroup 內部的計數器 + delta

  • (wg *WaitGroup) Done()

表示當前協程退出,計數器 -1

  • (wg *WaitGroup) Wait()

等待併發任務執行完畢,此時的計數器為變成 0

sync.Once

他是併發安全的,內部有互斥鎖 和 一個布林型別的資料

  • 互斥鎖 用於加鎖解鎖
  • 布林型別的資料 用於記錄初始化是否完成

一般用於在高併發的場景下只執行一次,我們一下子就能想到的場景會有程式啟動時,載入配置檔案的場景

針對類似的場景,Go 也給我們提供瞭解決方法 ,即 sync.Once 裡面的 Do 方法

  • func (o *Once) Do(f func()) {}

Do 方法的引數 是一個函式,可是我們要在該函式裡面傳遞引數咋整?

可以使用Go 裡面的閉包來實現 , 閉包的具體實現方式,感興趣的可以深入瞭解一下

sync.Map

他是併發安全的,正是因為 Go 中的 map 是併發不安全的,因此有了 sync.Map

sync.Map 有如下幾個明顯的優勢:

  • 併發安全
  • sync.Map 不需要使用 make 初始化,直接使用 myMap := sync.Map{} 即可使用 sync.Map 裡面的方法

sync.Map 涉及的方法

見名知意

  • Store

存入 key 和value

  • Load

取出 某個key 對應的 value

  • LoadOrStore

取出 並且 存入 2個操作

  • Delete

刪除key 和 對應的 value

  • Range

遍歷所有key 和 對應的 value

總結

  • 通道是什麼,通道的種類
  • 無緩衝,有緩衝,單向通道具體對應什麼
  • 對於通道的具體實踐
  • 分享了關於通道的異常情況整理
  • 簡單分享了sync包的使用

歡迎點贊,關注,收藏

朋友們,你的支援和鼓勵,是我堅持分享,提高質量的動力

好了,本次就到這裡,下一次 服務註冊與發現之 ETCD

技術是開放的,我們的心態,更應是開放的。擁抱變化,向陽而生,努力向前行。

我是小魔童哪吒,歡迎點贊關注收藏,下次見~

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章