select、超时与常见模式
channel 让你等一个数据。但真实并发里你常要同时等好几件事:这个 channel 来了处理这个、那个来了处理那个、都没来就先干别的、等太久就超时放弃。这就是 select 的舞台——它是 channel 版的 switch。这一章讲清 select、超时、以及几个你会反复用到的并发模式,最后直面 Go 并发最隐蔽的 bug:goroutine 泄漏。
select:同时等多个 channel
select 长得像 switch,但每个 case 是一次 channel 收发操作。它同时盯着所有 case,哪个 channel 先就绪就执行哪个:
select {
case msg := <-ch1:
fmt.Println("ch1 来了:", msg)
case msg := <-ch2:
fmt.Println("ch2 来了:", msg)
case ch3 <- 42:
fmt.Println("成功往 ch3 发了 42")
}
三个 case:等 ch1、等 ch2、往 ch3 发。select 会阻塞,直到其中任意一个能进行,然后执行那一个。如果多个同时就绪,Go 会随机挑一个 —— 这个随机是故意的,防止你写出「总是偏向第一个 case」而饿死其它 channel 的代码。
没有 select,一个 goroutine 一次只能 <-ch 等一个 channel,卡死在那。select 让它能「同时把耳朵支在好几个 channel 上」,谁先响应谁。这是构建复杂并发的关键积木 —— 超时、取消、多路复用、优先级,全都建立在 select 之上。你可以把它理解成 channel 版的「同时 await 多个 future,取第一个完成的」。
default:非阻塞地试一下
给 select 加一个 default,它就不再阻塞 —— 如果没有任何 case 立刻就绪,就走 default:
select {
case msg := <-ch:
fmt.Println("收到:", msg)
default:
fmt.Println("现在没数据,我不等,先干别的")
}
这叫「非阻塞接收 / 发送」—— 试一下,能收就收,不能就立刻走人,绝不卡住。常用于「轮询一下有没有活、没有就继续循环」的场景。
超时:select + time.After
这是 select 最经典、最实用的用法。time.After(d) 返回一个 channel,它会在 d 时间后自动送来一个值。把它当成 select 的一个 case,就实现了超时:
select {
case result := <-ch:
fmt.Println("按时拿到结果:", result)
case <-time.After(2 * time.Second):
fmt.Println("等超过 2 秒了,放弃") // ch 一直没来,这个 case 先响应
}
逻辑:同时等「结果 channel」和「2 秒定时器 channel」。谁先来走谁 —— 结果在 2 秒内来了,走第一个;2 秒到了结果还没来,定时器 channel 先响应,走超时分支。就这么几行,你给任何 channel 操作加上了超时。没有专门的超时 API,全是 channel + select 拼出来的 —— 这就是 Go 「用小积木搭出你要的东西」的风格。
几个你会反复用的模式
扇出 / 扇入(fan-out / fan-in)
扇出:一批活,起多个 goroutine 并行处理(从同一个 channel 抢任务)。扇入:多个 goroutine 的结果汇到同一个 channel 收集。这是并行处理一批任务的标准骨架:
jobs := make(chan int, 100)
results := make(chan int, 100)
// 扇出:起 5 个 worker,都从 jobs 抢活
for w := 0; w < 5; w++ {
go func() {
for j := range jobs { // 从同一个 channel 抢,天然负载均衡
results <- process(j) // 结果都送进 results(扇入)
}
}()
}
// 派活
for _, j := range myJobs {
jobs <- j
}
close(jobs) // 关掉,worker 的 range 会自然结束
妙处:多个 worker 从同一个 jobs channel 抢任务,谁空谁抢,负载天然就均衡了 —— 不用你手动分配。这就是 Go 版的「线程池并行处理」,但用 goroutine + channel 写出来,短小得多,也没有线程池的配置负担(因为 goroutine 便宜,第 17 章)。
goroutine 便宜,但不是免费 —— 一个永远阻塞、再也不会结束的 goroutine,会一直占着内存,越积越多,这叫 goroutine 泄漏,是 Go 生产事故的头号来源之一。最常见的泄漏:
func leak() {
ch := make(chan int)
go func() { val := <-ch; use(val) }() // 等 ch
// 但函数返回了,再没人往 ch 发 —— 这个 goroutine 永远卡在 <-ch
}
那个 goroutine 永远等一个不会来的数据,永远不结束,泄漏了。铁律:每起一个 goroutine,你都要能回答「它在什么情况下一定会退出」。如果答案是「等某个 channel,但可能没人发」,你就需要一个退出机制 —— 超时、或者下一章的 context 取消。这也是第 17 章说的「Go 把组织 goroutine 的责任交回给你」的具体代价:没有结构化并发替你兜底,goroutine 的生命周期得你自己管。
用 select + done 优雅退出
防泄漏的一个基础模式:给长期运行的 goroutine 一个「退出信号 channel」,用 select 同时等「干活」和「该退了」:
func worker(jobs <-chan int, done <-chan struct{}) {
for {
select {
case j := <-jobs:
process(j)
case <-done: // 收到退出信号
return // goroutine 干净地结束,不泄漏
}
}
}
// 要停的时候,close(done) —— 所有 worker 的 <-done 同时收到,全退出
close(done) 是个惯用小技巧:关闭一个 channel 会让所有在 <-done 上等待的 goroutine 同时被唤醒(第 18 章:从关闭的 channel 接收立刻返回)。所以一个 close(done) 就能一次性通知任意多个 goroutine「收工」。那个 chan struct{} 是惯用法 —— struct{} 是零大小的空类型,表示「这个 channel 只用来传信号、不传数据」。
这个「done channel」模式很好用,但当调用层级一深、要取消的 goroutine 一多,手动传 done 就繁琐了。所以 Go 把它标准化、升级成了一个专门的东西 —— 那就是下一章的主角 context。
Kotlin 对照
Kotlin 的高层做法:你要「多路等待 + 超时」,通常用 Flow 的操作符(merge、timeout、combine)或 withTimeout { },或协程的 select { } 表达式(Kotlin 也有 select,同样受 Go 启发)。这些是现成的高层抽象,写「若干数据流合并、带超时」很省事。
Go 的做法是低层、手工的:没有 Flow 那套操作符生态。要超时?select + time.After 自己拼。要合并多个流?自己写 fan-in 的 goroutine。要取消?传 done channel 或 context。Go 给你的是造这些东西的原材料(channel + select + goroutine),而不是造好的成品。
这有得有失。失:写常见模式比 Kotlin 啰嗦,没有一行 .timeout() 的爽快。得:没有黑箱,每个数据流的走向都是你亲手用 channel 画出来的,出问题时没有隐藏的操作符行为要猜。还有一个 Kotlin 帮你、Go 要你自己操心的大事:goroutine 泄漏。Kotlin 的结构化并发让「scope 结束、里面的协程自动取消」,泄漏很难发生。Go 没有这层保护,「每个 goroutine 怎么退出」是你必须主动设计的 —— 这是从协程迁过来最需要提高警惕的一点。
这一章的一句话
select 让一个 goroutine 同时等多个 channel、谁先就绪走谁(多个就绪则随机、防饿死),配 default 做非阻塞、配 time.After 做超时;扇出扇入是并行处理一批活的标准骨架;而 Go 并发最隐蔽的 bug 是 goroutine 泄漏——每起一个 goroutine 都要能答出「它一定在什么情况下退出」,靠 done channel 或下一章的 context 兜住。
done channel 模式手动传起来很累。下一章我们看 Go 怎么把「取消一个操作、连带取消它扇出的所有下游」这件事标准化 —— context,以及它和 Kotlin 的 Job 树有多像。