如果你写过一点 Go 的并发代码,大概率已经用过 select。它长得像 switch,但干的活儿完全不一样------switch 挑的是值,select 盯的是通道的状态。
很多人刚接触的时候会觉得它有点"玄学":为什么有时候走这个 case,有时候走那个?为什么多个 case 都就绪的时候,结果看起来是随机的?今天我们就把这个"多路开关"聊透。
先搞懂:它是给谁设计的?
select 从根子上就是为 channel 生的。每个 case 后面跟的,要么是接收表达式,要么是发送表达式,不能是别的。
go
select {
case v := <-ch1:
// 从 ch1 收到东西了
case ch2 <- x:
// 成功把 x 发到 ch2 了
default:
// 谁都没准备好,走这里
}
它的核心逻辑特别简单:哪个 case 能立即执行(不阻塞),就走哪个。如果多个都能执行,Go 运行时会随机挑一个,不是按顺序找第一个。这一点很多人第一次遇到都会觉得意外,但正是这个随机性,让程序在多个通道同时活跃时,不会出现"某个通道饿死"的情况。
最常见的几个用法
1. 多通道读:谁先到用谁
这是最经典的场景。比如你有多个数据源,谁先返回就用谁的结果:
go
func fetchFromSources() {
ch1 := fetch("source1")
ch2 := fetch("source2")
for i := 0; i < 2; i++ {
select {
case v := <-ch1:
fmt.Println("来自 source1:", v)
case v := <-ch2:
fmt.Println("来自 source2:", v)
}
}
}
如果 ch1 和 ch2 都准备好了,Go 会随机选一个执行,不会永远先挑 ch1。
2. 非阻塞操作:用 default 避免卡住
普通的通道读写是阻塞的:没东西读就等,没地方写也等。但有时候你不想等,这时候 default 就派上用场了。
go
select {
case msg := <-ch:
fmt.Println("收到:", msg)
default:
fmt.Println("没消息,先干别的")
}
这就是《Go 语言精进之路》里提到的"非阻塞读/写"惯用法。tryRecv、trySend 这类函数,本质上都是靠 default 实现的。
3. 超时控制:别让 goroutine 无限等下去
网络请求、远程调用,最怕的就是"等不到响应"。用 select + time.After,可以很优雅地加超时:
go
select {
case result := <-resultCh:
fmt.Println("拿到结果:", result)
case <-time.After(3 * time.Second):
fmt.Println("超时了,放弃等待")
}
time.After 返回一个 <-chan time.Time,3 秒后它会收到一个时间值,这时候 select 就会走超时分支。注意 :用完记得调用 timer.Stop() 回收资源,不然会给 GC 增加压力,这在《WebAssembly 原理与核心技术》里讲 timer 的时候也特意提过。
4. 退出通知:用 done channel 做级联取消
在 Go 的并发模式里,一个 goroutine 怎么通知其他 goroutine"该停了"?最常见的方式就是用一个 done 通道。
go
func worker(done <-chan struct{}) {
for {
select {
case <-done:
fmt.Println("收到退出信号,准备收工")
return
default:
// 干正常的活
doWork()
}
}
}
你也可以把 done 和其他工作通道放在一起,这样收到退出信号时,goroutine 能立刻响应,不会卡在某个通道的读写上。
一个容易踩的坑:通道关闭后怎么处理?
select 里从一个已关闭的通道 读数据,不会阻塞,而是会立刻返回零值 + false。如果你不处理这个信号,很容易出现"死循环读零值"的情况。
书里给了一个很巧妙的解法:**把已关闭的通道设为 nil**。
因为对 nil 通道的读写会永远阻塞,所以一旦检测到某个通道关了,就把它赋值为 nil,select 之后就再也不会选中它了。
go
for {
select {
case v, ok := <-ch1:
if !ok {
ch1 = nil // 禁用这个 case
} else {
fmt.Println(v)
}
case v, ok := <-ch2:
if !ok {
ch2 = nil
} else {
fmt.Println(v)
}
}
// 两个都关了就退出
if ch1 == nil && ch2 == nil {
break
}
}
再聊两句:select 和 Go 的哲学
Go 的并发哲学是 **"不要通过共享内存来通信,而要通过通信来共享内存"**。而 select 就是这个哲学里承上启下的关键:
channel负责 goroutine 之间的数据流动;select负责把多个流动"编排"起来,让你可以同时等待多个事件、做超时、做取消、做非阻塞操作。
很多人刚从其他语言过来,习惯用锁、用回调,但写着写着就会发现:用 channel + select 搭出来的并发逻辑,数据流特别清晰,不容易出竞态问题,也更好维护。
最后
select 本身语法不复杂,但它的威力全在和 channel 配合的各种模式里。非阻塞、超时、退出通知、多路复用......这些模式熟了之后,你会发现 Go 的并发代码写起来特别顺:没有复杂的回调嵌套,也没有乱七八糟的锁竞争,就是"几个 goroutine,几个 channel,一个 select",程序就跑起来了。
如果你还没试过把 select 用熟,不妨找个场景练练:比如写个带超时、可取消的并发请求,或者做个简单的 fan-in/fan-out 调度,用过一次你就明白它为什么是 Go 并发的"灵魂开关"了。