前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go语言中常见100问题-#66 Not using nil channels

Go语言中常见100问题-#66 Not using nil channels

作者头像
数据小冰
发布2022-08-15 15:25:10
3560
发布2022-08-15 15:25:10
举报
文章被收录于专栏:数据小冰
忽视nil通道使用

在Go开发中使用channel的时候,一个容易忽略的点是nil通道有时候是很有帮助的,本节内容将讨论nil通道是什么,以及为什么我们需要关注它。

现在有这样一个goroutine,它将创建nil channel, 然后等待从该通道中接收消息,这会产生什么效果?

代码语言:javascript
复制
var ch chan int
<-ch

ch是一个int类型的通道,它被初始化为nil,所以ch现在是nil.从nil通道中接收消息是有效的操作,goroutine不会产生panic, 然而goroutine将永远被阻塞。同理,向nil通道中发送消息操作,也会永远导致goroutine被阻塞。

代码语言:javascript
复制
var ch chan int
ch <- 0

问题来了,允许从nil通道接收消息或者向nil通道发送消息的目的是什么呢?下面通过一个具体的例子进行说明。

我们将实现一个merge函数,该函数从两个通道接收数据汇总到一个通道中。函数签名为func merge(ch1, ch2 <-chan int) <-chan int. 从通道ch1和ch2中接收到的数据将发送到返回的通道中。

上述功能在Go语言中怎样实现呢?现在来编写一个简单的版本。启动一个goroutine从两个通道中接收数据,返回的通道缓冲区大小为1.

代码语言:javascript
复制
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操作,代码如下:

代码语言:javascript
复制
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,打印出接收值日志如下。

代码语言:javascript
复制
received: 0
received: 0
received: 0
received: 0
received: 0
...

为啥会从接收通道ch中收到一串的0呢? 首先知道一点,从一个关闭的通道接收数据是一个非阻塞的操作。下面的程序会打印出0,0.

代码语言:javascript
复制
ch1 := make(chan int)
close(ch1)
fmt.Print(<-ch1, <-ch1)

尽管你期望上述代码在运行时出现panic或者被阻塞,但实际上上述代码输出0,0. 并且这里获取的0值并不是真正的实际消息,而是通道被关闭后产生的事件。要检查收到的数据是真正的消息还是关闭信号,可以通过下面的操作。open变量记录了通道是否被关闭,如果被关闭,open值为false,与此同时,此时v的值为0,因为int类型的零值是0.

代码语言:javascript
复制
ch1 := make(chan int)
close(ch1)
v, open := <-ch1
fmt.Print(v, open)

运行上面的程序数据结果为:

代码语言:javascript
复制
0 false

现在开始讨论原始问题的第二种解决方法。前面说了如果ch1被关闭了,代码运行的效果不是我们期望的。因为select操作匹配上了v:=<-ch1,会将收到的0值发送到返回通道ch中。

现在来梳理下解决上面问题的最佳方法是啥,如下图所示。

我们需要从两个通道中接收数据,然后如果:

  • ch1被先关闭,需要从ch2中接收数据,直到ch2被关闭
  • ch2被先关闭,需要从ch1中接收数据,直到ch1被关闭

如何用Go语言实现这里的逻辑呢?可以采用状态机方法,定义bool类型的变量,记录通道是否被关闭,实现代码如下:

代码语言:javascript
复制
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有新消息
  • ch2已关闭

因为第一个条件ch1已关闭总是成立的,只要通道ch2中没有新消息或者没有被关闭,这将导致继续执行循环中第一种情况,会造成CPU空转浪费,应该要避免。所以说上面的程序不可行。

我们可以对上面的程序进行修改,增强状态机部分处理逻辑,在每个case下继续通过for+select处理。但这会使得代码更加复杂难以处理。

现在是nil channel派上用场的好时机,正如前面提到的,从零通道接收数据将会永远阻塞,可以利用这个特性对上面的程序进行改进。一旦通道关闭,不是设置布尔值,而是将此通道设置为nil,实现代码如下:

代码语言:javascript
复制
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语句只会等待下面的两种情况:

  • ch2有新消息
  • ch2被关闭

ch1是一个nil通道,所以它永远不会case成功。同理,为ch2通道进行同样的逻辑操作,当ch2被关闭后,也将它设置为nil. 最后当两个通道都被关闭时,终止循环,关闭通道ch.

这正是我们期望实现的效果,它考虑到了各种情况并进行了处理,不会导致CPU空转浪费。

总结,向一个nil通道发送消息或者从nil通道接收消息都是一种阻塞操作,这种行为并不是没有任何用处的。本文通过一个具体的例子,将来自两个通道中的数据合并到一个通道,可以使用nil通道实现一个优雅的状态机,避免在case中继续嵌套一个for+select语句。这让我们认识到,nil通道在某些情况下确实有用,在处理并发代码时应该成为Go开发人员手中的一把有力工具。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-06-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 数据小冰 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 忽视nil通道使用
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档