GO語言之channel
前言:
初識go語言不到半年,我是一次偶然的機會認識了golang這門語言,看到他簡潔的語法風格和強大的語言特性,瞬間有了學習他的興趣。我是很看好go這樣的語言的,一方面因為他有谷歌主推,另一方面他確實有用武之地,高併發就是他的長處。現在的國內完全使用go開發的專案還不是很多,從這個上面可以看到:連結https://github.com/qiniu/go/issues/15,據我瞭解七牛雲端儲存應該是第一個完全使用go開發的大型專案,其中七牛雲的CEO許世偉是公認的go專家,同時也是《go語言程式設計》的作者,另外美團、小米、360、新浪等公司或多或少都有go語言的使用。
這篇部落格寫的是go語言中的channel,之所以寫他是因為我感覺channel很重要,同時channel也是go併發的重要支撐點,因為go是使用訊息傳遞共享記憶體而不是使用共享記憶體來通訊。併發程式設計是非常好的,但是併發是非常複雜的,難點在於協調,怎樣處理各個程式間的通訊是非常重要的。寫channel的使用和特性之前我們需要回顧作業系統中的程序間的通訊。
程序間的通訊
在工程上一般通訊模型有兩種:共享資料和訊息。程序通訊顧名思義是指程序間的資訊交換,因為程序的互斥和同步就需要程序間交換資訊,學過作業系統的人都知道程序通訊大致上可以分為低階程序通訊和高階程序通訊,現在基本上都是高階程序通訊。其中高階通訊機制又可以分為:訊息傳遞系統、共享儲存器系統、管道通訊系統和客戶機伺服器系統。
1、訊息傳遞系統
他不借助任何共享儲存區或著某一種資料結構,他是以格式化的訊息為單位利用系統提供的通訊原語完成資料交換,感覺效率底下。
2、共享儲存器系統
通訊的程序共享儲存區或者資料結構,程序通過這些空間進行通訊,這種方式比較常見,比如某一個檔案作為載體。
3、客戶機伺服器系統
其他幾種通訊機制基本上都是在同一個計算機上(可以說是同一環境),當然在一些情況下可以實現跨計算機通訊。而客戶機-伺服器系統是不一樣的,我的理解是可以當做ip請求,一個客戶機請求連線到一臺伺服器。這種方式在網路上是現在比較流行的,現在比較常用的遠端排程,如不RPC(聽著很高大上,其實在作業系統上早就有了)還有套接字、socket,這種還是比較常用的,與我們程式設計緊密相關的,因為你會發現好多的服務需要使用RPC呼叫。
4、管道通訊系
最後詳細說一下管道通訊的機制,在作業系統級別管道是指用於連結一個讀程序和一個寫程序來實現他們之間通訊的檔案。系統上叫pipe檔案。實現的機制如:管道提供了下面的二個功能,1、互斥性,當一個程序正在對一個pipe檔案執行讀或者寫操作時,其他的程序必須等待或阻塞或睡眠。2、同步性,當寫(輸入)程序寫入pipe檔案後會等待或者阻塞或者睡眠,直到讀(輸出)程序取走資料後把他喚醒,同理,當讀程序去讀一個空的pipe檔案時也會等待或阻塞或睡眠,直到寫程序寫入pipe後把他喚醒。
channel的使用
好了,上面花了不少的篇幅寫了程序間通訊的幾種方式,我們再回過來看看channel,對應到go中的channel應該是第四種,go語言的channel是在語言級別提供的goroutine間通訊的方式。單獨說channel是沒有任何意義的,因為他和goroutine一起才有效果,我們先看看一般語言解決程式間共享記憶體的方法,下面是一段我們熟悉的程式,什麼也不會輸出,我剛學習的時候認為會輸出東西,但是實際不是這樣,當是感到一臉懵逼。
1 package main
2
3 import "fmt"
4
5 var counts int = 0
6
7 func Count() {
8 counts++
9 fmt.Println(counts)
10 }
11 func main() {
12
13 for i := 0; i < 3; i++ {
14 go Count()
15 }
16 }
學過go的人都應該知道原因,因為:Go程式從初始化main() 方法和package,然後執行main()函式,但是當main()函式返回時,程式就會退出,主程式並不等待其他goroutine的,導致沒有任何輸出。
我們看看常規語言是怎樣解決這種併發的問題的:
1 package main
2
3 import "fmt"
4 import "sync"
5 import "runtime"
6
7 var counts int = 0
8
9 func Count(lock *sync.Mutex) {
10 lock.Lock()
11 counts++
12 fmt.Println(counts)
13 lock.Unlock()
14 }
15 func main() {
16 lock := &sync.Mutex{}
17
18 for i := 0; i < 3; i++ {
19 go Count(lock)
20 }
21
22 for {
23 lock.Lock()
24 c := counts
25 lock.Unlock()
26
27 runtime.Gosched()
28
29 if c >= 3 {
30 break
31 }
32
33 }
34 }
解決方式有點逗比,加了一堆的鎖,因為他的執行是這樣的:程式碼中的lock變數,每次對counts的操作,都要先將他鎖住,操作完成後,再將鎖開啟,在主函式中,使用for迴圈來不斷檢查counter的值當然同樣也要加鎖。當其值達到3時,說明所有goroutine都執行完畢了,這時主函式返回,然後程式退出。
這種方式是大眾語言解決併發的首選方式,可以看到為了解決併發,多寫了好多的東西,如果一個初具規模的專案,不知道要加多少鎖。
我們看看channel是如何解決這種問題的:
1 package main
2
3 import "fmt"
4
5 var counts int = 0
6
7 func Count(i int, ch chan int) {
8 fmt.Println(i, "WriteStart")
9 ch <- 1
10 fmt.Println(i, "WriteEnd")
11 fmt.Println(i, "end", "and echo", i)
12 counts++
13 }
14
15 func main() {
16 chs := make([]chan int, 3)
17 for i := 0; i < 3; i++ {
18 chs[i] = make(chan int)
19 fmt.Println(i, "ForStart")
20 go Count(i, chs[i])
21 fmt.Println(i, "ForEnd")
22 }
23
24 fmt.Println("Start debug")
25 for num, ch := range chs {
26 fmt.Println(num, "ReadStart")
27 <-ch
28 fmt.Println(num, "ReadEnd")
29 }
30
31 fmt.Println("End")
32
33 //為了使每一步數值全部列印
34 for {
35 if counts == 3 {
36 break
37 }
38 }
39 }
為了看清goroutine執行的步驟和channel的特性,我特意在每一步都做了列印,下面是執行的結果,感興趣的同學可以自己試試,列印的順序可能不一樣:
下面我們分析一下這個流程,看看channel在裡面的作用。主程式開始:
列印 "0 ForStart 0 ForEnd" ,表示 i = 0 這個迴圈已經開始執行了,第一個goroutine已經開始;
列印 "1 ForStart"、"1 ForEnd"、"2 ForStart"、"2 ForEnd" 說明3次迴圈都開始,現在系統中存在3個goroutine;
列印 "Start debug",說明主程式繼續往下走了,
列印 "0 ReadStar"t ,說明主程式執行到for迴圈,開始遍歷chs,一開始遍歷第一個,但是因為此時 i = 0 的channel為空,所以該channel的Read操作阻塞;
列印 "2 WriteStart",說明第一個 i = 2 的goroutine先執行到Count方法,準備寫入channel,因為主程式讀取 i = 0 的channel的操作再阻塞中,所以 i = 2的channel的讀取操作沒有執行,現在i = 2 的goroutine 寫入channel後下面的操作阻塞;
列印 "0 WriteEnd",說明 i = 0 的goroutine也執行到Count方法,準備寫入channel,此時主程式 i = 0 的channel的讀取操作被喚醒;
列印 "0 WriteEnd" 和 "0 end and echo 0" 說明寫入成功;
列印 "0 ReadEnd",說明喚醒的 i = 0 的channel的讀取操作已經喚醒,並且讀取了這個channel的資料;
列印 "0 ReadEnd",說明這個讀取操作結束;
列印 "1 ReadStart",說明 i = 1 的channel讀取操作開始,因為i = 1 的channel沒有內容,這個讀取操作只能阻塞;
列印 "1 WriteStart",說明 i = 1 的goroutine 執行到Count方法,開始寫入channel 此時 i = 1的channel讀取操作被喚醒;
列印 "1 WriteEnd" 和 "1 end and echo 1" 說明 i = 1 的channel寫入操作完成;
列印 "1 ReadEnd",說明 i = 1 的讀取操作完成;
列印 "2 ReadStart",說明 i = 2 的channel的讀取操作開始,因為之前已經執行到 i = 2 的goroutine寫入channel操作,只是阻塞了,現在因為讀取操作的進行,i = 2的寫入操作流程繼續執行;
列印 "2 ReadEnd",說明 i = 2 的channel讀取操作完成;
列印 "End" 說明主程式結束。
此時可能你會有疑問,i = 2 的goroutine還沒有結束,主程式為啥就結束了,這正好印證了我們開始的時候說的,主程式是不等待非主程式完成的,所以按照正常的流程我們看不到 i = 2 的goroutine的的完全結束,這裡為了看到他的結束我特意加了一個 counts 計算器,只有等到計算器等於3的時候才結束主程式,接著就出現了列印 “2 WriteEnd” 和 “2 end and echo 2” 到此所有的程式結束,這就是goroutine在channel作用下的執行流程。
上面分析寫的的比較詳細,耐心看兩遍基本上就明白了,主要幫助大家理解channel的寫入阻塞和讀入阻塞的應用。