在上一篇教程中,我们讨论了如何在Go中使用Goroutines实现并发性。在本教程中,我们将讨论通道以及Goroutines如何使用通道进行通信。
什么是通道
通道可以被认为是Goroutines用来通信的管道。类似于管道中水从一端流向另一端的情况,数据可以从一端发送,并通过通道从另一端接收。
声明通道
每个通道都有一个与之相关的类型。这个类型是该通道被允许传输的数据类型。其他类型的数据不允许使用该通道进行传输。
chan T
是一个类型为T
的通道
通道的零值是nil
。nil
通道没有任何用处,因此必须使用类似于maps
和slices
的方法来定义通道。
让我们写一些声明通道的代码。
1 | package main |
在第6行中声明的通道是nil
,因为通道的零值是nil
。因此,在if条件中的语句被执行,通道被定义。上述程序中的a
是一个int cahnnel
。这个程序将输出。
1 | channel a is nil, going to define it |
像往常一样,简短声明(short hand declaration)也是定义一个通道的有效和简洁的方法。
1 | a := make(chan int) |
上面这行代码也定义了一个int channel
a
。
从一个通道发送和接收数据
下面给出了从一个通道发送和接收数据的语法。
1 | data := <- a // read from channel a |
箭头相对于通道的方向指定了数据是被发送还是被接收。
在第一行中,箭头从a
向外指向,因此我们从通道a
中读取数据并将其存储到变量data
中。
在第二行中,箭头指向a
,因此我们正在向通道a
写入数据。
发送和接收默认为阻断(blocking)
对一个频道的发送和接收默认是阻塞的。这是什么意思?当数据被发送到一个通道时,控制在发送语句中被阻断,直到其他Goroutine从该通道读取。同样地,当数据从一个通道中读出时,读被阻断,直到某个Goroutine将数据写入该通道。
通道的这一属性有助于Goroutine有效地通信,而不需要使用显式锁或条件变量,这在其他编程语言中是很常见的。
如果这一点现在没有意义,也没关系。接下来的章节将更清楚地说明通道是如何默认阻塞的。
通道实例程序
理论讲得够多了:)。让我们写一个程序来了解Goroutines如何使用通道进行通信。
实际上我们将在这里用通道重写我们在学习Goroutines时写的程序。
让我在这里引用上一个教程中的程序。
1 | package main |
这是上一个教程中的程序。我们在这里使用了一个sleep
,让主Goroutine等待hello
Goroutine的完成。如果你觉得-没有意义,我建议你阅读Goroutines的教程
我们将使用通道重写上述程序。
1 | package main |
在上面的程序中,我们在第12行创建了一个完成的bool channel
,并把它作为一个参数传给hello
Goroutine。在第14行,我们从已完成的通道接收数据。14行,我们从已完成的通道接收数据。这行代码是阻塞的,这意味着在某个Goroutine将数据写入已完成的通道之前,控制句柄不会移动到下一行代码。因此,这就不需要在原程序中使用time.Sleep
来防止main
Goroutine退出。
这一行代码<-done
从已完成的通道接收数据,但不使用或储存这些数据于任何变量。这完全是合法的。
现在,我们的主Goroutine被阻塞了,等待done channel
上的数据。hello
Goroutine接收这个通道作为参数,打印出Hello world goroutine
,然后写到done channel
。当这个写入完成后,main
Goroutine从done channel
接收数据,它被解除阻塞,然后打印出main函数的文本。
这个程序的输出
1 | Hello world goroutine |
让我们修改这个程序,在hello Goroutine中引入一个sleep
,以更好地理解这个阻塞的概念。
1 | package main |
在上述程序中,我们在第10行为hello
函数引入了4秒的睡眠时间。
这个程序将首先打印Main going to call hello go goroutine
。然后,hello
Goroutine将被启动,它将打印hello go routine is going to sleep
。在打印之后,hello
Goroutine将休眠4秒,在这段时间内,main
Goroutine将被阻塞,因为它正在等待来自第18行done channel
的数据。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
我们将构建这个程序,使squares
在一个单独的Goroutine中计算,cubes
在另一个Goroutine中计算,最后的求和在main
Goroutine中发生。
1 | package main |
第7行的calcSquares
函数计算数字的各个数字的平方之和,并将其发送到squareop channel
。同样,第17行calcCubes
函数计算数字的各个数字的立方体之和,并将其发送到cubeop channel
。
这两个函数在第31行和第32行作为独立的Goroutines运行,每个函数都被传递给一个要写入的通道作为参数。main
Goroutine在第33行等待来自这两个通道的数据。一旦从这两个通道接收到数据,它们将被存储在squares
和cubes
变量中,并计算和打印最终输出。这个程序将打印:
1 | Final output 1536 |
Deadlock(死锁)
在使用通道时需要考虑的一个重要因素是死锁。如果一个Goroutine在一个通道上发送数据,那么预计其他的Goroutine应该会接收这些数据。如果这种情况没有发生,那么程序在运行时就会出现死锁的报错。
同样,如果一个Goroutine正在等待从一个通道接收数据,那么其他的Goroutine应该在这个通道上写数据,否则程序将报错。
1 | package main |
在上面的程序中,创建了一个channel ch
,我们在第6行的ch <- 5
向该通道发送5。在这个程序中,没有其他Goroutine从channel ch
接收数据。
1 | fatal error: all goroutines are asleep - deadlock! |
单向的通道(Unidirectional channels)
到目前为止,我们讨论的所有通道都是双向的,也就是说,数据可以在上面发送和接收。我们也可以创建单向通道,也就是只发送或接收数据的通道。
1 | package main |
在上面的程序中,我们在第10行创建了只发送的通道sendch
。chan<- int
表示一个只发送的通道,因为箭头指向 chan
。在第12行中,我们试图从只发送通道中接收数据。这是不允许的,当程序运行时,编译器会报错:
./prog.go:12:14: invalid operation: <-sendch (receive from send-only type chan<- int)
一切都很好,但是如果不能从一个只发送的通道中读出,那么向其写入的意义是什么呢?
这就是通道转换的作用处。可以将一个双向通道转换为只发送或只接收的通道,但不能反过来。
1 | package main |
在上面的程序的第10行,一个双向通道chnl
被创建。它在11行被作为一个参数传递给sendData
Goroutine。sendData
函数在第5行将这个通道转换为一个只发送的通道通过参数sendch chan<- int
。所以现在这个通道在sendData
Goroutine中是只发送的,但在main
Goroutine中是双向的。这个程序将打印10
作为输出。
关闭通道和通道上的for range循环
发送者有能力关闭通道,以通知接收者该通道将不再发送数据。
接收者可以在从通道接收数据时使用一个额外的变量来检查通道是否已经关闭。
1 | v, ok := <- ch |
在上面的语句中,如果值是由一个成功的发送操作收到的,那么ok
就是真的。如果ok
是假的,这意味着我们正在从一个封闭的通道中读取。从一个封闭的通道中读取的值将是该通道类型的零值。例如,如果通道是一个int
channel,那么从一个关闭的通道收到的值将是0
。
1 | package main |
在上面的程序中,producer
Goroutine将0到9写入chnl
channel,然后关闭该通道。主函数在第16行有一个无限的for
循环,通过第18行的变量ok
检查通道是否被关闭。如果18行的ok
是false
,意味着通道已经关闭,因此循环被中断。否则,接收到的值和ok
的值被打印出来。这个程序将打印:
1 | Received 0 true |
for range形式的for循环可以用来从一个通道接收数值,直到它被关闭。
让我们用for range循环重写上面的程序。
1 | package main |
第16行的for range循环接收来自ch
channel的数据,直到其关闭为止。一旦ch
被关闭,循环自动退出。这个程序输出:
1 | Received 0 |
通道部分的另一个例子(平方立方)中的程序可以用for range loop重写,有更多的代码可以重复使用。
如果你仔细看一下这个程序,你会发现在calcSquares
函数和calcCubes
函数中都重复了查找一个数字的各个数字的代码。我们将把这段代码移到自己的函数中,并同时调用它。
1 | package main |
上面程序中的digits
函数现在包含了从一个数字中获取各个数字的逻辑,它被calcSquares
和calcCubes
两个函数同时调用。一旦数字中没有更多的数字,dchnl
channel就会在第13行关闭。calcSquares
和calcCubes
Goroutines使用for range循环监听各自的通道,直到它被关闭。程序的其余部分也是如此。这个程序将打印:
1 | Final output 1536 |
这样我们就到了本教程的结尾。在通道中还有一些概念,如缓冲通道、工作者池和选择。我们将在单独的教程中讨论它们。谢谢你的阅读。祝你有个愉快的一天。