Golang中的select与休眠等待

眼泪是反重力的海,如果情绪有天气,那我困在雨天里

Posted by yishuifengxiao on 2026-11-05

select:多路通道的调度器

select 是 Go 为并发场景设计的控制结构,语法类似 switch,但所有 case 必须是 channel 的发送或接收操作。它的核心行为是:阻塞等待直到至少一个 case 就绪,然后执行那个就绪的分支;如果多个 case 同时就绪,则伪随机选择一个执行。

基本多路复用

func main() {
ch1 := make(chan string)
ch2 := make(chan string)

go func() { time.Sleep(1 * time.Second); ch1 <- "one" }()
go func() { time.Sleep(2 * time.Second); ch2 <- "two" }()

select {
case msg1 := <-ch1:
fmt.Println("received", msg1) // 先就绪,先执行
case msg2 := <-ch2:
fmt.Println("received", msg2)
}
}

ch1 在 1 秒后就绪,ch2 在 2 秒后就绪,因此 select 会先执行 ch1 的分支。

随机性与公平性

当多个 case 同时就绪时,Go 运行时不会按书写顺序选择,而是伪随机挑选一个。这是有意为之的设计,避免某个通道长期“饥饿”。

ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
ch1 <- "one"
ch2 <- "two"

select {
case msg := <-ch1:
fmt.Println(msg) // 可能输出 one
case msg := <-ch2:
fmt.Println(msg) // 可能输出 two
}

不要依赖 case 的书写顺序来实现优先级。如果需要优先级,应当用嵌套 select 显式表达。

default:非阻塞的“尝试”

不加 default 的 select 在没有 case 就绪时会一直阻塞;加上 default,就变成非阻塞操作:所有 case 都未就绪时立即执行 default,不会等待。

select {
case msg := <-messages:
fmt.Println("received:", msg)
default:
fmt.Println("no message received") // 通道为空时立刻执行
}

这种模式常用于“尝试接收”(try-receive)、“尝试发送”(try-send),以及在轮询循环中顺便处理其他工作。

超时控制:select + time.After

这是 select 最经典的用法之一。time.After(d) 返回一个 channel,在 d 时间后向其中发送一个时间值。把它放进 select,就可以为可能无限阻塞的操作设置“上限”。

select {
case result := <-ch:
fmt.Println("got result:", result)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}

如果 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

if ready {
activeJobs = jobs // 启用
}

select {
case job := <-activeJobs: // activeJobs 为 nil 时此分支禁用
handle(job)
case <-reload:
activeJobs = jobs // 下一轮 select 生效
}

进入一次 select 时,各 case 的 channel 操作数已经求值完毕,分支内的赋值影响的是下一轮 select,不会改变本轮的选择。

select 在循环中

select 常与 for 搭配,实现持续监听多个事件源:

for {
select {
case msg := <-messages:
process(msg)
case <-shutdown:
return // 收到关闭信号,退出循环
}
}

这种结构是 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(2 * time.Second)
fmt.Println("2 seconds later")

time.Sleep 的本质是把当前 goroutine 从运行队列中移出,直到定时器到期后再重新调度。如果传入负数或零,会立即返回。

注意:time.Sleep 的“至少”意味着它不保证精确的时长,实际恢复时间可能略长于 d,取决于调度时机。

time.After:一次性等待的 channel 形式

time.After(d) 返回一个 <-chan Time,d 时间后向其中发送一个时间值。它的价值在于可以与 select 无缝集成,从而让等待变成可被其他事件打断的操作。

select {
case <-time.After(500 * time.Millisecond):
fmt.Println("waited 500ms")
case <-ctx.Done():
fmt.Println("cancelled before timeout")
}

如果只是单纯等待、不需要被取消或与其他事件竞争,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)
defer timer.Stop()

select {
case <-timer.C:
fmt.Println("timer fired")
case <-done:
timer.Stop() // done 先到,主动停掉定时器
fmt.Println("cancelled, timer stopped")
}

Reset 的语义需要小心:在已到期或已停止的 timer 上重置,通常需要先确保 timer.C 已被排空,否则可能读到旧值。如果只需要“等一个结果,最多等多久”,time.After 的写法更简洁;如果需要提前取消,才需要 Timer。

time.Ticker:周期性等待

time.NewTicker(d) 每隔 d 向 ticker.C 发送一次时间,用于周期性任务。它和 select 搭配,可以在“定期做某事”的同时响应退出信号。

ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

for {
select {
case t := <-ticker.C:
fmt.Println("tick at", t)
case <-done:
return // 退出前 defer 会 Stop
}
}

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 泄漏,关键路径上应配合超时或退出信号使用。