背景与动机
select 是 Go 处理多个 channel 操作的核心语法。它最常见的用途不是“让代码更高级”,而是解决三个实际问题:
- 同时等待多个输入来源。
- 在等待数据时支持超时或取消。
- 根据运行时状态动态开启或关闭某个通信分支。
channel 的发送、接收、关闭规则放在 07.Go Channel 通道 里;这一篇只聚焦 select 自己的行为。
核心原理拆解
1. select 等待的是通信操作,不是普通条件
select 的每个 case 必须是 channel 发送或接收操作:
select {case v := <-ch: fmt.Println(v)case ch <- 1: fmt.Println("sent")}它不是 switch,也不是按顺序判断的 if-else。select 关心的是:当前有哪些通信操作已经可以立即完成。
【并发编程 - select 就绪选择与阻塞路径】

2. case 表达式会先求值
进入 select 时,所有 case 里的 channel 表达式都会先按源码顺序求值。发送语句右侧的值也会先求值。
这意味着:即使某个 case 最后没有被选中,它参与通信前的表达式求值也可能已经发生。
select {case ch <- buildValue(): fmt.Println("sent")case <-done: fmt.Println("done")}这里 buildValue() 可能在 done 被选中前就已经执行。不要把有副作用、成本很高或不可重复的操作随手塞进 case 表达式里。
3. 多个 case 同时就绪时,不按书写顺序优先
如果只有一个 case 就绪,select 会选中它。
如果多个 case 同时就绪,Go 会做伪随机选择。写在前面的 case 不会天然优先。
select {case v := <-fast: fmt.Println("fast:", v)case v := <-slow: fmt.Println("slow:", v)}即使 fast 写在前面,只要 fast 和 slow 同时就绪,也不能保证一定先选 fast。
如果业务需要严格优先级,就要显式拆成多层 select,或者用更清晰的队列和调度逻辑表达,而不是依赖 case 顺序。
4. 没有就绪 case 时,select 会阻塞或走 default
如果没有任何 case 就绪,并且没有 default,当前 goroutine 会阻塞:
select {case v := <-ch: fmt.Println(v)}如果有 default,则不会阻塞,会立刻执行 default:
select {case v := <-ch: fmt.Println(v)default: fmt.Println("no value")}default 适合做非阻塞尝试,但不适合随手放进无限循环。否则循环可能一直空转,把 CPU 打满。
5. nil channel 可以动态禁用 case
对 nil channel 的发送和接收会永久阻塞。这个特性在普通代码里容易导致 bug,但在 select 里可以用来动态禁用某个分支。
var out chan<- int
if ready { out = ch}
select {case out <- 1: fmt.Println("sent")case <-done: fmt.Println("done")}当 ready == false 时,out 是 nil,发送 case 永远不会就绪,相当于这个分支被关闭。
最小可运行代码示例
下面这个例子同时演示超时、取消和多输入选择:
package main
import ( "context" "fmt" "time")
func source(ctx context.Context, name string, delay time.Duration) <-chan string { out := make(chan string)
go func() { defer close(out)
select { case <-time.After(delay): out <- name case <-ctx.Done(): return } }()
return out}
func main() { ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) defer cancel()
a := source(ctx, "a", 100*time.Millisecond) b := source(ctx, "b", 200*time.Millisecond)
for a != nil || b != nil { select { case v, ok := <-a: if !ok { a = nil continue } fmt.Println("receive:", v) case v, ok := <-b: if !ok { b = nil continue } fmt.Println("receive:", v) case <-ctx.Done(): fmt.Println("timeout:", ctx.Err()) return } }}这个例子值得看三点:
ctx.Done()用来把超时或取消信号并入select。- channel 关闭后,把变量设为
nil,避免后续反复读到零值。 select同时等待多个输入,但不承诺哪个输入优先。
常见陷阱与错误示例
1. 误以为 select 按 case 顺序选择
select {case <-high: handleHigh()case <-low: handleLow()}如果两个 channel 同时就绪,high 不会因为写在前面就稳定优先。需要优先级时,要显式编码优先级。
2. 在热循环里滥用 default
for { select { case v := <-ch: fmt.Println(v) default: }}这段代码没有阻塞点。只要 ch 暂时没数据,循环就会高速空转。
可以根据场景改成阻塞等待、加入 time.Ticker、使用 context 退出,或者在 default 里做明确的退避。
3. 每轮循环都创建 time.After
for { select { case v := <-ch: handle(v) case <-time.After(time.Second): fmt.Println("idle") }}这会在每轮循环创建新的 timer。低频代码通常没问题,高频路径下更适合复用 time.Timer 或 time.Ticker。
4. 关闭后的 channel 一直被选中
关闭的 channel 接收会立即返回零值和 ok == false。如果在循环 select 里不处理 ok,这个 case 可能一直就绪。
for { select { case v := <-ch: fmt.Println(v) // ch 关闭后会反复打印零值 }}正确做法是检查 ok,关闭后退出循环或把 channel 设为 nil。
性能影响或设计取舍
select 适合表达“等待多个通信事件”。如果你只有一个 channel,直接接收通常更清晰。
default 会把等待变成非阻塞尝试,适合快速探测状态,但它也会让程序更容易忙等。只要写了 for + select + default,就应该主动确认循环里有没有退出、阻塞或退避机制。
time.After 写法简单,适合一次性超时;长期循环里要注意 timer 分配。需要周期触发时通常用 time.Ticker,需要可重置超时时用 time.Timer。
nil channel 动态禁用 case 很好用,但也要控制复杂度。如果一个 select 里到处都是状态切换和 nil 赋值,可能说明应该拆出更明确的状态机。
面试高频问题
-
select多个 case 同时就绪时怎么选? 伪随机选择一个,不保证源码顺序优先。 -
default有什么作用? 当没有 case 就绪时立即执行default,让select变成非阻塞。 -
select会先执行哪个步骤? 先求值所有 case 的 channel 表达式和发送值,再判断哪些通信可以进行。 -
nilchannel 在select里有什么用? 对nilchannel 的通信永远不就绪,所以可以用来动态禁用某个 case。 -
关闭的 channel 在
select里会怎样? 接收会立即就绪,返回零值和ok == false。循环里必须处理关闭状态。
一句话总结
select 的核心是“在多个 channel 通信事件之间做就绪选择”,它不提供顺序优先级,真正的优先级和退出规则要在代码里显式表达。