Go错误集锦 | nil通道及其使用场景

1,017 阅读7分钟

大家好,我是「Go学堂」的渔夫子。今天跟大家聊聊nil通道及其正确的使用场景。

原文链接:mp.weixin.qq.com/s/IIruvES-U…

在Go中有时候忘记使用nil通道也是经常犯的一个错误。本节我们一起来看看什么是nil通道,为什么要使用nil通道。

首先,假设我们在一个协程中有如下代码片段:

//初始化的channel值为nil
var ch chan int
<-ch

那么这段代码将会如何执行呢?该ch是int类型。channel的零值是nil,因为ch只是被定义但未被初始化,所以ch当前的值是nil。在Go中,从一个nil的通道中接收消息是合法的操作。该协程不会引发panic;但该协程将会永远被阻塞

如果往一个nil通道中发送消息也遵守同样的原则,该协程也会被永久阻塞:

var ch chan int
ch <- 0

那么,在Go中为什么要允许从nil通道中接收或发送信息呢?我们通过一个具体的示例来讨论该设计的目的。

我们要实现这样一个函数:func merge(ch1, ch2 chan int) chan int,该函数用于将两个通道中的信息合并到一个单一的通道中,即将ch1,ch2中接收到的信息都发送到同一个通道ch中,如下图: 01-合并通道消息.png

好,现在我们来实现该功能。

实现版本一:for循环版

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函数中,我们启动了一个协程,在协程中通过for循环从两个通道中接收消息,然后都发送到ch通道中。

版本一的问题

在这个实现版本中主要问题是我们先从ch1接收信息,然后再从ch2接收信息。也就是说只有在ch1关闭了的情况下,才能收到ch2中的信息,否则就会一直阻塞在ch1中。 这显然不符合我们的使用场景,如果ch1永远不会被关闭,那么ch2中的消息永远就不会被接收到。而我们希望的是从两个通道中都能接收消息。

实现版本二:select版

既然不能使用for循环,我们使用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循环中,所以,我们会重复的从ch1或ch2中接收信息。

版本二的问题

这里存在一个问题就是close(ch)语句永远不会被执行到。通过range循环一个通道的话,当通道被关闭后,range就会结束。但是,如果是使用for/select模式进行循环,即使ch1或ch2关闭了,for循环也不会结束。更糟糕的是,如果在某个时间点,ch1或ch2被关闭,那么用于从合并通道ch中接收的协程将会永远接收到0(因为ch类型是int类型,int的默认零值是0),如下:

received: 0
received: 0
received: 0
received: 0
received: 0
...

为什么呢?因为从一个关闭状态的通道中接收信息是不会被阻塞的

ch1 := make(chan int)
close(ch1)
fmt.Print(<-ch1, <-ch1)

我们可能认为上述代码的执行结果要么是panic,要么是阻塞,但该代码却正常执行了并输出了0 0两个值。实际上,我们从关闭的通道中接收到的是一个代表关闭事件的零值,而非真正的消息。我们可以通过以下代码来检查是接收到的是消息还是关闭事件的零值:

ch1 := make(chan int)
close(ch1)
v, open := <-ch1
fmt.Print(v, open)

通道在输出的时候还有另外一个代表通道是否关闭的状态值:open变量,我们可以通过该值来判断通道是否出于关闭状态:

0, false

同时,如果通道处于关闭状态,那么还会将通道类型的零值赋值给第一个变量。

所以,在实现版本二中,如果ch1关闭,那么该段代码同样也不会按预期的执行。例如,如果select语句选择的是 v := <-ch1,我们会一直阻塞在这里,并往合并的channel中持续发送零值。

实现版本三:状态变量版

既然在版本二中,如果一个通道被关闭后,还会持续的接收对应类型的零值并将其发送到负责合并数据的通道ch中。那么,我们就可以使用一个状态变量来标识通道是否被关闭,当被关闭的时候就不往合并数据通道ch中发送。如下:

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
                }else {
                    ch <- v
                }
                
            case v, open := <-ch2:
                if !open { 
                    ch2Closed = true
                }else {
                    ch <- v
                }
            }
            
            if ch1Closed && ch2Closed { 
                close(ch)
                return
            }
 	}
    }()
 
    return ch
}

在该版本的实现中,我们定义了两个布尔类型的变量 ch1Closed和ch2Closed,分别代表通道ch1和ch2的关闭状态。一旦我们从一个通道中接收到消息,我们就检查该通道是否被关闭。如果是,则就将对应的状态变量(ch1Closed和ch2Closed)设置为true。当两个通道都被关闭后,我们关闭合并结果的通道ch,并终止协程。

版本三的问题

上述代码除了让代码变复杂之外,还有一个主要的问题:当ch1和ch2的任意一个通道被关闭后,即使不往负责合并消息的通道ch发送零值了,但是因为协程依然能从被关闭的通道中接收到零值信息,但是for循环依然会执行下去。例如,如果ch1是被关闭的通道,那么在ch2没有新消息的时候,select会一直选中第一个case语句,会不断的执行从ch1中接收零值,然后break,然后再执行for。同时,也会造成CPU的浪费,因为一直在循环接收空值

实现版本四:

现在,刚nil通道出场了。利用对nil通道读写都会永久阻塞的特性,我们再结合select的多路监听特性来实现。代码如下:

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 
                }else {
                    ch <- v
                }
 			
            case v, open := <-ch2:
                if !open {
                    ch2 = nil
 		}else {
                    ch <- v
                }
            }
 	}
 
        close(ch)
        
     }()
 
     return ch
}

在该实现中,只要有一个通道(ch1或ch2)是开启的状态我们就一直循环。假设ch1被关闭了,我们就将ch1置为nil。因此,在下一次循环中,select语句要么等待ch2有新消息或ch2被关闭接收到关闭的信号。因为ch1是nil,所以不会再被select语句选中。最后,当ch1和ch2都被关闭后,我们就关闭负责合并信息的通道ch。下面是整个实现的流程图: 02-合并流程图.png

在该版本实现中,程序按我们所预期的逻辑进行执行。同时,又解决了版本三中对CPU的浪费的问题。

总之,我们利用的就是往nil通道中发送或接收信息会被阻塞的特性。这种特性在特定的场景下还是很有用的。在我们的示例中,我们就通过将对应的通道(ch1或ch2)置为nil,从而将其从select监听中移除掉的。

如果你喜欢这篇文章,请关注「Go学堂」,获取更多易错集锦。