1. 引言
众所周知,go中的select关键字可以同时监听多个chan的事件,像下面这样
c1 := make(chan int, 1)
for {
select {
case v1 := <-c1:
// do something with v1
// but..what about c2、c3、c4... created at runtime
}
}
但你可能有过这种困惑,有时chan是动态创建的,如何在运行时来监听这种动态创建的chan呢?go协程的代价很低,所以通常的做法就是为每个动态创建的chan,启动一个独立协程,来处理其读事件。
func watchChan(c chan int){
go func(cc chan int){
for {
select {
case v := <-cc:
}
}
}(c)
}
但是协程代价低并不等于没有,在我遇到的应用场景中,当协程数来到6w+的时候,我的4核8G内存主机是扛不住了。而且这些协程大部分其实都在等待读chan,并没有真正工作,占用了大量内存。
某天顺手询问了chatgpt看看有么有办法,它推荐使用reflect.Select来解决动态数量chan监听的问题,经过一顿叮叮当当造了个轮子,那么今天,ta来了。
伸手党可移步最后【6. 开箱即用的ChanWatcher】
2. 了解reflect.Select
函数原型如下:
func Select(cases []SelectCase) (chosen int, recv Value, recvOK bool)
以下是官方对该函数的说明:
Select executes a select operation described by the list of cases. Like the Go select statement, it blocks until at least one of the cases can proceed, makes a uniform pseudo-random choice, and then executes that case. It returns the index of the chosen case and, if that case was a receive operation, the value received and a boolean indicating whether the value corresponds to a send on the channel (as opposed to a zero value received because the channel is closed). Select supports a maximum of 65536 cases.
它的入参是reflect.SelectCase列表,reflect.SelectCase就是反射方式表达的chan变量。函数调用后reflect.Select会阻塞,其中任意一个chan收到消息,Select会立即返回。
返回值有三个,第一个是接收到事件的chan在cases slice中的索引位置,后两个返回值与正常编码的chan读取返回值一致:recv是从chan接收到的数据,recvOk是chan的状态是否ok,如果为true表示正常,false表示chan被关闭了。
其实本质上select就是多路复用,多路复用你懂的,那不想整多少chan,就能整多少chan么。只需要一个reflect.Select阻塞调用,一个goroutine不就搞定了么。
3. 监听固定数量的chan
先来看看reflect.Select的基本使用姿势。创建三个chan,并通过reflect.Select监听事件。
func watchChans(cs ...chan int)
func main() {
c1 := make(chan int, 1)
c2 := make(chan int, 1)
c3 := make(chan int, 1)
go watchChans(c1, c2, c3)
<-time.After(time.Minute)
}
当前目标是通过reflect.Select方法,在watchChans中不创建新协程的情况下,实现对c1、c2、c3三个chan的监听。下面来看watchChans的实现。
func watchChans(cs ...chan int) {
chans := make([]reflect.SelectCase, 0, len(cs))
for _, c := range cs {
selectCase := reflect.SelectCase{
Dir: reflect.SelectRecv, // 在select上做'读'操作
Chan: reflect.ValueOf(c), // chan自身
}
// 添加到slice
chans = append(chans, selectCase)
}
for {
// 阻塞监听chan事件
chosen, recv, ok := reflect.Select(chans)
if !ok {
chans = append(chans[:chosen], chans[chosen+1:]...) // 从chans中移除已经关闭的chan
continue
}
// do something with 'recv', eg fmt.Println
fmt.Println(recv.Interface())
}
}
上述代码将chan数组,转换成了SelectCase结构,并同时监听这些chan的事件,只用一个协程就解决了多个chan事件监听的问题。
4. 监听可变数量的chan
这次我们提出更高一点的要求,希望可以动态塞入任意数量的chan,并且可以接收到这些chan的事件。使用姿势如下:
// ChanWatcher接口定义
type ChanWatcher interface{
AddChans(...chan int)
}
func main() {
c1 := make(chan int, 1)
c2 := make(chan int, 1)
c3 := make(chan int, 1)
//...
chanWatcher := NewChanWatcher()
chanWatcher.AddChans(c1, c2, c3) // 立即返回
// write to c1、c2、c3...
<-time.After(time.Minute)
}
与watchChans不同,这次我们定义一个ChanWatcher的接口,其中包含一个AddChans的方法。
期望在运行期可以随时通过AddChans,向ChanWatcher添加新chan,并且要求AddChan不能阻塞住用户协程。ChanWatcher可以动态监听所有通过AddChans添加的channel。下面是对ChanWatcher的一个实现示例:
// 创建ChanWatcher
func NewChanWatcher() ChanWatcher {
cw := new(chanWatcher)
cw.init()
return cw
}
// ChanWatcher接口实现
type chanWatcher struct {
sync.Once
selectCases []reflect.SelectCase // select监听的slice
temp []reflect.SelectCase // 异步chan添加slice
sync.Mutex
}
func (cw *chanWatcher) init() {
cw.Do(func() {
cw.selectCases = make([]reflect.SelectCase, 0)
cw.temp = make([]reflect.SelectCase, 0)
cw.selectCases = append(cw.selectCases,
reflect.SelectCase{
Dir: reflect.SelectDefault, // 添加default
})
go cw.doWatch()
})
}
func (cw *chanWatcher) doWatch() {
for {
// 阻塞监听chan事件
chosen, recv, ok := reflect.Select(cw.selectCases)
// 触发select的chan
c := cw.selectCases[chosen]
if c.Dir == reflect.SelectDefault {
// 如果是default分支,则将异步添加的chan追加到selectCases
cw.Lock()
cw.selectCases = append(cw.selectCases, cw.temp...)
cw.temp = cw.temp[:0]
cw.Unlock()
// 避免default被频繁命中拉高
continue
}
if !ok {
cw.Lock()
cw.selectCases = append(cw.selectCases[:chosen], cw.selectCases[chosen+1:]...) // 从chans中移除已经关闭的chan
cw.Unlock()
continue
}
// do something with 'recv', eg fmt.Println
fmt.Println(recv.Interface())
}
}
func (cw *chanWatcher) AddChans(cs ...chan int) {
cw.Lock()
defer cw.Unlock()
for _, c := range cs {
cw.temp = append(cw.temp, reflect.SelectCase{
Dir: reflect.SelectRecv,
Chan: reflect.ValueOf(c),
})
}
}
chanWatcher类型实现了ChanWatcher接口,其中包含两个主要数据结构,一个是selectCases,reflect.Select主要作用在这个slice上,另一个temp是用来异步地添加待监听chan的slice。为了保障线程安全,为这两个数据结构添加了互斥锁。
AddChans首先将待监听chan添加到temp数组。当chanWatcher初始化后,会启动一个协程,selectCases里默认添加了default分支,确保reflect.Select不会完全阻塞。当doWatch中命中了default分支时,我们将temp中的chan追加到selectCases数组。如果命中的不是default分支,则执行相应的自定义业务操作(示例中的fmt.Println)。
5. 通用的ChanWatcher实现
我们解决了动态添加的问题,但是ChanWatcher的实现,还有两个问题没有解决:
- 当前接收到chan事件后的处理逻辑是耦合在chanWatcher里的(上述实现中的fmt.Println),如何实现chan自定义的业务过程,避免侵入chanWatcher代码呢?
- 目前接收到的chan只能是chan int类型,如何处理任意类型的chan呢?
第一个可以通过传入闭包方法或自定义接口,来实现业务的自定义回调过程,在触发自定义回调过程时,最好是启动新协程,避免doWatch因某个chan的处理过程慢,而影响其他chan的事件接收。第二个问题,可以通过将AddChans的入参修改为interface{}类型来实现,当然如果要进行一些额外的入参类型校验。
更加通用的ChanWatcher实现代码这里不贴了,动手能力强的可以自己实现下试试,伸手党的话请看下一节。
自己实现的话,除了上面两个功能性的问题,还要注意一个坑。reflect.Select支持最多65535个chan,如果chan的数量较多,可以创建多个ChanReceiver来处理,或者再封装一层,来实现无数量限制的chan监听。
还有就是这里只考虑了多路读复用,没有考虑多路写,如果对写channel有类似需求,只能自己动手啦。
6. 开箱即用的ChanWatcher
地址: chan_watcher
功能:
- 支持任意类型的可读chan监听
- 支持自定义回调过程和自定义回调参数
- 最多支持65535个chan同时监听