Go 併發 -- 通道

Seekload發表於2019-04-22

這是『就要學習 Go 語言』系列的第 22 篇分享文章

上篇文章講了關於協程的一些用法,比如如何建立協程、匿名協程等。這篇文章我們來講講通道。 通道是協程之間通訊的管道,從一端傳送資料,另一端接收資料。

通道宣告

使用通道之前需要宣告,有兩種方式:

var c chan int  		// 方式一
c := make(chan int)		// 方式二
複製程式碼

使用關鍵字 chan 建立通道,宣告時有型別,表明通道只允許該型別的資料傳輸。通道的零值為 nil。方式一就宣告瞭 nil 通道。nil 通道沒什麼作用,既不能傳送資料也不能接受資料。方式二使用 make 函式建立了可用的通道 c。

func main() {
	c := make(chan int)
	fmt.Printf("c Type is %T\n",c)
	fmt.Printf("c Value is %v\n",c)
}
複製程式碼

輸出:

c Type is chan int
c Value is 0xc000060060
複製程式碼

上面的程式碼建立了通道 c,而且只允許 int 型資料傳輸。一般將通道作為引數傳遞給函式或者方法實現兩個協程之間通訊,有沒有注意到通道 c 的值是一個地址,傳參的時候直接使用 c 的值就可以,而不用取址。

通道的使用

讀寫資料

Go 提供了語法方便我們操作通道:

c := make(chan int)
// 寫資料
c <- data   

// 讀資料
variable <- c  // 方式一
<- c  			// 方式二
複製程式碼

讀寫資料注意通道的位置,通道在箭頭的左邊是寫資料,在右邊是從通道讀資料。上面的方式二讀資料是合理的,讀出來的資料丟棄不使用。 注意:通道操作預設是阻塞的,往通道里寫資料之後當前協程便阻塞,直到其他協程將資料讀出。一個協程被通道操作阻塞後,Go 排程器會去呼叫其他可用的協程,這樣程式就不會一直阻塞。通道的這種特性非常有用,接下來我們就可以看到。 我們來溫習下上篇文章的一個例子:

func printHello() {
	fmt.Println("hello world goroutine")
}

func main() {
	go printHello()
	time.Sleep(1*time.Second)
	fmt.Println("main goroutine")
}
複製程式碼

這個例子 main() 協程使用了 time.Sleep() 函式休眠了 1s 等待 printHello() 執行完成。很黑科技,在生產環境絕對不可以這樣用。我們使用通道修改下:

func printHello(c chan bool) {
	fmt.Println("hello world goroutine")
	<- c    // 讀取通道的資料
}

func main() {
	c := make(chan bool)
	go printHello(c)
	c <- true    // main 協程阻塞
	fmt.Println("main goroutine")
}
複製程式碼

輸出:

hello world goroutine
main goroutine
複製程式碼

上面的例子,main 協程建立完 printHello 協程之後,第 8 行往通道 c 寫資料,main 協程阻塞,Go 排程器排程可使用 printHello 協程,從通道 c 讀出資料,main 協程接觸阻塞繼續執行。注意:讀取操作沒有阻塞是因為通道 c 已有可讀的資料,否則,讀取操作會阻塞。

死鎖

前面提到過,讀/寫資料的時候通道會阻塞,排程器會去排程其他可用的協程。問題來了,如果沒有其他可用的協程會發生什麼情況?沒錯,就會發生著名的死鎖。最簡單的情況就是,只往通道寫資料。

func main() {
	c := make(chan bool)
	c <- true    // 只寫不讀
	fmt.Println("main goroutine")
}
複製程式碼

報錯:

fatal error: all goroutines are asleep - deadlock!
複製程式碼

同理,只讀不寫也會報同樣的錯誤。

關閉通道與 for loop

傳送資料的通道有能力選擇關閉通道,資料就不能傳輸。資料接收的時候可以返回一個狀態判斷該通道是否關閉:

val, ok := <- channel
複製程式碼

val 是接收的值,ok 標識通道是否關閉。為 true 的話,該通道還可以進行讀寫操作;為 false 則標識通道關閉,資料不能傳輸。 使用內建函式 close() 關閉通道。

func printNums(ch chan int) {
	for i := 0; i < 4; i++ {
		ch <- i
	}
	close(ch)
}

func main() {
	ch := make(chan int)
	go printNums(ch)
	for {
		v, ok := <-ch
		if ok == false {     // 通過 ok 判斷通道是否關閉
			fmt.Println(v, ok)
			break
		}
		fmt.Println(v, ok)
	}
}
複製程式碼

輸出:

0 true
1 true
2 true
3 true
0 false
複製程式碼

printNums 協程寫完資料之後關閉了通道,在 main 協程裡對 ok 判斷,若為 false 說明通道關閉,退出 for 循壞。從關閉的通道讀出來的值是對應型別的零值,上面最後一行的輸出值是 int 型別的零值 0。

使用 for 迴圈,需要手動判斷通道有沒有關閉。如果嫌煩的話,那就使用 for range 讀取通道吧,通道關閉,for range 自動退出。

func printNums(ch chan int) {
	for i := 0; i < 4; i++ {
		ch <- i
	}
	close(ch)
}

func main() {
	ch := make(chan int)
	go printNums(ch)

	for v := range ch {
		fmt.Println(v)
	}
}
複製程式碼

輸出:

0
1
2
3
複製程式碼

提一點,使用 for range 一個通道,傳送完畢之後必須 close() 通道,不然發生死鎖。

緩衝通道和通道容量

之前建立的通道是無緩衝的,讀寫通道會立馬阻塞當前協程。對於緩衝通道,寫不會阻塞當前通道直到通道滿了,同理,讀操作也不會阻塞當前通道除非通道沒資料。建立帶緩衝的通道:

ch := make(chan type, capacity)  
複製程式碼

capacity 是緩衝大小,必須大於 0。 內建函式 len()、cap() 可以計算通道的長度和容量。

func main() {
	ch := make(chan int,3)

	ch <- 7
	ch <- 8
	ch <- 9
	//ch <- 10    
	// 註釋開啟的話,協程阻塞,發生死鎖
	會發生死鎖:通道已滿且沒有其他可用通道讀取資料

	fmt.Println("main stopped")
}
複製程式碼

輸出:main stopped 建立了緩衝為 3 的通道,寫入 3 個資料時通道不會阻塞。如果將第 7 行程式碼註釋開啟的話,此時通道已滿,協程阻塞,又沒有其他可用協程讀資料,便發生死鎖。 再來看個例子:

func printNums(ch chan int) {

	ch <- 7
	ch <- 8
	ch <- 9
	fmt.Printf("channel len:%d,capacity:%d\n",len(ch),cap(ch))
	fmt.Println("blocking...")
	ch <- 10   // 阻塞
	close(ch)
}

func main() {
	ch := make(chan int,3)
	go printNums(ch)

	// 休眠 2s
	time.Sleep(2*time.Second)
	for v := range ch {
		fmt.Println(v)
	}

	fmt.Println("main stopped")
}
複製程式碼

輸出:

channel len:3,capacity:3
blocking...
7
8
9
10
main stopped
複製程式碼

休眠 2s 的目的是讓通道寫滿資料發生阻塞,從列印結果可以看出。2s 之後,主協程從通道讀取資料,通道容量有餘阻塞便解除,繼續寫資料。

如果緩衝通道是關閉狀態但有資料,仍然可以讀取資料:

func main() {
	ch := make(chan int,3)
	
	ch <- 7
	ch <- 8
	//ch <- 9
	close(ch)

	for v := range ch {
		fmt.Println(v)
	}

	fmt.Println("main stopped")
}
複製程式碼

輸出:

7
8
main stopped
複製程式碼

單向通道

之前建立的都是雙向通道,既能傳送資料也能接收資料。我們還可以建立單向通道,只傳送或者只接收資料。 語法:

sch := make(chan<- int)
rch := make(<-chan int) 
複製程式碼

sch 是隻傳送通道,rch 是隻接受通道。 這種單向通道有什麼用呢?我們總不能只發不接或只接不發吧。這種通道主要用在通道作為引數傳遞的時候,Go 提供了自動轉化,雙向轉單向。 重寫之前的例子:

func printNums(ch chan<- int) {
	for i := 0; i < 4; i++ {
		ch <- i
	}
	close(ch)
}

func main() {
	ch := make(chan int)
	go printNums(ch)

	for v := range ch {
		fmt.Println(v)
	}
}
複製程式碼

輸出:

0
1
2
3
複製程式碼

main 協程中 ch 是一個雙向通道,printNums() 在接收引數的時候將 ch 自動轉成了單向通道,只發不收。但在 main 協程中,ch 仍然可以接收資料。使用單向通道主要是可以提高程式的型別安全性,程式不容易出錯。

通道資料型別

通道是一類值,類似於 int、string 等,可以像其他值一樣在任何地方使用,比如作為結構體成員、函式引數、函式返回值,甚至作為另一個通道的型別。我們來看下使用通道作為另一個通道的資料型別

func printWord(ch chan string) {
	fmt.Println("Hello " + <-ch)
}

func productCh(ch chan chan string)  {
	c := make(chan string)   // 建立 string type 通道
	ch <- c     // 傳輸通道
}

func main() {

	// 建立 chan string 型別的通道
	ch := make(chan chan string)
	go productCh(ch)
	// c 是 string type 的通道
	c := <-ch
	go printWord(c)
	c <- "world"
	fmt.Println("main stopped")
}
複製程式碼

輸出:

Hello world
main stopped
複製程式碼

希望這篇文章能夠幫助你,Good day!


(全文完)

原創文章,若需轉載請註明出處!
歡迎掃碼關注公眾號「Golang來啦」或者移步 seekload.net ,檢視更多精彩文章。

給你準備了學習 Go 語言相關書籍,公號後臺回覆【電子書】領取!

公眾號二維碼

相關文章