go语言的channel有一个看上去很奇怪的特性,就是如果向一个为空值(nil)的channel写入或者读取数据,当前goroutine将永远阻塞。例如
func main() {
var ch chan int
ch <- 1 // block forerver
}
func main() {
var ch chan int
<-ch // block forerver
}
func main() {
<-chan int(nil) // block forerver
}
func main() {
chan int(nil)<-1 // block forerver
}
以上四个main函数都会永远阻塞(但是因为没有其他goroutine,所以runtime会报告一个deadlock错误)。
这看上去似乎是一个bug,因为向一个没有初始化的channel读或者写其实是没有意义的。但是为什么go team要这么设计呢?
关于这个问题在golang-nuts上有很多讨论,其中有一组讨论[1]说到其实这个特性(即对nil channel的读写永远阻塞)可以用来比较优雅地实现一个叫做"guarded selective wating"的模式,其实就是条件等待:在select中的一些case中如果对应的条件不满足就不在这个case上等待。假设有这样一个条件等待的需求:
select {
case <-chan_a: // 希望cond_a为真时才在chan_a上等待
// do something
case <-chan_b: // 希望cond_b为真时才在chan_b上等待
// do something
case <-chan_def:
// do something
}
这个模式如果不利用这个特性,也是可以实现的,但是代码就比较冗长难看。其中一种实现可能是这样:
switch {
case cond_a && cond_b:
select {
case <-chan_a:
// do something
case <-chan_b:
// do something
case <-chan_def:
// do something
}
case !cond_a && cond_b:
select {
case <-chan_b:
// do something
case <-chan_def:
// do something
}
case cond_a && !cond_b:
select {
case <-chan_a:
// do something
case <-chan_def:
// do something
}
default:
select {
case <-chan_def:
// do something
}
}
可以看到,实现代码非常的冗长罗嗦容易出错。而且如果case分支更多一些,实现代码的行数会以2的指数的数量爆炸性增长。当然还有其他实现方式,但如果不想办法去故意阻塞一个channel,实现的方法都是大同小异,都有前面说的问题。
但是如果用了nil channel特性,实现起来就可以非常的优雅简洁:
maybe := func(flag bool, ch chan int) <-chan int {
if flag {
return ch
}
return nil
}
select {
case <- maybe(cond_a, chan_a):
// do something
case <- maybe(cond_b, chan_b):
// do something
case <- chan_def:
// do something
}
这里实际是利用了nil channel永远阻塞的特性,但是如果我们创建一个channel,但是不向它写数据也不关闭它,而是只从它读数据,那么也是可以实现永远阻塞的。以下代码实现了同样的效果:
var _BLOCK = make(<-chan int)
maybe := func(flag bool, ch chan int) <-chan int {
if flag {
return ch
}
return _BLOCK
}
select {
case <- maybe(cond_a, chan_a):
// do something
case <- maybe(cond_b, chan_b):
// do something
case <- chan_def:
// do something
}
这也就意味着:对于实现一个"guarded selective wating"模式来说,nil channel的永久阻塞的特性并不是必须的,因为有其他替代实现方式。但是显然用nil channel更方便,也不需要额外浪费资源去创建一个用来永久阻塞的channel。
一些争议:有人说就算nil channel在select里很有用,但是在select之外单独去读写一个nil channel确实是个很奇怪的行为,无论如何看上去都是一个bug。runtime如果在这里产生一个panic而不是永久阻塞,就可以更好地告诉程序员说:嗨,你这里有个bug。如果是永久阻塞的话,这个bug就不会那么容易被注意到。
go team的成员回应说:这个是为了和在select里的行为一致,如果nil channel在select里永久阻塞而在其他地方panic,行为就不一致了,会让程序员感到疑惑;而且这也违反了go1的语言规范;另外读nil channel永久阻塞,和读一个没有数据的channel效果是一样的,如同遍历一个为空值的数组切片或者map和遍历一个长度为0的数组切片或者没有成员的map效果也是一样。
例如:
var s []int // 未初始化,s是一个空值
for k, v := range s {
// do something
}
和
s := []int{} // 已初始化,s长度为0
for k, v := range s {
// do something
}
这两段代码行为是一样的,循环体里的代码都不会执行到,也都不会panic。
我的看法是在select之外的读写nil channel确实是一个bug,至少也是不好的代码风格(如果真有人故意这么用的话)。但是它并不容易在实际中出现,因为我们在使用channel的时候通常是把声明和初始化放在一起的,所以不会是空值;或者channel作为struct的一个成员,声明和初始化是分离的,但是一般也会有一个函数来初始化这个结构。所以在实际编码中并不容易产生读写一个nil channel的bug,这不是一个严重的问题。
[1]https://groups.google.com/forum/?fromgroups=#!topic/golang-nuts/ChPxr_h8kUM有疑问加站长微信联系(非本文作者)