目錄
一、 Go的并發機制:線程模型
二、 Go并發編程初探:goroutine和channel
Go有兩種并發編程的選擇,一種是本篇介紹的goroutine,它是基于通信順序進程(CSP)的編發模式,另一種是傳統的通過共享內存訪問多線程的模式。
goroutine
goroutine是Go程序中最基本的組織單位,一個程序最少有一個goroutine,通常只需main()
函數的稱為主goroutine。這好像有點像傳統的線程,但是由上一篇我們已經知道,兩者是完全不同的。
在這里,可以直接將goroutine理解為上一篇GMP中的G,原理都已經介紹過,所以下面主要是記錄一些它的特性以及使用中比較容易忽略的地方。
先來一段最簡單的并發操作
func main() {
go func() {
fmt.Println("say hi")
}()
}
這是一個匿名函數,在函數前面加一個go就能開啟并發,上面的代碼正常來說會創建兩個goroutine,一個是主goroutine,另個一個是用于執行go后面的func代碼的goroutine,這個過程需要找到一個可用的空閑G并初始化,然后加入到P隊列,再找可用的M關聯(詳細流程翻閱上一篇),但是這一通操作還沒來得及,主goroutine已經結束了,所以上面代碼并不會輸出任何信息。
如果需要等待新的goroutine執行完打印語句在結束可以通過下面兩種方法:
func main() {
//方法1
c := make(chan struct{})
go func() {
fmt.Println("say hi 1")
c <- struct{}{}
}()
<-c
//方法2
var n sync.WaitGroup
n.Add(1)
go func() {
defer n.Done()
fmt.Printf("say hi 2")
}()
n.Wait()
}
輸出:
say hi 1
say hi 2
第一種用到通道(channel),在下面通過<-c
讀取通道的值造成阻塞,等到另一個goroutin執行完打印操作,通過c<-
往通道傳遞值,才能繼續。通道相關部分下面會介紹。第二種使用傳統共享內存訪問的方式,相信好多人看到n.wait()
也能猜出來,在Go語言中,sync包提供了傳統共享內存訪問的操作,waiGroup能使我們可以收集多個goroutine的結果,Add
類似一個計數器,而Done
會進行遞減,wait
會一直阻塞知道計數器為零。sync包還包含互斥鎖、讀寫鎖、條件變量等,這些都是共享內存訪問的并發方式,它們的含義和使用上其他語言這里不多,這里不打算介紹。
通道(channel)
通道是可以讓一個goroutine發送特定的值到另一個goroutine的通信機制。通道是可以指定傳遞某種類型的,例如一個int類型的元素通道:chain int
,創建一個通道可以和map一樣:ch := make(chan int)
。
通道是引用類型的,未初始化的時候值為零值nil,使用==
對通道進行比較時,如果都是同一通道的數據引用時值為true
。通道有三個最主要的操作:
ch <- x //發送值,寫
x := <- ch //接收值,讀
close(ch) //關閉通道
下面通過一個簡單的例子:一個goroutine通知另一個goroutine打印指定信息,來了解下通道的使用,同時對比一下其他語言使用多線程實現的區別:
func main() {
ch := make(chan string)
done := make(chan struct{})
//發送方
go func() {
ch <- "Golang"
}()
//接收方
go func() {
x := <-ch
fmt.Printf("Print: %s\n", x)
done <- struct{}{}
}()
<-done
}
首先創建了兩個通道,第一個ch
是字符串類型的元素通道,一個goroutine將需要打印的信息通過此通道,傳遞給另一個goroutine,第二個done
是一個struct{}
空結構體的通道,這種通道一般表示不用來傳遞任何實質的值,在這里的用途主要是告知主goroutine打印已經執行完成,這種不關心傳通訊遞值只是關心通訊本身的時間點的通道我們一般稱為事件(event)。
接著發送方將“Golang”字符串放入通道ch
,雖然兩個goroutine是并發,但是在發送方塞入值之前,接收方會一直阻塞在x := <-ch
這里,知道接收到通道ch
的值后,將其讀取,并打印出來。接著把一個空結構體放入done
通道,這里同理,主goroutine會一直阻塞在<-done
這里,等待接收方的通知來臨,最后整個程序結束。
通道是可以有方向的,上面兩種通道在使用上都是雙向的,即既可以讀(<-ch)也可以寫(ch<-),通道也是可以限制為單向的,但一般不會在創建的時候就指定是單向的,因為創建一個只進不出的通道顯示是沒有意義的,所以單向通道一般是通過雙向通道轉換而來,把上面的例子改造為單向通道:
func main() {
//創建的時候依然是雙向通道
ch := make(chan string)
done := make(chan struct{})
//發送方
//約束只能是單向的寫通道
go func(w chan<- string) {
w <- "Golang"
// <-w 嘗試讀取值,會報編譯錯誤
}(ch)
//接收方
//約束稚只能是單向的讀通道
go func(r <-chan string) {
x := <-r
fmt.Printf("Print: %s\n", x)
done <- struct{}{}
}(ch)
<-done
}
兩個goroutine函數的入參除聲明了單向的通道,如果嘗試在單向寫通道讀取值,編譯就不會通過,還有一點主意,close
關閉通道的操作也只能在寫通道處執行。既然雙向通道也可以正常使用,為什么要弄一個單向通道呢?這個問題我覺得和規范使用通道有關,也是在真正的項目使用上需要特別注意的,這部分放在下面再詳細介紹。
無緩沖通道
上面例子的代碼使用的都是無緩沖通道,前面也介紹過,如果一個goroutine在通道讀取值的時候,通道內沒有任何值,這時候goroutine就會一直阻塞。反過來,如果一個goroutine往通道寫入一個值,但是沒有其他goroutine去讀取,這時候如果繼續往通道寫值也會一直阻塞。無緩沖通道使通訊的雙方變得同步化,所以有時候也叫做同步通道。
緩存通道
緩沖通道內部維護一個隊列,隊列的最大長度在創建的時候通過make
的容量來設置
//創建一個容量為5的緩沖通道
ch := make(chan string,5)
make(chan string,0) 等價于 make(chan string)
既然內部是一個隊列,所以通道的值無論是寫入還是讀取,都是有序的,遵循FIFO的規則。除此之外,對通道進行寫入和讀取的操作也是有序的,下面再看一個例子:
func main() {
ch := make(chan string, 3)
var n sync.WaitGroup
n.Add(3)
go func() {
time.Sleep(1 * time.Second)
ch <- "Golang"
ch <- "Golang"
ch <- "Golang"
}()
go func() {
time.Sleep(3 * time.Second)
x := <-ch
fmt.Printf("A sleep 2sec and print: %s\n", x)
n.Done()
}()
go func() {
time.Sleep(3 * time.Second)
x := <-ch
fmt.Printf("B sleep 3sec and print: %s\n", x)
n.Done()
}()
go func() {
time.Sleep(1 * time.Second)
x := <-ch
fmt.Printf("C sleep 1sec and print: %s\n", x)
n.Done()
}()
n.Wait()
}
輸出:
C sleep 1sec and print: Golang
A sleep 2sec and print: Golang
B sleep 3sec and print: Golang
創建一個容量為3的緩沖通道,并一次寫入3個值,下面新開3個goroutine分別讀取通道的值,注意看每個函數里面都有睡眠時間,最后的輸出結果會按照睡眠時間的升序排序。
無緩沖通道的值是一次性寫入的,也就是說它不用關系有沒有goroutine正在讀取,當寫入的值數量達到了最大值,如果繼續嘗試寫入,這時候就會阻塞。
剛開始接觸通道的時候,可能大家都會和我一樣覺得它和隊列(queue)十分相識,但是官方提示千萬不要把它作為隊列來使用,因為它還和goroutine有深度的關聯,這樣使用反而會變得復雜。
關閉通道(close)
通道的第三種操作。只有那些已創建并正在打開的通道才能關閉,嘗試再未初始化、已關閉、只讀的通道進行關閉都是不允許的。還有一點,當一個通道關閉的時候,會通知(實際是喚醒)到所有讀取通道的goroutine:
func main() {
ch := make(chan string, 3)
var n sync.WaitGroup
n.Add(3)
go func() {
time.Sleep(1 * time.Second)
close(ch)
}()
go func() {
x := <-ch
fmt.Printf("A print: %s\n", x)
n.Done()
}()
go func() {
x := <-ch
fmt.Printf("B print: %s\n", x)
n.Done()
}()
go func() {
x := <-ch
fmt.Printf("C print: %s\n", x)
n.Done()
}()
n.Wait()
}
輸出:
B print:
C print:
A print:
上面代碼有3個goroutine在讀取通道的值,第一個goroutine在睡眠1秒后關閉通道,這時候控制臺即刻輸出結果并結束程序。我們并沒有往通道寫入任何值,但是讀取值的goroutine仍然被喚醒,最后讀取的是個空字符串,因為string
是一個值類型,默認值就是空字符串。
循環讀取通道的值
說到循環,岔開一下,在Go語言中,循環是通過for
來完成的,沒有其他語言的while和foreach,任何你想要循環的邏輯都可以通過一個for
來完成,事實上簡單易用也是Go追求的。從循環只有for
,沒有++i
這種表達也可以看出,開發者們的確是想砍掉那些可有可無的,留下的都是必須要的。我覺得這種想法是很好的,特別是對于一個初學者,進來一下用for一下用while,會感到迷茫。
說回來,如果想要對一個無緩沖通道依次讀取值,也可以使用for
,通過range
關鍵字作為參數遍歷,它會在通道關閉時自動中斷循環:
func main() {
ch := make(chan string, 5)
go func() {
defer close(ch)
for i := 0; i < 5; i++ {
ch <- strconv.Itoa(i)
}
}()
for i := range ch {
fmt.Println(i)
}
}
輸出:
0
1
2
3
4
這里在主goroutine循環讀取通道的值,for依次讀取了通道的值。在寫入通道的goroutine寫入5個值,最后關閉通道defer close(ch)
,這一句是必須的,如果不關閉通道,for會一直嘗試循環讀取值而導致阻塞,最終結果就是死鎖。
select多路復用
select語句是一種只能用于通道接收和發送操作的專用語句,他的用法與switch有點相似
select {
case <-ch:
//讀取通道值
case x := <-ch:
//讀取通道值,并賦值給x
case ch <- "s":
//通道寫入值:s
default:
//所有case語句都不符合條件
}
上面展示了select語句一般的使用場景,和switch一樣它有多個條件和一個默認的分支(可選)。上面3個case分別代表了3中情況,當執行select語句的時候,運行時并不急于值判斷,而是先求所有case語句的值,例如先求得case getTopNum()
,這時候也知道哪個case是符合條條件的。接著判斷:如果有一個case符合條件,執行case分支下的代碼,拋棄其他case;如果有多個case符合條件,通過偽隨機算法選擇一個case執行,并拋棄其他case;如果沒有任何符合條件的case,執行default的代碼,如果這種情況下沒有設置default分支,則會阻塞而不是終止;
for-select循環
select語句只會執行一輪,無論是有符合條件還是沒有都是,就算是阻塞,也只是延長一輪的結束時間,由于select是用在通道這種特殊的場景,所有一般會結合for
循環來使用,達到一種類似監聽的效果:
func main() {
ch := make(chan string, 5)
go func() {
defer close(ch)
for i := 0; i < 5; i++ {
ch <- strconv.Itoa(i)
}
}()
loop:
for {
select {
case v, ok := <-ch:
if v == "2" {
fmt.Printf("value:%s ok:%v \n", v, ok)
}
if !ok {
fmt.Printf("channel closed %v \n", ok)
break loop
}
}
}
}
上面展示了for和select結合使用的例子,一開始新啟動的goroutine會往通道寫入5個值,select的case分支讀取通道的值,因為通道是有序的,第一個讀取的是“0”,這時候由于有v=="2"
的判斷,所以不會進入代碼塊,繼續往下走,到了判斷ok
值的分支,當通道的值已被全部取出并關閉時,此值為false。所以第一輪ok的值肯定是true的,這時候對于select語句來說,已經結束了,但是我們再select外面包了一層for循環,這時候會讓select再執行一次。注意這里使用了帶標簽(loop)的break語句,當前最后執行到break loop
的時候,會直接終止for循環。
在前面對于select語句有個關于多個case分支符號條件的執行結果,可能大家會有疑問,為什么會偽隨機執行而不是順序去執行?我認為是這樣:Go預約的select語句是特殊的,只能和通道一起使用,而通道是并發中goroutine通訊的管道,多個通道頻繁傳遞值肯定是常有的,想象一下,如果有多個case分支,每一個都正在監控著不同的通道,每個通道都正在不停的進行通訊(寫->讀),這時候如果是順序執行,可能永遠都只執行第一個case,其他case被拋棄。這種情況下,最好的方法只能是通過偽隨機,是每一個case分支都有運行的機會。
通道的使用規范
前面還留下一個問題:為什么還需要有單向通道的存在?這里也說下我的理解,在這之前,先列一個通道操作以及對應的狀態:通道有三種操作且有多種狀態,在每某些狀態下對通道進行一些操作,可能會導致發生一些不可預測的問題,例如panic、死鎖,非期望的阻塞的呢過。而通道本身與普通的變量并無不同,可以函數間進行傳遞,想象如果在一個只是需要讀取值的函數,不小心對通道進行了關閉操作。
上面列表出現了好幾種死鎖和panic的操作,應該盡量避免這種情況:
- 正確的分配通道的所有權,這里所有權定義為實例化、寫入和關閉通道。要弄清楚哪個goroutine擁有通道。在這里,單向通道的作用就提現出來了,它將允許我們區分通道的擁有者和使用者。擁有者有一個(chan或<-chan)權限,使用者只有(chan<-)權限。一旦我們將擁有者和使用者區分,上面表的結果就會很清晰。
- 盡量保持通道所有權的范圍很小,避免將一個通道作為結構體的公開成員變量。
總結
總得來說,Go語言提供goroutine和sync同步包支持并發,Go的設計者們更愿意推薦使用goroutine和通道的方式,這是基于CSP(communicating sequential process)的設計,相比使用sync優點是更方便優雅的編寫并發程序,但傳統的共享內存是最接近硬件的通信方式,性能也更好。當然,在真正的項目上,肯定也不會只局限與一種,可能也需要兩者結合使用。