这是我参与8月更文挑战的第 22 天,活动详情查看: 8月更文挑战
本文主要是分享channel的一些语法及概念性的东西。当然,也会通过示例协助理解。后边会准备一篇文章主要分享channel的实际使用,深入的了解channel
channel初识
如果说goroutine是Go程序并发的执行体,通道就是它们之间的连接。通道是可以让 一个goroutine发送特定值到另一个goroutine的通信机制。每一个通道是一个具体类型的导管,叫作通道的元素类型。一个有int类型元素的通道写为chan int
使用内置的make函数来创建一个通道:
ch := make(chan int) // ch 的类型是,chan int
像map一样,通道是一个使用make创建的数据结构的引用。当复制或者作为参数传递 到一个函数时,复制的是引用,这样调用者和被调用者都引用同一份数据结构。和其他引用 类型一样,通道的零值是nil
var ch chan int//此时ch就是nil
同种类型的通道可以使用==符号进行比较。当二者都是同一通道数据的引用时,比较值为true。通道也可以和nil进行比较
通道有两个主要操作:发送(send)和接收(receive),两者统称为通信。send语句从一 个goroutine传输一个值到另一个在执行接收表达式的goroutine。两个操作都使用 <- 操作符 书写。发送语句中,通道和值分别在 <- 的左右两边。在接收表达式中,<- 放在通道操作数前面。在接收表达式中,其结果未被使用也是合法的
ch <- x //发送语句
x = <-ch //赋值语句中的接收表达式
<-ch //接收语句,丢弃结果
通道支持第三个操作:关闭(close),它设置一个标志位来指示值当前已经发送完毕,,这个通道后面没有值了;关闭后的发送操作将导致宕机。在一个已经关闭的通道上进行接收操作,将获取所有已经发送的值,直到通道为空;这时任何接收操作会立即完成,同时获取到一个通道元素类型对应的零值
调用内置的close函数来关闭通道
close(ch)
使用简单的make创建的通道叫无缓冲(unbuffered)通道,但是make还可以接收第二个可选参数,它表示通道容量,如果容量是0,就创建了一个无缓冲通道
ch = make(chan int)//无缓冲通道
ch = make(chan int, 0)//无缓冲通道
ch = make(chan int, 3) //容量为3的缓冲通道
无缓冲channel
无缓冲通道上的发送操作将会阻塞,直到另一个goroutine在对应的通道上执行接收操作,这时值传送完成,两个goroutine都可以继续执行。相反,如果接收操作先执行,接收方goroutine将阻塞,直到另一个goroutine在同一个通道上发送一个值
使用无缓冲通道进行的通信导致发送和接收goroutine同步化。因此,无缓冲通道也称为同步通道。当一个值在无缓冲通道上传递时,接收值后发送方goroutine才被再次唤醒
在讨论并发的时候,当我们说x早于y发生时,不仅仅是说x发生的时间早于y,而是说保证它是这样,并且是可预期的,比如更新变量,我们可以依赖这个机制
当x既不比y早也不比y晚时,我们说x和y并发。这不意味着,x和y一定同时发生,只说明我们不能假设它们的顺序。后边的文章会分享,在两个goroutine并发地访问同一 个变量的时候,有必要对这样的事件进行排序,避免程序的执行发生问题
通过下边的示例来理解上边提到的概念
package main
import "fmt"
func chanDemo() {
c := make(chan int)
c <- 1
c <- 2
n := <-c
fmt.Println(n)
}
func main() {
chanDemo()
}
如果运行上边这段程序,你会发现报错,报错内容是:
fatal error: all goroutines are asleep - deadlock!
原因是:因为c是一个无缓冲通道,所以,当往c这个通道中发送1之后,需要有一个goroutinue进行接收之后,才可以继续往c中发送数据(如果你将c <- 2这一行删掉,也是会报这个错误的,需要另一个goroutine进行接收)
对上边的chanDemo函数进行如下修改
func chanDemo() {
c := make(chan int)
go func() {
for{ //死循环,不断的从通道中取数据,没有数据的时候就阻塞住
n := <-c
fmt.Println(n)
}
}()
c <- 1
c <- 2
}
此时再执行,就可以正常打印出结果了。如果你的go版本是1.13以前的,或者你的计算机是单核的,那只能打印出来一个1。如果你的go版本是1.14及以上,你的计算机是多核,且没设置runtime.GOMAXPROCS(1)(在1.14版本及以上,它的默认值和你电脑的CPU核数相等),那你的执行结果是1、2(我这里以1.13以前版本为例)
解释一下原因:对于go的1.13以前的版本,goroutine是非抢占式的,如果一个goroutine不让出控制权,别的goroutine是不能执行的(如果你不清楚非抢占式调度,点这里)。并且从上篇文章中,知道,当涉及IO操作的时候,goroutine会让出控制权,所以上边执行结果为1,就好理解了。当往通道中传递一个1之后,闭包那个goroutine会获取到1并打印,因为打印操作涉及IO操作,所以它会让出控制权,此时会将2放入c中,因为此时主goroutine,也就是main,执行完毕了,所以主goroutine会关闭,所以,闭包的goroutine还没执行,就被杀了
在前边的文章中,我们知道go语言中的函数是一等公民(如果不清楚,可以点这里),channel也是一等公民,也就是说,它可以作为函数的参数,也可以作为返回值
channel作为参数
可以针对上边的程序进行修改,看一下channel作为函数的参数和返回值
package main
import (
"fmt"
)
func worker(c chan int) {
for{ //死循环,不断的从通道中取数据,没有数据的时候就阻塞住
n := <-c
fmt.Println(n)
}
}
func chanDemo() {
c := make(chan int)
go worker(c)
c <- 1
c <- 2
}
func main() {
chanDemo()
}
channel作为返回值
还是上边的示例,对它进行改进。现在开很多个worker,然后不断的往每个worker中发数据
package main
import (
"fmt"
"time"
)
func createWorker() chan int {
c := make(chan int)
go func() { //需要开不同的goroutine去收
for{ //死循环,不断的从通道中取数据,没有数据的时候就阻塞住
fmt.Printf("worker received %c\n", <-c)
}
}()
return c
}
func chanDemo() {
var channels [10]chan int
for i := 0; i < 10; i++ {
channels[i] = createWorker()
}
//向10个channel发送数据
for i := 0; i < 10; i++ {
channels[i] <- 'a' + i
}
time.Sleep(time.Millisecond)//为了避免创建完所有通道,并发送完所有数据之后,main函数执行结束,导致其它goroutine还没执行就被杀。所以睡眠1ms,让其它goroutine执行
}
func main() {
chanDemo()
}
上边的程序会比较简单,但是特别的方便理解
单向channel
从上边的程序,我们可以看出来,我们通过createWorker函数创建出来的通道,是用来发送数据的,所以可以将createWorker的返回值写成这样
func createWorker() chan<- int {
......
}
这样使用者一眼就可以看出来,这个函数返回的channel是一个发送数据的单向channel
//只能发送的通道(允许发送,但是不能接收)
chan <- int
//只能接收的通道(允许接收,但是不能发送)
<- chan int
还是以上边的代码为例,我们将返回值的channel改成一个只能发送数据的channel类型
package main
import (
"fmt"
"time"
)
func createWorker() chan<- int {
c := make(chan int)
go func() {
for{
fmt.Printf("worker received %c\n", <-c)
}
}()
return c
}
func chanDemo() {
var channels [10]chan<- int
for i := 0; i < 10; i++ {
channels[i] = createWorker()
}
//向10个channel发送数据
for i := 0; i < 10; i++ {
channels[i] <- 'a' + i
}
time.Sleep(time.Millisecond)//为了避免创建完所有通道,并发送完所有数据之后,main函数执行结束,导致其它goroutine还没执行就被杀。所以睡眠1ms,让其它goroutine执行
}
func main() {
chanDemo()
}
我们将createWorker函数的返回值类型,改成了chan<- int的单向通道,它返回的通道,只能进行发送数据,因此,在创建channels的时候,也需要将它创建为一个只能发送数据的单向通道
如果我们试图在发动数据的循环中去发送数据,就会编译错误
缓冲channel
理论
缓冲通道有一个元素队列,队列的最大长度在创建的时候,通过make的第二个参数来设置
ch := make(chan string, 3)
缓冲通道上的发送操作在队列的尾部插入一个元素,接收操作从队列的头部移除一个元素。如果通道满了,发送操作会阻塞所在的goroutine,直到另一个goroutine对它进行接收操作来留出可用空间。如果通道是空的,执行接收操作的goroutine会被阻塞,直到另一个goroutine向通道中发送数据
不太常见的一个情况是,程序需要知道通道缓冲区的容量,可以通过调用内置的cap函数获取它:
fmt.Println(cap(ch)) //3
当使用内置的len函数时,可以获取当前通道内的元素个数。因为在并发程序中这个信息会随着检索操作很快过时,所以它的价值很低,但是它在错误诊断和性能优化的时候很有用
fmt.Println(len(ch)) // "2"
如果,发送和接收操作都由同一个goroutine执行,但在真实的程序中通常由不同的goroutine执行。因为语法简单,新手有时候粗暴地将缓冲通道作为队列在单个goroutine中 使用,但是这是个错误。通道和goroutine的调度深度关联,如果没有另一个goroutine从通道进行接收,发送者(也许是整个程序)有被永久阻塞的风险。如果仅仅需要一个简单的队列,用slice创建一个就可以了
如果使用一个无缓冲通道,有3个goroutine向通道中发送数据,两个比较慢的goroutine将被卡住,因为在它们发送响应结果到通道的时候没有goroutine来接收。这个情况叫作goroutine泄漏,它属于一个bug。不像回收变量,泄露的goroutine不会自动回收,所以确保goroutine在不再需要的时候可以自动结束
无缓冲和缓冲通道的选择、缓冲通道容量大小的选择,都会对程序的正确性产生影响。 无缓冲通道提供强同步保障,因为每一次发送都需要和一次对应的接收同步;对于缓冲通道,这些操作则是解耦的。如果我们知道要发送的值数量的上限,通常会创建一个容量是使用上限的缓冲通道,在接收第一个值前就完成所有的发送。在内存无法提供缓冲容量的情况 下,可能导致程序死锁
通道的缓冲也可能影响程序的性能。想象蛋糕店里的三个厨师,在生产线上,在把每一 个蛋糕传递给下一个厨师之前,一个烤,一个加糖衣,一个雕刻。在空间比较小的厨房,每 一个厨师完成一个蛋糕流程,必须等待下一个厨师准备好接受它;这个场景类似于使用无缓冲通道来通信
如果在厨师之间有可以放一个蛋糕的位置,一个厨师可以将制作好的蛋糕放到这里,然 后立即开始制作下一个,这类似于使用一个容量为1的缓冲通道。只要厨师们以相同的速度工作,大多数工作就可以快速处理,消除他们各自之间的速率差异。如果在厨师之间有更多的空间——更长的缓冲区——就可以消除更大的暂态速率波动而不影响组装流水线,比如当 一个厨师稍作休息时,后面再抓紧跟上进度
另一方面,如果生产线的上游持续比下游快,缓冲区满的时间占大多数。如果后续的流程更快,缓冲区通常是空的。这时缓冲区的存在是没有价值的
组装流水线是对于通道和goroutine合适的比喻。例如,如果第二段更加复杂,一个厨师可能跟不上第一个厨师的供应,或者跟不上第三个厨师的需求。为了解决这个问题,我们可以雇用另一个厨师来帮助第二段流程,独立地执行同样的任务。这个类似于创建另外一个 goroutine使用同一个通道来通信
示例
从上边我们知道,一个无缓冲的通道,发送一个数据,如果没有人接收,是会报错的。比如下边这段程序,执行的时候会报错
package main
func bufferedChannel() {
ch := make(chan int)
ch <- 1
}
func main() {
bufferedChannel()
}
现在将它改成一个长度为3的缓冲通道,然后我们可以往里边发送3个数
package main
func bufferedChannel() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
}
func main() {
bufferedChannel()
}
执行这部分是没有问题的,如果再往里边发送第4个数据,它就会报错,因为缓冲区已经满了,需要有接收者接收之后,才能继续往里发送数据。下边通过创建goroutine来进行接收缓冲通道中的数据
package main
import (
"fmt"
"time"
)
func worker(id int, c chan int) {
for {
fmt.Printf("worker %d, received %c\n", id, <-c)
}
}
func bufferedChannel() {
ch := make(chan int, 3)
go worker(0, ch)
ch <- 'a'
ch <- 'b'
ch <- 'c'
ch <- 'd'
time.Sleep(time.Millisecond)
}
func main() {
bufferedChannel()
}
通道什么时候知道数据发完了?
在前边我们知道,channel是可以close的。channel创建出来并不是说一定要close,所以上边的代码写法也是对的。但是如果我们要发发送的数据有一个明显的结尾的话,就可以加close。永远是发送方来进行close的
发送方通过close来告诉接收方,我没有新的数据发送了。还是以上边的代码为例
package main
import (
"fmt"
"time"
)
func worker(id int, c chan int) {
for {
fmt.Printf("worker %d, received %d\n", id, <-c)
}
}
func channelClose() {
ch := make(chan int, 3)
go worker(0, ch)
ch <- 1
ch <- 2
ch <- 3
ch <- 4
close(ch)
time.Sleep(time.Millisecond)
}
func main() {
channelClose()
}
输出结果:
worker 0, received 1
worker 0, received 2
worker 0, received 3
worker 0, received 4
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
......
执行上边的代码之后,你会看到接收方打印出很多0。这是因为channel一旦close了,接收方还是能从channel中接收到数据,收到的是channel中元素类型的零值,因为我们创建的是一个chan int类型,所以它的零值就是0,因此我们看到打印出来很多的0
所以,我们就需要在接收方从通道中获取数据的时候进行判断,下边对worker函数进行修改,具体如下:
func worker(id int, c chan int) {
for {
n, ok := <-c //n为获取到的具体的数,ok就是,是否还有值(如果close了,就没值了)
if !ok {
break
}
fmt.Printf("worker %d, received %d\n", id, n)
}
}
除了用上边那种判断ok的方式,还可以通过range来遍历通道,等通道中没数据了,就不会再接收了,还是对worker进行修改,具体如下:
func worker(id int, c chan int) {
for n := range c {
fmt.Printf("worker %d, received %d\n", id, n)
}
}
注意,上边都是建立在channel被close的情况,如果没有close,其它goroutine一直发,他就会一直收,直到main执行结束
总结
本篇对channel的介绍,还是比较抽象。后边的文章会对channel进行应用。如何运用channel?Go语言的创作者说过一句话
Don't commuincate by sharing memory;share memory by communicating 不要通过共享内存进行通信;通过通信来共享内存
我们以前一直是通过共享内存来进行通信,比如一件事情做完了,通过跟其他人共享一个flag,将其赋值为true,别人就知道做完了,这就是通过共享内存来进行通信。下边一篇文章会分享,CSP模型基于goroutine和channel,通过通信来共享内存
参考
《Go程序设计语言》—-艾伦 A. A. 多诺万
《Go语言学习笔记》—-雨痕