Go 的 select 不保证公平性,因伪随机遍历和持续就绪 channel 导致某些 case 长期饿死;解决方法是用轮询封装替代直接 select,通过独立 goroutine + 调度器实现主动公平。

Go 的 select 本身不保证公平性,多个就绪的 case 可能被反复选中,而其他 case 长期得不到执行——这不是 bug,是设计使然。解决饥饿,关键不是“让 select 变公平”,而是重构调度逻辑,避开对 select 均匀性的依赖。
为什么 select 会饿死某些 case?
Go 运行时在 select 中遍历 case 时采用伪随机顺序(实际是轮询索引 + 随机偏移),但一旦某个 channel 持续就绪(比如缓冲通道未清空、或写端不断发数据),它就大概率每次都被选中。没有“老化计数”或“上次执行时间”机制,也没有优先级权重。
- 常见错误现象:
select里混用无缓冲通道和带缓冲通道,后者几乎永远胜出 - 使用场景:消息分发器、多路健康检查、混合读写操作的代理层
- 性能影响:看似吞吐高,实则部分业务路径延迟飙升,监控上表现为 P99 毛刺持续存在
用缓冲通道 + 轮询代替纯 select
把“谁先就绪谁上”改成“轮流点名”,主动控制公平性。核心是把每个通道封装成独立 goroutine + 缓冲队列,再由一个调度 goroutine 定期轮询它们。
- 不要直接在
select中监听原始 channel,而是监听每个封装后的chan struct{}通知信号 - 每个封装 goroutine 负责从真实 channel 收/发一次,然后发信号给调度器:“我处理完了,轮到别人了”
- 示例片段:
for range []int{0, 1, 2} { // 固定顺序轮询 select { case msg := <-ch1: handle(msg) default: } select { case msg := <-ch2: handle(msg) default: } // ... 同理 ch3 }
加权轮询与 context 超时兜底
纯轮询太机械;真实业务中,有些通道确实该多分资源(如用户请求通道),有些可降级(如日志上报)。这时需要加权,且必须配超时——否则某个慢通道卡住,整个轮询就阻塞。
- 权重不写死在代码里,建议用配置驱动,例如:
weightMap = map[string]int{"user": 3, "metrics": 1, "debug": 0} - 每个 channel 操作必须带
context.WithTimeout(ctx, 100*time.Millisecond),防止单次阻塞拖垮整轮 - 容易踩的坑:忘记重置 timer、在 for-select 循环里重复创建 context、超时后没清空 channel 缓冲区导致后续误触发
goroutine 泄漏比饥饿更隐蔽
很多所谓“饥饿”,其实是 goroutine 卡死没退出,导致新任务进不来。尤其当用 select + default 做非阻塞尝试时,若没配合 context.Done() 检查,就会无限空转。
- 典型泄漏模式:
go func() { for { select { case <-ch: process() default: time.Sleep(1 * time.Millisecond) // 错!没检查退出信号 } } }() - 正确做法:所有长期运行的 goroutine 必须监听
ctx.Done(),哪怕只是做case - 调试时优先跑
go tool trace看 Goroutine 数量趋势,再查 pprof/goroutine stack,别一上来就调select逻辑


















