1. 程式人生 > >22.通道(channel)

22.通道(channel)

歡迎來到Golang 系列教程的第 22 章。

在上一教程裡,我們探討了如何使用 Go 協程(Goroutine)來實現併發。我們接著在本教程裡學習通道(Channel),學習如何通過通道來實現 Go 協程間的通訊。

什麼是通道?

通道可以想像成 Go 協程之間通訊的管道。如同管道中的水會從一端流到另一端,通過使用通道,資料也可以從一端傳送,在另一端接收。

通道的宣告

所有通道都關聯了一個型別。通道只能運輸這種型別的資料,而運輸其他型別的資料都是非法的。

chan T 表示 T 型別的通道。

通道的零值為 nil。通道的零值沒有什麼用,應該像對 map 和切片所做的那樣,用 make

來定義通道。

下面編寫程式碼,宣告一個通道。

package main

import "fmt"

func main() {  
    var a chan int
    if a == nil {
        fmt.Println("channel a is nil, going to define it")
        a = make(chan int)
        fmt.Printf("Type of a is %T", a)
    }
}

由於通道的零值為 nil,在第 6 行,通道 a 的值就是 nil。於是,程式執行了 if 語句內的語句,定義了通道 a

。程式中 a 是一個 int 型別的通道。該程式會輸出:

channel a is nil, going to define it  
Type of a is chan int  

簡短宣告通常也是一種定義通道的簡潔有效的方法。

a := make(chan int) 

這一行程式碼同樣定義了一個 int 型別的通道 a

通過通道進行傳送和接收

如下所示,該語法通過通道傳送和接收資料。

data := <- a // 讀取通道 a  
a <- data // 寫入通道 a  

通道旁的箭頭方向指定了是傳送資料還是接收資料。

在第一行,箭頭對於 a

來說是向外指的,因此我們讀取了通道 a 的值,並把該值儲存到變數 data

在第二行,箭頭指向了 a,因此我們在把資料寫入通道 a

傳送與接收預設是阻塞的

傳送與接收預設是阻塞的。這是什麼意思?當把資料傳送到通道時,程式控制會在傳送資料的語句處發生阻塞,直到有其它 Go 協程從通道讀取到資料,才會解除阻塞。與此類似,當讀取通道的資料時,如果沒有其它的協程把資料寫入到這個通道,那麼讀取過程就會一直阻塞著。

通道的這種特效能夠幫助 Go 協程之間進行高效的通訊,不需要用到其他程式語言常見的顯式鎖或條件變數。

通道的程式碼示例

理論已經夠了:)。接下來寫點程式碼,看看協程之間通過通道是怎麼通訊的吧。

我們其實可以重寫上章學習Go協程 時寫的程式,現在我們在這裡用上通道。

首先引用前面教程裡的程式。

package main

import (  
    "fmt"
    "time"
)

func hello() {  
    fmt.Println("Hello world goroutine")
}
func main() {  
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("main function")
}

這是上一篇的程式碼。我們使用到了休眠,使 Go 主協程等待 hello 協程結束。如果你看不懂,建議你閱讀上一教程Go 協程。

我們接下來使用通道來重寫上面程式碼。

package main

import (  
    "fmt"
)

func hello(done chan bool) {  
    fmt.Println("Hello world goroutine")
    done <- true
}
func main() {  
    done := make(chan bool)
    go hello(done)
    <-done
    fmt.Println("main function")
}

在上述程式裡,我們在第 12 行建立了一個 bool 型別的通道 done,並把 done 作為引數傳遞給了 hello 協程。在第 14 行,我們通過通道 done 接收資料。這一行程式碼發生了阻塞,除非有協程向 done 寫入資料,否則程式不會跳到下一行程式碼。於是,這就不需要用以前的 time.Sleep 來阻止 Go 主協程退出了。

<-done 這行程式碼通過協程(譯註:原文筆誤,通道)done 接收資料,但並沒有使用資料或者把資料儲存到變數中。這完全是合法的。

現在我們的 Go 主協程發生了阻塞,等待通道 done 傳送的資料。該通道作為引數傳遞給了協程 hellohello 打印出 Hello world goroutine,接下來向 done 寫入資料。當完成寫入時,Go 主協程會通過通道 done 接收資料,於是它解除阻塞狀態,打印出文字 main function

該程式輸出如下:

Hello world goroutine  
main function  

我們稍微修改一下程式,在 hello 協程里加入休眠函式,以便更好地理解阻塞的概念。

package main

import (  
    "fmt"
    "time"
)

func hello(done chan bool) {  
    fmt.Println("hello go routine is going to sleep")
    time.Sleep(4 * time.Second)
    fmt.Println("hello go routine awake and going to write to done")
    done <- true
}
func main() {  
    done := make(chan bool)
    fmt.Println("Main going to call hello go goroutine")
    go hello(done)
    <-done
    fmt.Println("Main received data")
}

在上面程式裡,我們向 hello 函式裡添加了 4 秒的休眠(第 10 行)。

程式首先會列印 Main going to call hello go goroutine。接著會開啟 hello 協程,列印 hello go routine is going to sleep。列印完之後,hello 協程會休眠 4 秒鐘,而在這期間,主協程會在 <-done 這一行發生阻塞,等待來自通道 done 的資料。4 秒鐘之後,列印 hello go routine awake and going to write to done,接著再列印 Main received data

通道的另一個示例

我們再編寫一個程式來更好地理解通道。該程式會計算一個數中每一位的平方和與立方和,然後把平方和與立方和相加並打印出來。

例如,如果輸出是 123,該程式會如下計算輸出:

squares = (1 * 1) + (2 * 2) + (3 * 3) 
cubes = (1 * 1 * 1) + (2 * 2 * 2) + (3 * 3 * 3) 
output = squares + cubes = 49

我們會這樣去構建程式:在一個單獨的 Go 協程計算平方和,而在另一個協程計算立方和,最後在 Go 主協程把平方和與立方和相加。

package main

import (  
    "fmt"
)

func calcSquares(number int, squareop chan int) {  
    sum := 0
    for number != 0 {
        digit := number % 10
        sum += digit * digit
        number /= 10
    }
    squareop <- sum
}

func calcCubes(number int, cubeop chan int) {  
    sum := 0 
    for number != 0 {
        digit := number % 10
        sum += digit * digit * digit
        number /= 10
    }
    cubeop <- sum
} 

func main() {  
    number := 589
    sqrch := make(chan int)
    cubech := make(chan int)
    go calcSquares(number, sqrch)
    go calcCubes(number, cubech)
    squares, cubes := <-sqrch, <-cubech
    fmt.Println("Final output", squares + cubes)
}

在第 7 行,函式 calcSquares 計算一個數每位的平方和,並把結果傳送給通道 squareop。與此類似,在第 17 行函式 calcCubes 計算一個數每位的立方和,並把結果傳送給通道 cubop

這兩個函式分別在單獨的協程裡執行(第 31 行和第 32 行),每個函式都有傳遞通道的引數,以便寫入資料。Go 主協程會在第 33 行等待兩個通道傳來的資料。一旦從兩個通道接收完資料,資料就會儲存在變數 squarescubes 裡,然後計算並打印出最後結果。該程式會輸出:

Final output 1536 

死鎖

使用通道需要考慮的一個重點是死鎖。當 Go 協程給一個通道傳送資料時,照理說會有其他 Go 協程來接收資料。如果沒有的話,程式就會在執行時觸發 panic,形成死鎖。

同理,當有 Go 協程等著從一個通道接收資料時,我們期望其他的 Go 協程會向該通道寫入資料,要不然程式就會觸發 panic。

package main

func main() {  
    ch := make(chan int)
    ch <- 5
}

在上述程式中,我們建立了一個通道 ch,接著在下一行 ch <- 5,我們把 5 傳送到這個通道。對於本程式,沒有其他的協程從 ch 接收資料。於是程式觸發 panic,出現如下執行時錯誤。

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:  
main.main()  
    /tmp/sandbox249677995/main.go:6 +0x80

單向通道

我們目前討論的通道都是雙向通道,即通過通道既能傳送資料,又能接收資料。其實也可以建立單向通道,這種通道只能傳送或者接收資料。

package main

import "fmt"

func sendData(sendch chan<- int) {  
    sendch <- 10
}

func main() {  
    sendch := make(chan<- int)
    go sendData(sendch)
    fmt.Println(<-sendch)
}

上面程式的第 10 行,我們建立了唯送(Send Only)通道 sendchchan<- int 定義了唯送通道,因為箭頭指向了 chan。在第 12 行,我們試圖通過唯送通道接收資料,於是編譯器報錯:

main.go:11: invalid operation: <-sendch (receive from send-only type chan<- int)

一切都很順利,只不過一個不能讀取資料的唯送通道究竟有什麼意義呢?

這就需要用到通道轉換(Channel Conversion)了。把一個雙向通道轉換成唯送通道或者唯收(Receive Only)通道都是行得通的,但是反過來就不行。

package main

import "fmt"

func sendData(sendch chan<- int) {  
    sendch <- 10
}

func main() {  
    chnl := make(chan int)
    go sendData(chnl)
    fmt.Println(<-chnl)
}

在上述程式的第 10 行,我們建立了一個雙向通道 cha1。在第 11 行 cha1 作為引數傳遞給了 sendData 協程。在第 5 行,函式 sendData 裡的引數 sendch chan<- intcha1 轉換為一個唯送通道。於是該通道在 sendData 協程裡是一個唯送通道,而在 Go 主協程裡是一個雙向通道。該程式最終列印輸出 10

關閉通道和使用 for range 遍歷通道

資料傳送方可以關閉通道,通知接收方這個通道不再有資料傳送過來。

當從通道接收資料時,接收方可以多用一個變數來檢查通道是否已經關閉。

v, ok := <- ch  

上面的語句裡,如果成功接收通道所傳送的資料,那麼 ok 等於 true。而如果 ok 等於 false,說明我們試圖讀取一個關閉的通道。從關閉的通道讀取到的值會是該通道型別的零值。例如,當通道是一個 int 型別的通道時,那麼從關閉的通道讀取的值將會是 0

package main

import (  
    "fmt"
)

func producer(chnl chan int) {  
    for i := 0; i < 10; i++ {
        chnl <- i
    }
    close(chnl)
}
func main() {  
    ch := make(chan int)
    go producer(ch)
    for {
        v, ok := <-ch
        if ok == false {
            break
        }
        fmt.Println("Received ", v, ok)
    }
}

在上述的程式中,producer 協程會從 0 到 9 寫入通道 chn1,然後關閉該通道。主函式有一個無限的 for 迴圈(第 16 行),使用變數 ok(第 18 行)檢查通道是否已經關閉。如果 ok 等於 false,說明通道已經關閉,於是退出 for 迴圈。如果 ok 等於 true,會打印出接收到的值和 ok 的值。

Received  0 true  
Received  1 true  
Received  2 true  
Received  3 true  
Received  4 true  
Received  5 true  
Received  6 true  
Received  7 true  
Received  8 true  
Received  9 true  

for range 迴圈用於在一個通道關閉之前,從通道接收資料。

接下來我們使用 for range 迴圈重寫上面的程式碼。

package main

import (  
    "fmt"
)

func producer(chnl chan int) {  
    for i := 0; i < 10; i++ {
        chnl <- i
    }
    close(chnl)
}
func main() {  
    ch := make(chan int)
    go producer(ch)
    for v := range ch {
        fmt.Println("Received ",v)
    }
}

在第 16 行,for range 迴圈從通道 ch 接收資料,直到該通道關閉。一旦關閉了 ch,迴圈會自動結束。該程式會輸出:

Received  0  
Received  1  
Received  2  
Received  3  
Received  4  
Received  5  
Received  6  
Received  7  
Received  8  
Received  9  

我們可以使用 for range 迴圈,重寫通道的另一個示例這一節裡面的程式碼,提高程式碼的可重用性。

如果你仔細觀察這段程式碼,會發現獲得一個數裡的每位數的程式碼在 calcSquarescalcCubes 兩個函式內重複了。我們將把這段程式碼抽離出來,放在一個單獨的函式裡,然後併發地呼叫它。

package main

import (  
    "fmt"
)

func digits(number int, dchnl chan int) {  
    for number != 0 {
        digit := number % 10
        dchnl <- digit
        number /= 10
    }
    close(dchnl)
}
func calcSquares(number int, squareop chan int) {  
    sum := 0
    dch := make(chan int)
    go digits(number, dch)
    for digit := range dch {
        sum += digit * digit
    }
    squareop <- sum
}

func calcCubes(number int, cubeop chan int) {  
    sum := 0
    dch := make(chan int)
    go digits(number, dch)
    for digit := range dch {
        sum += digit * digit * digit
    }
    cubeop <- sum
}

func main() {  
    number := 589
    sqrch := make(chan int)
    cubech := make(chan int)
    go calcSquares(number, sqrch)
    go calcCubes(number, cubech)
    squares, cubes := <-sqrch, <-cubech
    fmt.Println("Final output", squares+cubes)
}

上述程式裡的 digits 函式,包含了獲取一個數的每位數的邏輯,並且 calcSquarescalcCubes 兩個函式併發地呼叫了 digits。當計算完數字裡面的每一位數時,第 13 行就會關閉通道。calcSquarescalcCubes 兩個協程使用 for range 迴圈分別監聽了它們的通道,直到該通道關閉。程式的其他地方不變,該程式同樣會輸出:

Final output 1536  

本教程的內容到此結束。關於通道還有一些其他的概念,比如緩衝通道(Buffered Channel)、工作池(Worker Pool)和 select。我們會在接下來的教程裡專門介紹它們。感謝閱讀。祝你愉快。