本节摘要: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) } } } // 运行输出: // 监听台收到 日志:磁盘清理完成 // 监听台收到 指标:缓存命中率下降
语义三条,条条有讲究:
随机选择是 Go 的独特设计:如果固定优先级,繁忙的窗口会饿死冷门窗口;随机化把公平性做进了语言,不需要使用者操心。

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 每次调用都会新建一个定时器,到点前一直被运行时持有。放在万次级的热循环里,就是几万个活到循环结束的定时器挂在内存里——延迟不重、内存先爆。热路径的正规姿势是复用一个 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 要替你标准化的能力。
监听台解决"等谁"的问题,还剩一类工具解决"都盯着一处共享状态"的问题——下一节的 sync 包,签到板与钥匙柜。