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 語言源碼和一些教程中瞭解到,通道有幾個重要的特性,需要理解並牢記。

  1. 通道可以作爲參數在函數中傳遞,當作參數傳遞時,複製的是引用。

  2. 通道是併發安全的。

  3. 同一個通道的發送操作之間是互斥的,必須一個執行完了再執行下一個。接收操作和發送操作一樣。

  4. 緩衝通道的發送操作需要複製元素值,然後在通道內存放一個副本。非緩衝通道則直接複製元素值的副本到接收操作。

  5. 往通道內複製的元素如果是引用類型,則複製的是引用類型的地址。

  6. 緩衝通道中的值放滿之後,再往通道內發送數據,操作會阻塞。當有值被取走之後,會優先通知最早被阻塞的 goroutine, 重新發送數據。如果緩衝通道中的值爲空,再從緩衝通道中接收數據也會被阻塞,當有新的值到來時,會優先通知最早被堵塞的 goroutine,再次執行接收操作。

  7. 非緩衝通道,無論讀寫,都是堵塞的,都需要找到配對的操作方纔能執行。

  8. 對於剛初始化的 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