Go语言 通道



导言

  • 原文链接: Part 22: Channels
  • If translation is not allowed, please leave me in the comment area and I will delete it as soon as possible.

通道

在之前的教程中,我们已经讨论了:Go语言 是如何使用协程实现并发的。在这一部分,我们将讨论下通道,以及通过通道,协程是如何进行通信的。

这一节,我们要讨论的是无缓冲通道!这意味着,以下知识适用于无缓冲通道,但可能不适用于其他类型的通道。


通道是什么?

通道可以认为是一根用于协程通信的管道。在现实中,水总是从管道的一端流向另外一端,而在计算机世界中,数据能发送给通道,也可以从通道的另一端接收。


声明通道

通道是有类型的。通道的类型,就是通道中数据的类型 — 通道只能传输同类型的数据。

chan T 表示类型为 T 的通道。

通道的零值是 nilnil通道 几乎没用,因此,通道必须使用 make函数 定义,这与 slicemap 类似。

来声明个通道吧~

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)
    }
}

在上面程序的第 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 <- data 		// 向通道发送数据

箭头相对于通道的方向,表明了数据到底是发送还是接收。

在第 1 行,箭头指向外部,因此我们正在从通道中读取数据,并存储到 变量data

在第 2 行,箭头指向 a,因此我们正在向通道写数据。


默认情况 (无缓冲通道)

默认情况下,发送和接收操作都是阻塞的。这是什么意思呢?当向通道发送数据时,控件会阻塞在发送语句,直到其它协程从通道中接收数据。同样的,当从通道中接收数据时,接收语句也会阻塞,直到其它协程向通道发送数据。

通道的这个特性,使得协程交互无需使用显式锁、条件变量 — 其它编程语言可能经常使用这些东西。


通道实例程序

理论已经说够了,来实战吧~

下面,我们来写一个程序,帮助我们理解:协程们是如何通过通道进行通信的。

我们来重写一个程序,这个程序曾出现在 协程 的教程中。这里,我们引用一下。

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")
}

这就是之前的程序。这里,我们使用 Sleep函数,让 main协程 等待 hello协程 完成工作。如果你对这不太理解,我推荐你阅读之前的 教程

下面,我将用通道重写这个程序。

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。在接下来的一行,我们将它传递给 hello函数。
在第 14 行,我们从 done通道 中读取数据,这一句代码会产生阻塞,直到协程向 done通道 写入数据。

因此,通过这种方式,我们可以在不使用 Sleep函数 的条件下,避免 main协程 的过早终止。

代码<-done,表示从 done通道 中读取数据,且不存储读取到的数据。这是完全合法的。

现在,我们已经使 main协程 阻塞了,它在等待 done通道 的数据。hello协程 将通道作为参数,在打印了 Hello world goroutine 后,便向该通道写入数据。当写入操作完成,main协程 也随即在 done通道 读取到数据。此时,main协程 阻塞消除,并随即打印 main function

程序输出如下:

Hello world goroutine  
main function  

让我们为 hello协程,引入 Sleep 函数,以使我们能更好地理解阻塞的概念。

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")
}

在上面程序的第 10 行,我们为 hello协程 添加了一段 4 秒的休眠时间。

首先,这个程序会输出 Main going to call hello go goroutine。之后 hello协程 开启,并打印出 hello go routine is going to sleep。随后,hello协程 进入 4 秒的休眠状态,在此期间,main协程 会处于阻塞状态,它在等待 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 = 50

我们会把计算平方和的任务交给一个协程,计算立方和的任务交给另一个协程,最终在 main协程 中输出结果。

程序如下:

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)
}

calcSquares函数 用于计算数的平方和,并把结果写入 squareop通道。同样地,calcCubes函数 用于计算数的立方和,并把结果写入 cubeop通道 中。

2 个函数分别在 2 个协程中运行。main协程 会等待这 2 个协程任务完成。通道的数据会分别存储在 squares变量 和 cubes变量 中,之后进行结果计算并输出。

最终程序输出如下:

Final output 1536  

死锁

在使用通道时,我们必须考虑死锁。一个协程向通道写数据,另一个从通道读数据,此时不会导致死锁。如果情况不是这样的,程序运行时就会因死锁而奔溃。

同样地,如果一个协程正在等待通道中的数据,而另外一个协程能为通道写入数据,此时也不会产生死锁。如果情况不是这样的,程序同样会因死锁而奔溃。

package main

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

在上面的程序中,ch 被创建,之后我们向其写入 5。在这个程序中,没有其它的协程从 通道ch 中读取数据,因此,程序会奔溃,并输出运行错误:

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 行,我们创建了一个 只能向其发送数据 的单向通道。chan<- int表示 1只能向其发送数据 的单向通道,因为箭头指向了 chan
在第 12 行,我们试图从这个通道读取数据,然而,这是不允许的。程序运行时,编译器会告诉我们:

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

小伙伴可能会产生疑惑:如果无法从 只能发送数据的通道 中获取数据,那么向该通道发送数据还有什么意义呢!

单向通道存在的意义是:我们可以把双向通道转换为单向通道,反之则不行。

package main

import "fmt"

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

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

在上面程序的第 10 行,我们创建了一个 双向通道chnl。在第 11 行,我们将它作为参数,传递给 sendData函数。sendData函数 会把双向的 chnl 转换为单向的 sendch。此时,sendData协程 的通道只能进行数据发送,而 main协程 中的通道却是双向的。

最终,程序会输出 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协程 将 09 写入 chnl通道,之后便关闭了该通道。在第 16 行,main协程 开启了一个死循环,通过 ok 变量,去检测 chnl通道 是否关闭。当 okfalse,这表示通道已经关闭,于是 main协程 会跳出循环。否则,main协程 会一直打印读取到的数据、以及 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通道 中读取数据,直到该通道关闭。一旦通道关闭,循环也会自动退出。

程序输出如下:

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

接下来,通过 for range循环,我们来重写一下上面 通道的另外一个例子 中的程序,以使该程序具有更好的代码重用性。

如果你细心观察,你会发现:获得数字每个数位 的代码,既出现在 calcSquares函数,又出现在 calcCubes函数。

下面,我将这段代码封装成一个函数,进而可以并发地调用它。

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)
}

digis函数,现在拥有了 获取数字每个数位 的逻辑,并且它会被 calcSquares函数 和 calcCubes函数 并发调用。在 digis函数 中,一旦数位遍历完毕,通道dchnl 就会被关闭。
calcSquares协程 和 calcCubes协程 会使用for range循环,监听各自的 dch通道,直到通道关闭。

余下的代码是一样的,最终,程序输出如下:

Final output 1536  

这就是本章的结尾了。关于通道,这还有许多概念,例如 有缓冲通道协程池select。这些之后再进行讲解吧~

祝你开心~


原作者留言

优质内容来之不易,您可以通过该 链接 为我捐赠。


最后

感谢原作者的优质内容。

这是我的第九次翻译,欢迎指出文中的任何错误。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值