在Go开发中使用channel的时候,一个容易忽略的点是nil通道有时候是很有帮助的,本节内容将讨论nil通道是什么,以及为什么我们需要关注它。
现在有这样一个goroutine,它将创建nil channel, 然后等待从该通道中接收消息,这会产生什么效果?
var ch chan int
<-ch
ch是一个int类型的通道,它被初始化为nil,所以ch现在是nil.从nil通道中接收消息是有效的操作,goroutine不会产生panic, 然而goroutine将永远被阻塞。同理,向nil通道中发送消息操作,也会永远导致goroutine被阻塞。
var ch chan int
ch <- 0
问题来了,允许从nil通道接收消息或者向nil通道发送消息的目的是什么呢?下面通过一个具体的例子进行说明。
我们将实现一个merge函数,该函数从两个通道接收数据汇总到一个通道中。函数签名为func merge(ch1, ch2 <-chan int) <-chan int
. 从通道ch1和ch2中接收到的数据将发送到返回的通道中。
上述功能在Go语言中怎样实现呢?现在来编写一个简单的版本。启动一个goroutine从两个通道中接收数据,返回的通道缓冲区大小为1.
func merge(ch1, ch2 <-chan int) <-chan int {
ch := make(chan int, 1)
go func() {
for v := range ch1 {
ch <- v
}
for v := range ch2 {
ch <- v
}
close(ch)
}()
return ch
}
在上面merge函数中启动的goroutine从通道ch1和ch2中接收数据,然后将它们发送到返回通道ch中。
上面程序存在的主要问题是,先从ch1中接收数据,然后从ch2中接收数据,在通道ch1被关闭之前,我们是无法从ch2中获取数据的。也就是说这个实现不满足我们的要求,由于ch1可能一直不会关闭,但是我们希望可以并发的从两个通道接收数据。
下面是一个改进版本,采用了select操作,代码如下:
func merge(ch1, ch2 <-chan int) <-chan int {
ch := make(chan int, 1)
go func() {
for {
select {
case v := <-ch1:
ch <- v
case v := <-ch2:
ch <- v
}
}
close(ch)
}()
return ch
}
select语句可以同时监听多个通道,将select放在for循环中,可以反复的从两个通道其一接收消息。上述代码有啥问题吗?能正确工作吗?
上面代码存在的一个问题是close(ch)
语句是不可达的,它永远不会被执行。通过range遍历通道的时候,当通道被关闭的时候,range循环会自动结束。然而,上面的程序采用的是for+select操作,当ch1或ch2被关闭的时候,是感知不到的。更糟糕的是,如果ch1或ch2通道被关闭了,将会从接收通道ch中收到一系列的0,打印出接收值日志如下。
received: 0
received: 0
received: 0
received: 0
received: 0
...
为啥会从接收通道ch中收到一串的0呢? 首先知道一点,从一个关闭的通道接收数据是一个非阻塞的操作。下面的程序会打印出0,0.
ch1 := make(chan int)
close(ch1)
fmt.Print(<-ch1, <-ch1)
尽管你期望上述代码在运行时出现panic或者被阻塞,但实际上上述代码输出0,0. 并且这里获取的0值并不是真正的实际消息,而是通道被关闭后产生的事件。要检查收到的数据是真正的消息还是关闭信号,可以通过下面的操作。open变量记录了通道是否被关闭,如果被关闭,open值为false,与此同时,此时v的值为0,因为int类型的零值是0.
ch1 := make(chan int)
close(ch1)
v, open := <-ch1
fmt.Print(v, open)
运行上面的程序数据结果为:
0 false
现在开始讨论原始问题的第二种解决方法。前面说了如果ch1被关闭了,代码运行的效果不是我们期望的。因为select操作匹配上了v:=<-ch1
,会将收到的0值发送到返回通道ch中。
现在来梳理下解决上面问题的最佳方法是啥,如下图所示。
我们需要从两个通道中接收数据,然后如果:
如何用Go语言实现这里的逻辑呢?可以采用状态机方法,定义bool类型的变量,记录通道是否被关闭,实现代码如下:
func merge(ch1, ch2 <-chan int) <-chan int {
ch := make(chan int, 1)
ch1Closed := false
ch2Closed := false
go func() {
for {
select {
case v, open := <-ch1:
if !open {
ch1Closed = true
break
}
ch <- v
case v, open := <-ch2:
if !open {
ch2Closed = true
break
}
ch <- v
}
if ch1Closed && ch2Closed {
close(ch)
return
}
}
}()
return ch
}
上面程序中,定义了两个bool类型的变量ch1Closed和ch2Closed.一旦从任何一个通道中收到消息,都检查一下通道是否被关闭,如果被关闭,将标记该通道被关闭,例如设置ch1Closed=true.一旦两个通道都关闭了,将关闭返回通道ch并返回,停止goroutine运行。
上述代码有什么问题吗?除了开始变得复杂外,还有一个主要问题:当两个通道任何一个关闭时,for循环将导致通道忙等待,这会导致另一个通道即使没有收到任何消息,也会继续循环。我们需要注意程序中select语句的行为,假设ch1已关闭(不会从此通道收到任何新消息)。一旦CPU再次运行到达select,它将等待下面三个条件其中一个发生:
因为第一个条件ch1已关闭总是成立的,只要通道ch2中没有新消息或者没有被关闭,这将导致继续执行循环中第一种情况,会造成CPU空转浪费,应该要避免。所以说上面的程序不可行。
我们可以对上面的程序进行修改,增强状态机部分处理逻辑,在每个case下继续通过for+select处理。但这会使得代码更加复杂难以处理。
现在是nil channel派上用场的好时机,正如前面提到的,从零通道接收数据将会永远阻塞,可以利用这个特性对上面的程序进行改进。一旦通道关闭,不是设置布尔值,而是将此通道设置为nil,实现代码如下:
func merge(ch1, ch2 <-chan int) <-chan int {
ch := make(chan int, 1)
go func() {
for ch1 != nil || ch2 != nil {
select {
case v, open := <-ch1:
if !open {
ch1 = nil
break
}
ch <- v
case v, open := <-ch2:
if !open {
ch2 = nil
break
}
ch <- v
}
}
close(ch)
}()
return ch
}
只要通道ch1和ch2中任何一个没有关闭,都会继续进行循环。例如,如果ch1被关闭,它将会被赋值为nil. 在下一次循环中,select语句只会等待下面的两种情况:
ch1是一个nil通道,所以它永远不会case成功。同理,为ch2通道进行同样的逻辑操作,当ch2被关闭后,也将它设置为nil. 最后当两个通道都被关闭时,终止循环,关闭通道ch.
这正是我们期望实现的效果,它考虑到了各种情况并进行了处理,不会导致CPU空转浪费。
总结,向一个nil通道发送消息或者从nil通道接收消息都是一种阻塞操作,这种行为并不是没有任何用处的。本文通过一个具体的例子,将来自两个通道中的数据合并到一个通道,可以使用nil通道实现一个优雅的状态机,避免在case中继续嵌套一个for+select语句。这让我们认识到,nil通道在某些情况下确实有用,在处理并发代码时应该成为Go开发人员手中的一把有力工具。