select:多路通道的调度器
select 是 Go 为并发场景设计的控制结构,语法类似 switch,但所有 case 必须是 channel 的发送或接收操作。它的核心行为是:阻塞等待直到至少一个 case 就绪,然后执行那个就绪的分支;如果多个 case 同时就绪,则伪随机选择一个执行。
基本多路复用
func main() { |
ch1 在 1 秒后就绪,ch2 在 2 秒后就绪,因此 select 会先执行 ch1 的分支。
随机性与公平性
当多个 case 同时就绪时,Go 运行时不会按书写顺序选择,而是伪随机挑选一个。这是有意为之的设计,避免某个通道长期“饥饿”。
ch1 := make(chan string, 1) |
不要依赖 case 的书写顺序来实现优先级。如果需要优先级,应当用嵌套 select 显式表达。
default:非阻塞的“尝试”
不加 default 的 select 在没有 case 就绪时会一直阻塞;加上 default,就变成非阻塞操作:所有 case 都未就绪时立即执行 default,不会等待。
select { |
这种模式常用于“尝试接收”(try-receive)、“尝试发送”(try-send),以及在轮询循环中顺便处理其他工作。
超时控制:select + time.After
这是 select 最经典的用法之一。time.After(d) 返回一个 channel,在 d 时间后向其中发送一个时间值。把它放进 select,就可以为可能无限阻塞的操作设置“上限”。
select { |
如果 ch 在 1 秒内没有数据,time.After 的 channel 先就绪,select 执行超时分支,避免永久阻塞。
空 select:永久阻塞
select {} |
没有任何 case 的 select 会让当前 goroutine 永久阻塞。这看似“无用”,但在某些场景下用于阻塞主 goroutine,防止程序提前退出(类似一个极简的守护)。
nil channel:动态禁用分支
nil channel 上的发送和接收永远阻塞,因此 select 中指向 nil channel 的 case 永远不会被选中。利用这一点,可以在不关闭通道的情况下动态“开关”某个分支。
var activeJobs <-chan Job // 零值为 nil |
进入一次 select 时,各 case 的 channel 操作数已经求值完毕,分支内的赋值影响的是下一轮 select,不会改变本轮的选择。
select 在循环中
select 常与 for 搭配,实现持续监听多个事件源:
for { |
这种结构是 worker、事件循环、合并多通道(fan-in)等模式的基础。
休眠等待:time 包中的几种方式
“休眠等待”在 Go 中并不只有 time.Sleep。根据场景不同,time.Sleep、time.After、time.Timer、time.Ticker 各有分工。
time.Sleep:直接暂停当前 goroutine
time.Sleep(d) 让当前 goroutine 至少暂停 d 时长。它是最直接的“等待”方式,不依赖 channel,也无法被中途取消。
fmt.Println("start") |
time.Sleep 的本质是把当前 goroutine 从运行队列中移出,直到定时器到期后再重新调度。如果传入负数或零,会立即返回。
注意:time.Sleep 的“至少”意味着它不保证精确的时长,实际恢复时间可能略长于 d,取决于调度时机。
time.After:一次性等待的 channel 形式
time.After(d) 返回一个 <-chan Time,d 时间后向其中发送一个时间值。它的价值在于可以与 select 无缝集成,从而让等待变成可被其他事件打断的操作。
select { |
如果只是单纯等待、不需要被取消或与其他事件竞争,time.Sleep 更直接;如果需要等待的同时还能响应其他事件,time.After 是标准选择。
一个常见误解是 time.After 创建的定时器在超时前无法主动回收(底层 Timer 到期后才可被 GC),因此在高频循环中反复调用 time.After 可能造成资源压力。这种场景应改用可复用的 time.Timer。
time.Timer:可停止、可重置的一次性定时器
time.NewTimer(d) 创建一个一次性定时器,到期后向 timer.C 发送时间。与 time.After 相比,它提供了 Stop() 和 Reset(),适合需要主动取消或重置的等待场景。
timer := time.NewTimer(2 * time.Second) |
Reset 的语义需要小心:在已到期或已停止的 timer 上重置,通常需要先确保 timer.C 已被排空,否则可能读到旧值。如果只需要“等一个结果,最多等多久”,time.After 的写法更简洁;如果需要提前取消,才需要 Timer。
time.Ticker:周期性等待
time.NewTicker(d) 每隔 d 向 ticker.C 发送一次时间,用于周期性任务。它和 select 搭配,可以在“定期做某事”的同时响应退出信号。
ticker := time.NewTicker(1 * time.Second) |
Ticker.Stop() 不会关闭 ticker.C,因此如果用 for range ticker.C 来消费,Stop 之后循环不会自动退出,需要额外的 done channel 配合 select 才能优雅终止。如果在不需要时忘记 Stop,Ticker 底层的定时器资源可能无法及时释放(Go 1.23 之前更明显)。
选型速查
| 需求 | 推荐方式 |
|---|---|
| 单纯暂停当前 goroutine | time.Sleep |
| 等待的同时可被其他事件打断(超时/取消) | time.After + select |
| 需要提前取消或重置的等待 | time.NewTimer + select |
| 周期性重复执行 | time.NewTicker + select(并 defer Stop) |
常见陷阱与要点
1. 空 select 与 nil channel 的“阻塞”含义不同。 select {} 是主动选择永久阻塞,常用于阻塞 main;而 nil channel 上的操作永久阻塞,在 select 中表现为该分支被“禁用”。
2. 不要在 select 中假设顺序。 多个 case 同时就绪时选择是随机的,优先级必须用嵌套 select 显式实现。
3. time.After 在高频循环中谨慎使用。 每次调用都会创建一个底层定时器,循环中反复调用可能带来不必要的分配压力,可考虑复用 Timer。
4. Ticker 要 Stop,但不要指望 Stop 关闭 channel。 需要退出消费循环时,配合独立的 done channel,而不是依赖 range ticker.C 自己结束。
5. select 不加 default 且所有 case 都不可就绪时会永久阻塞。 这是 select 的正常语义,但在生产代码中容易演变成 goroutine 泄漏,关键路径上应配合超时或退出信号使用。