对select在多个通道中的行为做出错误的假设是Go开发人员常犯的的一个错误,这种错误的假设可能会导致难以识别和重现的细微错误。假设我们想要实现一个需要从两个通道接收信息的goroutine,两个通道的作用如下:
在这两个通道中,希望messageCh优先,例如,如果发生断开连接,希望在返回之前确保已收到所有消息。也许你会写如下这样的代码来处理优先级:
for {
select {
case v := <-messageCh:
fmt.Println(v)
case <-disconnectCh:
fmt.Println("disconnection, return")
return
}
}
使用select从多个通道接收数据,由于考虑到有优先级,messageCh优先,所以在case语句中,将从messageCh中接收消息写在第一个位置,disconnectCh写在第二个位置。但是这段代码是有效的吗?下面通过一个生产者发送10条消息,然后发送断开连接通知进行验证。
for i := 0; i < 10; i++ {
messageCh <- i
}
disconnectCh <- struct{}{}
运行上述代码,messageCh是缓冲通道,下面是在我的机器上运行的输出,完整的代码见(https://github.com/ThomasMing0915/100-go-mistakes-code/tree/main/64)
0
1
2
3
4
5
6
disconnect, return
通过输出结果可以看到,只消费了7条消息,那其他3条去哪里了?是什么原因导致的?原因在于多通道时,select语句是不保证顺序的。<< Programming Language Specification>>书中有下面的表述,这里与switch语句不一样,switch语句是选择第一个匹配的。但是在select语句中,如果有多个匹配,会随机选取一个。
❝如果一个或多个通道可以进行,select会通过统一的伪随机选择一个可以进行的通道。 ❞
这种处理方法一开始可能看起来比较奇怪,但有一个解释的理由:防止可能的饥饿。假设选择的第一个可能的通道是基于代码顺序的。在这种情况下,就可能会存在这种情况。例如,由于发送者速度快,会存现只能从一个通道接收,为了防止这种情况,在语言设计的时候作者决定使用随机选择。
回到前面的程序,即使case v:= <- messageCh
是源顺序中的第一个,如果messageCh和disconnectCh中都有消息,则不能保证会选择哪个,所以前面的程序输出结果是不确定的,可能收到0个消息,也有可能收到5个消息,甚至可能收到10个消息,对此语言层面没有任何保证。
如何处理上面的问题呢?有多种方法可以解决在断开连接之前接收到所有消息。如果只有一个生产者,有两种处理思路:
如果有多个生产者,上面的处理思路就不行了,在有多个生产者goroutine的情况下,无法保证哪个goroutine先写。因此,无论是无缓冲的通道还是单个通道,都会导致生产者goroutine之间存在竞争。这种情况,可以用下面的解决方法:
for {
select {
case v := <-messageCh:
fmt.Println(v)
case <-disconnectCh:
for {
select {
case v := <-messageCh:
fmt.Println(v)
default:
fmt.Println("disconnection, return")
return
}
}
}
}
上述代码使用了两层for+select结构,外层的for+select处理messageCh有消息且disconnectCh没有消息的情况,内层的for+select处理收到了断开消息之后,仅当其他情况都不匹配时,才会选择select语句中的default执行,这能保证我们只有在收到messageCh中所有的剩余消息后才会返回。
下面通过一个可视化的演示来看看上述代码是如何运行的。演示的是messageCh中有两条消息和disconnectCh中有一条断开连接消息的情况。
在上图中,由于messageCh和disconnectCh中都有消息,所以会随机选择其中一个通道,现在假定选择执行的是第二通道。
现在程序会从disconnectCh中接收消息,进入内部的for+select语句中。
只要messageCh中有数据,select语句总是会执行第一个case,而不是default语句,直到接收完messageCh通道中的全部数据,才会进入default.
最后,当从messageCh接收完全部数据之后,select语句不会被block,而是选择default分支执行。
这种方法可以确保在具有多个通道的情况下,接收者可以从通道接收完所有剩余消息。当然,如果在goroutine返回之后发送消息到messageCh(例如在有多个生产者goroutine的时候),是收不到后续消息的。
总结,当select语句中有多个通道时,需要注意到,选择哪个通道是不确定的,并不是代码中写在前面的优先于后面的,因为会随机选择。在单个生产者goroutine的情况,解决这种问题的方法是使用无缓冲通道或者使用单个通道。在有多个生产者goroutine的情况下,可以使用双层for+select处理,内层for+select语句结合default处理优先级。