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 才有。
sendx
, recvx
均指向底層循環數組,表示當前可以發送和接收的元素位置索引值(相對于底層數組)。
sendq
, recvq
分別表示被阻塞的 goroutine,這些 goroutine 由于嘗試讀取 channel 或向 channel 發送數據而被阻塞。
waitq
是 sudog
的一個雙向鏈表,而 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 的使用,有幾點不方便的地方:
- 在不改變 channel 自身狀態的情況下,無法獲知一個 channel 是否關閉。
- 關閉一個 closed channel 會導致 panic。所以,如果關閉 channel 的一方在不知道 channel 是否處于關閉狀態時就去貿然關閉 channel 是很危險的事情。
- 向一個 closed channel 發送數據會導致 panic。所以,如果向 channel 發送數據的一方不知道 channel 是否處于關閉狀態時就去貿然向 channel 發送數據是很危險的事情。
應該如何優雅地關閉 channel?
根據 sender 和 receiver 的個數,分下面幾種情況:
- 一個 sender,一個 receiver
- 一個 sender, M 個 receiver
- N 個 sender,一個 reciver
- 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 來保證。