3.4 Select:多路监听台


3.4 Select:多路监听台

本节摘要:select 让一个 goroutine 同时监听多个 channel 操作,哪路就绪走哪路,多路同时就绪则随机选择。本节讲语法语义与三大惯用法——超时、非阻塞、心跳轮询,并给出热循环里定时器的正确姿势。学完本节,你的并发程序第一次拥有"对多路事件做裁决"的能力。

窗口一多,人只有一个:值班台前坐着几十路窗口,调度员不可能轮流死等。select 就是那张多路监听台——语法长得像 switch, case 挂的不是值而是 channel 操作。它把"同时等待多路事件"从复杂的回调嵌套压平成一段直线代码,是超时控制、优雅退出、限流聚合三类需求的共同底座。

语法与语义:哪路就绪走哪路

package main import ( "fmt" "time" ) func main() { metrics := make(chan string) logs := make(chan string) go func() { time.Sleep(30 * time.Millisecond) metrics <- "指标:缓存命中率下降" }() go func() { time.Sleep(10 * time.Millisecond) logs <- "日志:磁盘清理完成" }() for i := 0; i < 2; i++ { select { case m := <-metrics: fmt.Println("监听台收到", m) case l := <-logs: fmt.Println("监听台收到", l) } } } // 运行输出: // 监听台收到 日志:磁盘清理完成 // 监听台收到 指标:缓存命中率下降

语义三条,条条有讲究:

  • 阻塞等待:select 停在原地,直到某一个 case 的通信可以进行。
  • 多路就绪随机选:多路同时可走时随机挑一路,语言层面强制均匀——保证没有哪一路会被永久饿死。
  • 空 select 永久阻塞:一个 case 都没有的 select 是合法语句,等于无限等待,偶尔用作纯阻塞。

随机选择是 Go 的独特设计:如果固定优先级,繁忙的窗口会饿死冷门窗口;随机化把公平性做进了语言,不需要使用者操心。

图 3-4:监听台的一次裁决

图 3-4:监听台的一次裁决

三大惯用法:超时、非阻塞、轮询

select 的 case 除了收发 channel,还能配 time 包的两件工具,撑起最常见的三种模式:

package main import ( "fmt" "time" ) func main() { work := make(chan string) go func() { time.Sleep(80 * time.Millisecond) // 模拟慢任务 work <- "结果" }() // 惯用法一:超时控制。time.After 到点向返回的窗口发一个值 select { case r := <-work: fmt.Println("按时完成:", r) case <-time.After(50 * time.Millisecond): fmt.Println("超时,放弃等待") } // 惯用法二:非阻塞探测。default 让 select 变成立即返回 msg := make(chan int, 1) select { case v := <-msg: fmt.Println("取到:", v) default: fmt.Println("当前无货,先去干别的") } } // 运行输出: // 超时,放弃等待 // 当前无货,先去干别的

第三种惯用法是轮询心跳,for 加 select 加定时器构成并发程序的骨架循环:

func serve(job <-chan string, done <-chan struct{}) { ticker := time.NewTicker(100 * time.Millisecond) // 周期心跳 defer ticker.Stop() for { select { case j := <-job: fmt.Println("处理:", j) case <-ticker.C: fmt.Println("心跳:还活着") case <-done: // 收到收工信号即退出,for-select 的标准出口 fmt.Println("监听台下班") return } } } // 每百毫秒输出一次心跳,有活干活,有信号走人

退出分支放在 for-select 里几乎是模板句:并发循环的每一轮都先问一句"该下班了吗"。

一个热循环陷阱:time.After 不退款

time.After 每次调用都会新建一个定时器,到点前一直被运行时持有。放在万次级的热循环里,就是几万个活到循环结束的定时器挂在内存里——延迟不重、内存先爆。热路径的正规姿势是复用一个 Timer:

timer := time.NewTimer(timeout) defer timer.Stop() for task := range tasks { timer.Reset(timeout) // 复用同一个定时器 select { case <-task: // 处理结果 case <-timer.C: // 超时处理 } }

低频路径(每次请求一次超时控制)用 time.After 完全没问题,代码更短;高频路径才需要这层手工管理。两个 API 的分界就是循环频次。

完整案例:双源竞速,谁快用谁

背景:调度台要取一份配置,同一份配置存了两个镜像,希望总耗时等于较快的那一个,而不是傻等慢源。

操作:同时向两个镜像发起请求,各把结果发进自己的窗口;select 竞速取第一个到达者,另一个的结果作废——注意要用缓冲窗口接住作废结果,否则慢源 goroutine 会永远卡在发送上,形成泄露(第 4 章正式展开)。

package main import ( "fmt" "time" ) func fetch(name string, latency time.Duration, out chan<- string) { time.Sleep(latency) // 模拟网络耗时 out <- name + " 的配置" } func main() { fast := make(chan string, 1) // 缓冲 1:慢源的迟到结果有处可去 slow := make(chan string, 1) go fetch("镜像A", 30*time.Millisecond, fast) go fetch("镜像B", 90*time.Millisecond, slow) select { case r := <-fast: fmt.Println("采用:", r) case r := <-slow: fmt.Println("采用:", r) } fmt.Println("调度台:配置就绪") } // 运行输出: // 采用: 镜像A 的配置 // 调度台:配置就绪

结果:总耗时约等于镜像 A 的延迟,镜像 B 的迟到结果落进缓冲后随程序结束被回收。

解读:竞速模式把"多路就绪随机选"的语义变成了性能工具——不是随机,是"谁快谁赢"。缓冲窗口在这里是防泄露的关键细节:无缓冲时,慢源发送会一直阻塞到程序结束,goroutine 与其栈都释放不掉;缓冲一格,它发完即退,干干净净。

变式:竞速加上超时上限——再添一路 case <-time.After(150ms),三路 select,全部超时则返回默认配置。三行改动,你就得到了一个带降级的配置读取器,这正是 3.6 节 context.WithTimeout 要替你标准化的能力。

收班要点

  • select 阻塞等待多路,多路就绪随机放行,公平性由语言保证。
  • 超时用 time.After,非阻塞加 default,轮询用 for-select 加 Ticker,三大惯用法覆盖多数场景。
  • for-select 循环里放一个退出分支,是并发循环的标准收口。
  • 热循环复用 NewTimer 加 Reset,time.After 只留给低频路径。
  • 竞速模式记得给落败方留缓冲,别让迟到者卡死在发送上。

监听台解决"等谁"的问题,还剩一类工具解决"都盯着一处共享状态"的问题——下一节的 sync 包,签到板与钥匙柜。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U