一.設計原理
Go 語言中最常見的、也是經常被人提及的設計模式就是:
"不要通過共享記憶體來通訊,我們應該使用通訊來共享記憶體"
通過共享記憶體來通訊是直接讀取記憶體的資料,而通過通訊來共享記憶體,是通過傳送訊息的方式來進行同步。
而通過傳送訊息來同步的這種方式常見的就是 Go 採用的通訊順序程式 CSP(Communication Sequential Process) 模型以及 Erlang 採用的 Actor 模型,這兩種方式都是通過通訊來共享記憶體。
如下圖所示
大部分的語言採用的都是第一種方式直接去操作記憶體,然後通過互斥鎖,CAS 等操作來保證併發安全。Go 引入了 Channel 和 Goroutine 實現 CSP 模型來解耦這個操作。
-
優點:
- 在 Goroutine 當中我們就不用手動去做資源的鎖定與釋放,同時將生產者和消費者進行了解耦,Channel 其實和訊息佇列很相似。
-
缺點:
- 由於 Channel 底層也是通過這些低階的同步原語實現的,所以效能上會差一些,如果有極高的效能要求時也可以用 sync 包中提供的低階同步原語
先入先出
目前的 Channel 收發操作均遵循了先進先出的設計,具體規則如下:
- 先從 Channel 讀取資料的 Goroutine 會先接收到資料;
- 先向 Channel 傳送資料的 Goroutine 會得到先傳送資料的權利;
無鎖管道
鎖(Lock) 是一種常見的併發控制技術,我們一般會將鎖分成樂觀鎖 和 悲觀鎖,即樂觀併發控制和悲觀併發控制,無鎖(lock-free)佇列更準確的描述是使用樂觀併發控制的佇列。樂觀併發控制也叫樂觀鎖,很多人都會誤以為樂觀鎖是與悲觀鎖差不多,然而它並不是真正的鎖,只是一種併發控制的思想.
樂觀併發控制本質上是基於驗證的協議,我們使用原子指令 CAS(compare-and-swap 或者 compare-and-set)在多執行緒中同步資料,無鎖佇列的實現也依賴這一原子指令。
從某種程度上說,Channel 是一個用於同步和通訊的有鎖佇列,使用互斥鎖解決程式中可能存在的執行緒競爭問題
Go 語言社群也在 2014 年提出了無鎖 Channel 的實現方案,該方案將 Channel 分成了以下三種型別:
-
同步 Channel — 無緩衝區,傳送方會直接將資料交給(Handoff)接收方
-
非同步channel: 基於環形快取的傳統生產者消費者模型;
-
chan struct{} 型別的非同步 Channel — struct{} 型別不佔用記憶體空間,不需要實現緩衝區和直接傳送(Handoff)的語義;
二.資料結構
Go 語言的 Channel 在執行時使用 runtime.hchan 結構體表示。我們在 Go 語言中建立新的 Channel 時,實際上建立的都是如下所示的結構:
type hchan struct {
qcount uint // 佇列中元素總數量
dataqsiz uint // 迴圈佇列的長度
buf unsafe.Pointer // 指向長度為 dataqsiz 的底層陣列,只有在有緩衝時這個才有意義
elemsize uint16 // 能夠傳送和接受的元素大小
closed uint32 // 是否關閉
elemtype *_type // 元素的型別
sendx uint // 當前已傳送的元素在佇列當中的索引位置
recvx uint // 當前已接收的元素在佇列當中的索引位置
recvq waitq // 接收 Goroutine 連結串列
sendq waitq // 傳送 Goroutine 連結串列
lock mutex // 互斥鎖
}
// waitq 是一個雙向連結串列,裡面儲存了 goroutine
type waitq struct {
first *sudog
last *sudog
}
如下圖所示,channel 底層其實是一個迴圈佇列
三.建立管道
Go 語言中所有 Channel 的建立都會使用 make 關鍵字。建立的表示式使用 make(chan T, cap)
來建立 channel.
如果不向 make 傳遞表示緩衝區大小的引數,那麼就會設定一個預設值 0,也就是當前的 Channel 不存在緩衝區。
四. 傳送資料
當想要向 Channel
傳送資料時,就需要使用 ch <- i
語句.
在傳送資料的邏輯執行之前會先為當前 Channel 加鎖,防止多個執行緒併發修改資料。
如果 Channel 已經關閉,那麼向該 Channel 傳送資料時會報 “send on closed channel” 錯誤並中止程式。
4.1 直接傳送
如果 Channel 沒有被關閉並且已經有處於讀等待的 Goroutine,會取出最先陷入等待的 Goroutine 並直接向它傳送資料:
直接傳送的過程稱為兩個部分:
- 呼叫
runtime.sendDirect
將傳送的資料直接拷貝到 x = <-c 表示式中變數 x 所在的記憶體地址上; - 呼叫
runtime.goready
將等待接收資料的 Goroutine 標記成可執行狀態 Grunnable 並把該 Goroutine 放到傳送方所在的處理器的 runnext 上等待執行,該處理器在下一次排程時會立刻喚醒資料的接收方;
需要注意的是,傳送資料的過程只是將接收方的 Goroutine 放到了處理器的 runnext 中,程式沒有立刻執行該 Goroutine。
4.2 緩衝區
如果建立的 Channel 包含緩衝區並且 Channel 中的資料沒有裝滿,會使用 runtime.chanbuf
計算出下一個可以儲存資料的位置,然後通過 runtime.typedmemmove
將傳送的資料拷貝到緩衝區中並增加 sendx 索引和 qcount 計數器。
4.3 阻塞傳送
當 Channel 沒有接收者能夠處理資料時,向 Channel 傳送資料會被下游阻塞,當然使用 select 關鍵字可以向 Channel 非阻塞地傳送訊息。
4.4 小結
可以簡單梳理和總結一下使用 ch <- i
表示式向 Channel 傳送資料時遇到的幾種情況:
- 如果當前 Channel 的 recvq 上存在已經被阻塞的 Goroutine,那麼會直接將資料傳送給當前 Goroutine 並將其設定成下一個執行的 Goroutine;
- 如果 Channel 存在緩衝區並且其中還有空閒的容量,我們會直接將資料儲存到緩衝區 sendx 所在的位置上;
- 如果不滿足上面的兩種情況,當前 Goroutine 也會陷入阻塞等待其他的協程從 Channel 接收資料;
五. 接收資料
可以使用兩種不同的方式去接收 Channel 中的資料:
i <- ch
i, ok <- ch
5.1 直接接收
會根據緩衝區的大小分別處理不同的情況
- 如果 Channel 不存在緩衝區,直接從傳送者那裡把資料拷貝給接收變數
- 如果是有緩衝 channel
- 將佇列中的資料拷貝到接收方的記憶體地址;
- 將傳送佇列頭的資料拷貝到緩衝區中,釋放一個阻塞的傳送方;
5.2 緩衝區
當 Channel 的緩衝區中已經包含資料時,從 Channel 中接收資料會直接從緩衝區中 的索引位置中取出資料進行處理:
5.3 阻塞接收
當 Channel 的傳送佇列中不存在等待的 Goroutine 並且緩衝區中也不存在任何資料時,從管道中接收資料的操作會變成阻塞的,然而不是所有的接收操作都是阻塞的,與 select 語句結合使用時就可能會使用到非阻塞的接收操作:
六. 關閉channel
使用 close(ch) 來關閉 channel 最後會呼叫 runtime 中的 closechan 方法.
- 關閉一個 nil 的 channel 和已關閉了的 channel 都會導致 panic
- 關閉 channel 後會釋放所有因為 channel 而阻塞的 Goroutine
七. 使用場景
channel一般用於協程之間的通訊,channel也可以用於併發控制。比如主協程啟動N個子協程,主協程等待所有子協程退出後再繼續後續流程,這種場景下channel也可輕易實現。
7.1 使用channel控制子協程
package main
import (
"time"
"fmt"
)
func Process(ch chan int) {
//Do some work...
time.Sleep(time.Second)
ch <- 1 //管道中寫入一個元素表示當前協程已結束
}
func main() {
channels := make([]chan int, 10) //建立一個10個元素的切片,元素型別為channel
for i:= 0; i < 10; i++ {
channels[i] = make(chan int) //切片中放入一個channel
go Process(channels[i]) //啟動協程,傳一個管道用於通訊
}
for i, ch := range channels { //遍歷切片,等待子協程結束
<-ch
fmt.Println("Routine ", i, " quit!")
}
}
輸出:
Routine 0 quit!
Routine 1 quit!
Routine 2 quit!
Routine 3 quit!
Routine 4 quit!
Routine 5 quit!
Routine 6 quit!
Routine 7 quit!
Routine 8 quit!
Routine 9 quit!
上面程式通過建立N個channel來管理N個協程,每個協程都有一個channel用於跟父協程通訊,父協程建立完所有協程後等待所有協程結束。
這個例子中,父協程僅僅是等待子協程結束,其實父協程也可以向管道中寫入資料通知子協程結束,這時子協程需要定期地探測管道中是否有訊息出現。
7.2 通過關閉 channel 實現一對多的通知
關閉 channel 時會釋放所有阻塞的 Goroutine,所以我們就可以利用這個特性來做一對多的通知,除了一對多之外我們還用了 done 做了多對一的通知,當然多對一這種情況還是建議直接使用 WaitGroup 即可
package main
import (
"fmt"
"time"
)
func run(stop <-chan struct{}, done chan<- struct{}) {
// 每一秒列印一次
for {
select {
case <-stop:
fmt.Println("stop...")
// 接收到停止後,向 done 管道中傳送資料,然後退出函式
done <- struct{}{}
return
// 超時1秒將輸出hello
case <-time.After(time.Second):
fmt.Println("hello...")
}
}
}
func main() {
// 一對多,使用無緩衝通道,當關閉chan後,其他程式中接收到關閉訊號後會統一執行操作
stop := make(chan struct{})
// 多對一,當關閉後,關閉一個chan, 寫入一個資料到管道中
done := make(chan struct{}, 10)
for i := 0; i < 10; i++ {
go run(stop, done)
}
// 模擬超時時間
time.Sleep(5 * time.Second)
close(stop)
for i := 0; i < 10; i++ {
<-done
}
}
輸出:
hello...
hello...
hello...
...
hello..
stop...
stop...
stop...
stop...
stop...
stop...
stop...
stop...
stop...
stop...
7.3 使用 channel 做非同步程式設計
利用無緩衝channel,接收早於傳送的特點,只有當資料寫入後,接收才能完成實現資料一致性
package main
import (
"fmt"
)
// 這裡只能讀
func read(c <-chan int) {
fmt.Println("read:", <-c)
}
// 這裡只能寫
func write(c chan<- int) {
c <- 0
}
func main() {
c := make(chan int)
go write(c)
read(c)
}
7.4 超時控制
超時控制還是建議使用 context
func run(stop <-chan struct{}, done chan<- struct{}) {
// 每一秒列印一次 hello
for {
select {
case <-stop:
fmt.Println("stop...")
done <- struct{}{}
return
case <-time.After(time.Second):
fmt.Println("hello")
}
}
}
7.5 協程池
根據控制Channel的快取大小來控制併發執行的Goroutine的最大數目
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func() {
limit <- 1
w()
<-limit
}()
}
select{}
}
最後一句select{}是一個空的管道選擇語句,該語句會導致main執行緒阻塞,從而避免程式過早退出。還有for{}
、<-make(chan int)
等諸多方法可以達到類似的效果。因為main執行緒被阻塞了,如果需要程式正常退出的話可以通過呼叫os.Exit(0)實現。