Go Channel(收藏以備面試)
Hi,我是行舟,今天和大家一起學習 Go 語言的 Channel。
Go 語言採用 CSP 模型,讓兩個獨立執行的程序通過消息傳遞的方式共享內存,Channel 就是 Golang 用來完成消息通訊的數據類型。
Go 語言中,仍然可以使用共享內存的方式在多個協程間共享數據,只不過不推薦使用。
聲明 Channel
聲明一個通道
var Channel類型 = chan 元素類型
除了上面的聲明方式,還可以在 chan 的左右添加 <- 符號,分別表示只讀通道和只寫通道。
看幾個實際的例子:
package main
import "fmt"
func main() {
var c1 chan int // 可讀寫的通道
var c2 chan<- float64 // 只寫通道
var c3 <-chan int // 只讀通道
fmt.Printf("c1=%+v \n",c1)
fmt.Printf("c2=%+v \n",c2)
fmt.Printf("c3=%+v \n",c3)
}
只聲明未初始化的通道值是 nil,需要初始化之後纔會分配存儲空間,通道初始化使用 make 方法。make 方法的第二個參數定義了通道可以緩衝參數的個數。
c1 := make(chan int) // 初始化無緩衝的通道
c2 := make(chan float64,10) // 初始化可以緩衝10個元素的通道
fmt.Printf("c1=%+v \n",c1)
fmt.Printf("c2=%+v \n",c2)
如上面代碼 c1 這種沒有緩衝空間的通道,我們稱爲無緩衝通道;c2 稱爲有緩衝通道。
基本用法
寫入和讀取數據
我們執行下面的代碼
c1 := make(chan int , 10) // 初始化可以緩衝10個元素的通道
c1 <-1
c1 <-2
初始化通道 c1,並寫入數據。
看下一個例子
c1 := make(chan int , 10) // 初始化可以緩衝10個元素的通道
c2 := make(chan float64) // 初始化無緩衝通道
c1 <- 1 // 往通道c1寫值
c2 <- 1.01 // 往通道c2寫值,會報錯
此時將會看到這樣的報錯:
fatal error: all goroutines are asleep - deadlock!
這是因爲我們往 c2 這個無緩衝通道中,寫入數據, 而 c2 沒有讀操作。我們加一行對 c2 的讀操作
c2 := make(chan float64) // 初始化無緩衝通道
c1 <- 1 // 往通道c1寫值
c2 <- 1.01 //往通道c2寫值
<-c2
此時還是報和上面同樣的錯誤。這是因爲無緩衝通道的讀寫必須位於不同的協程中。
c1 := make(chan int , 10) // 初始化可以緩衝10個元素的通道
c2 := make(chan float64) // 初始化無緩衝通道
go func() {
fmt.Printf("c2=%+v \n", <-c2) // 讀取c2中的數據,輸出c2=1.01
}()
c2 <- 1.01 // 往通道c2寫值
c1 <- 1 // 往通道c1寫值
time.Sleep(1*time.Second) // 短暫的sleep,等待協程讀取channel數據
這樣寫,纔是正確的方式,程序可以正常運行。
讀取通道的數據時,通道左邊如果是一個變量,會返回通道中的元素;如果是兩個變量,第一個是通道中複製出來的元素,第二個是通道的狀態。其中通道的狀態爲 true 時,通道未關閉,狀態爲 fasle 時,通道關閉。已經關閉的通道不允許再發送數據。
c1 := make(chan int , 10) // 初始化可以緩衝10個元素的通道
c1 <- 1 // 往通道c1寫值
ret,status := <- c1
fmt.Printf("r=%+v,status=%+v",ret,status) // 輸出 r=1,status=true
關閉通道
關閉通道的方法是 close 方法。
c := make(chan int ,5)
c<-1
close(c)
c<-2 // 往關閉的通道中發送數據會報錯
調用 close 方法關閉 c 通道,然後繼續往 c 通道發送數據會報錯。
panic: send on closed channel
調用 close 方法關閉通道時,會給所有等待讀取通道數據的協程發送消息。這是一個非常有用的特性。
c := make(chan int)
go func() {
ret,status := <-c
fmt.Printf("go rountine 1 ret=%+v,status=%+v \n",ret,status)
}()
go func() {
ret,status := <-c
fmt.Printf("go rountine 2 ret=%+v,status=%+v \n",ret,status)
}()
close(c)
time.Sleep(1*time.Second)
雖然通道可以關閉,但並不是一個必須執行的方法,因爲通道本身會通過垃圾回收器,根據它是否可以訪問來決定是否回收。
遍歷通道
遍歷通道內的所有數據
c := make(chan int, 5)
c <- 1
c <- 2
c <- 3
c <- 4
c <- 5
go func() {
for ret := range c{
fmt.Printf("ret=%d \n",ret)
}
}()
time.Sleep(2*time.Second)
上文很多例子中都在示例的最後加了 time.Sleep(1*time.Second) ,讓主程序等待 1s 鍾之後再退出。因爲 main 函數也是一個 goroutine,它執行完成就會退出,而不會判斷是否有其他協程需要執行。我們讓 main goroutine 等待 1s 鍾,給其他協程足夠的執行時間。
select
select 是 Golang 中的控制結構,和其它語言的 switch 語句寫法類似。不過 select 的 case 語句必須是通道的讀寫操作。
如果有多個 case 都可以運行,select 會隨機選出一個執行, 其他 case 不會執行。default 在沒有 case 可 執行時,總可以執行。
如下示例
c1 := make(chan int ,10)
c2 := make(chan int ,10)
c3 := make(chan int ,10)
var i1, i2 int
c1 <- 10
c3 <- 20
select {
case i1 = <-c1:
fmt.Printf("received i1=%d \n", i1) // 輸出 received i1=10
case c2 <- i2:
fmt.Printf("sent %d \n", i2 ) // 輸出 sent 0
case i3, ok := (<-c3): // 等價於 i3, ok := <-c3
if ok {
fmt.Printf("received i3=%d \n", i3) // 輸出received i3=20
} else {
fmt.Printf("c3 is closed\n")
}
default:
fmt.Printf("no communication\n")
}
我們多次運行這段代碼會發現, 三個 case 都有可能執行到,這也驗證了,select 在滿足多個 case 操作時,會在滿足條件的 case 中隨機選擇一個執行。
當 select 語句沒有 case 條件滿足,且沒有定義 default 語句時,當前 select 所在協程會陷入阻塞狀態。
通過 time.After(1* time.Second),方法在 1s 之後會給通道發送消息,完成對 select 的超時操作:
c1 := make(chan int)
select{
case <- c1:
fmt.Println("print c1" )
case <-time.After( 1* time.Second):
fmt.Println("print 1s鍾" )
}
select 經常和 for 一起使用,下面是兩者一起使用的一些例子:
c1 := make(chan int ,10)
// 把數組中的元素依次放入Channel
for _, str := rang []string{"a","b","c"} {
select{
case <- done:
return
case c1 <- str
}
}
done := make(chan int)
// 無限循環,直到滿足某個條件,操作done通道,完成循環
for{
select{
case <- done:
return
default:
//進行某些操作
}
}
通道的特性
通過學習 Golang 語言源碼和一些教程中瞭解到,通道有幾個重要的特性,需要理解並牢記。
-
通道可以作爲參數在函數中傳遞,當作參數傳遞時,複製的是引用。
-
通道是併發安全的。
-
同一個通道的發送操作之間是互斥的,必須一個執行完了再執行下一個。接收操作和發送操作一樣。
-
緩衝通道的發送操作需要複製元素值,然後在通道內存放一個副本。非緩衝通道則直接複製元素值的副本到接收操作。
-
往通道內複製的元素如果是引用類型,則複製的是引用類型的地址。
-
緩衝通道中的值放滿之後,再往通道內發送數據,操作會阻塞。當有值被取走之後,會優先通知最早被阻塞的 goroutine, 重新發送數據。如果緩衝通道中的值爲空,再從緩衝通道中接收數據也會被阻塞,當有新的值到來時,會優先通知最早被堵塞的 goroutine,再次執行接收操作。
-
非緩衝通道,無論讀寫,都是堵塞的,都需要找到配對的操作方纔能執行。
-
對於剛初始化的 nil 通道,他的發送和接收操作會永遠阻塞。
高級示例
我們使用 Channel 完成兩個常見的問題,以加深對 Channel 的理解。第一個,藉助通道,使兩個協程交替輸出大小寫字母。
package main
import (
"fmt"
"time"
)
func main() {
arr1 := []string{"a","b","c","d","e"}
arr2 := []string{"A","B","C","D","E"}
a := make(chan bool)
b := make(chan bool)
go func() {
for _,str := range arr1{
if <-a {
fmt.Printf(str)
b <- true
}
}
}()
go func() {
for _,str := range arr2{
if <-b {
fmt.Printf(str)
a <- true
}
}
}()
a<-true
time.Sleep(2*time.Second)
}
我們定義了 a,b 兩個 channel,利用無緩衝通道接收堵塞的特性,在兩個 goroutine 中,接收通道的值並作爲繼續執行的依據,從而達到交替執行的目的。
第二個,爬取指定的網站
package main
import (
"fmt"
"time"
)
// 抓取網頁內容
func crawl(url string) (result string) {
time.Sleep(1*time.Second) // 睡眠1s鐘模擬抓取完數據
return url+":抓取內容完成 \n"
}
// 保存文件內容到本地
func saveFile(url string,limiter chan bool, exit chan bool) {
fmt.Printf("開啓一個抓取協程 \n")
result := crawl(url) // 抓取網頁內容
if result != "" {
fmt.Printf(result)
}
<-limiter // 通知限速協程,抓取完成
if (exit != nil){
exit<-true // 通知退出協程,程序執行完成
}
}
// urls是要爬取的地址,n併發goroutine限制
func doWork(urls []string,n int) {
limiter := make(chan bool,n) // 限速協程
exit := make(chan bool) // 退出協程
for i,value := range urls{
limiter <- true
if i == len(urls)-1 {
go saveFile(value,limiter,exit)
}else{
go saveFile(value,limiter,nil)
}
}
<-exit
}
func main() {
urls := []string{"https://www.lixiang.com/","https://www.so.com","https://www.baidu.com/","https://www.360.com/"}
doWork(urls, 1)
}
我們通過 limiter 協程的緩衝區大小,控制協程併發數量。通過 exit 協程的阻塞,結束最終程序。
實現原理
Channel 在 Golang 中用 hchan 結構體表示。
type hchan struct {
qcount uint // channel通道中元素個數
dataqsiz uint // 環形隊列中數據大小
buf unsafe.Pointer // 存放實際元素的位置
elemsize uint16 // channnel類型大小
closed uint32 // channnel是否關閉
elemtype *_type // channel中元素類型
sendx uint // 發送的goroutine在buf中的位置
recvx uint // 接收的goroutine在buf中的位置
recvq waitq // 等待讀取的goroutine隊列
sendq waitq // 等待寫入的goroutine隊列
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex // channel併發鎖
}
buf 中存放了所有緩衝的數據,結合 sendx,recvx,構造了一個環形隊列結構。
通道初始化時,根據元素大小、是否含有指針決定存儲空間的分配。當元素大小爲 0 時,只分配 hchan 結構體的內存就可以了。當沒有指針時,連續分配元素大小和結構體大小的內存。當存在指針時,需要給指針元素單獨分配內存空間。
通道寫入數據的過程中,首先判斷是否有正在等待讀取的協程,如果有的話,複製數據給此協程;否則繼續判斷是否有空閒緩衝區,如果有的話把數據複製到緩衝區;否則,把當前 goroutine 放入等待寫入隊列。
通道讀取數據的流程和寫入類似,首先判斷是否有等待寫入的協程,如果有的話,啓動協程的寫入操作,複製數據;否則繼續判斷緩衝區中是否有數據,如果有的話複製數據;否則,把當前 goroutine 放入等待讀取的隊列
Go Channel 的源碼,主要在 runtime/chan.go 目錄下。
總結
本文主要介紹了 Go Channel 的基本用法,特性,常用場景和實現原理。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/zff6YyFcjLfCz956SvW2cw