如何高效地select读取任意数量的channel

本文探讨了Go语言中如何在运行时监听动态创建的channel,介绍了使用reflect.Select解决动态chan监听的问题,以及如何通过ChanWatcher实现多路复用,包括固定和可变数量channel的监听,以及自定义回调和类型处理的优化策略。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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的实现,还有两个问题没有解决:

  1. 当前接收到chan事件后的处理逻辑是耦合在chanWatcher里的(上述实现中的fmt.Println),如何实现chan自定义的业务过程,避免侵入chanWatcher代码呢?
  2. 目前接收到的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同时监听
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值