Go select 多路复用

笔记/Go/Go核心编程/Go select 多路复用

背景与动机#

select 是 Go 处理多个 channel 操作的核心语法。它最常见的用途不是“让代码更高级”,而是解决三个实际问题:

  1. 同时等待多个输入来源。
  2. 在等待数据时支持超时或取消。
  3. 根据运行时状态动态开启或关闭某个通信分支。

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-elseselect 关心的是:当前有哪些通信操作已经可以立即完成。

【并发编程 - select 就绪选择与阻塞路径】

image.png
image.png

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 写在前面,只要 fastslow 同时就绪,也不能保证一定先选 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 时,outnil,发送 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.Timertime.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 赋值,可能说明应该拆出更明确的状态机。

面试高频问题#

  1. select 多个 case 同时就绪时怎么选? 伪随机选择一个,不保证源码顺序优先。

  2. default 有什么作用? 当没有 case 就绪时立即执行 default,让 select 变成非阻塞。

  3. select 会先执行哪个步骤? 先求值所有 case 的 channel 表达式和发送值,再判断哪些通信可以进行。

  4. nil channel 在 select 里有什么用? 对 nil channel 的通信永远不就绪,所以可以用来动态禁用某个 case。

  5. 关闭的 channel 在 select 里会怎样? 接收会立即就绪,返回零值和 ok == false。循环里必须处理关闭状态。

一句话总结#

select 的核心是“在多个 channel 通信事件之间做就绪选择”,它不提供顺序优先级,真正的优先级和退出规则要在代码里显式表达。

文章目录

文章目录