260 lines
8.2 KiB
Markdown
260 lines
8.2 KiB
Markdown
---
|
||
tags: [go/lang, select, poll-multiplexing, fairness, nil-channel]
|
||
create time: 2026-08-08 19:00
|
||
update time: 2026-08-08 19:00
|
||
---
|
||
|
||
# Select 多路复用机制
|
||
|
||
## 概述
|
||
|
||
select 是 Go 语言中专为 channel 设计的关键字,它允许在一个语句中等待多个 channel 操作完成。底层通过随机选择可通信的 case 来保证公平性,避免了优先级饥饿问题。理解 select 的多路复用原理和边界行为,对编写健壮的并发程序必不可少。
|
||
|
||
> [!NOTE] 核心心法
|
||
> select 不是线程安全的轮询器——它是调度器级别的同步原语。每个 case 要么阻塞等待(该侧 goroutine 被挂起),要么立刻匹配执行。没有"轮询空转"这种情况。
|
||
|
||
## 核心原理
|
||
|
||
### select 的底层实现结构
|
||
|
||
每个 select 语句在编译时被转换为一个 `hchan.go` 中的 `chans` 数组和 `sendv`/`recvv` 指针数组:
|
||
|
||
```go
|
||
// 编译后生成的内部表示(简化)
|
||
type selstruct struct {
|
||
chans []*hchan // case 关联的 channel
|
||
recv []unsafe.Pointer // 接收目标地址(nil 表示 send case)
|
||
send []unsafe.Pointer // 发送源地址(nil 表示 recv case)
|
||
order []uint16 // 随机化后的访问顺序
|
||
ncases int // case 数量
|
||
}
|
||
```
|
||
|
||
编译时 select 的行为分析:
|
||
- 如果所有 case 都有确定的 channel,编译器将其转为静态的 select 表
|
||
- 如果有运行时才能确定 channel 的情况(如 defer-case),使用动态生成
|
||
|
||
### pollSurprise / pollRandom / pollDelay
|
||
|
||
Go 调度器的 netpoll 系统使用三个关键函数处理 channel 的 select 场景:
|
||
|
||
| 函数 | 调用时机 | 作用 |
|
||
|------|---------|------|
|
||
| **pollSurprise** | 已注册的 goroutine 突然可以被唤醒 | 不需要 sleep 直接检查 |
|
||
| **pollRandom** | 有多个 case 同时就绪时的随机选择 | 保证公平性 |
|
||
| **pollDelay** | 没有 case 就绪,需要进入睡眠等待 | 设置超时或永久休眠 |
|
||
|
||
select 的执行流程如下:
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["开始执行 select"] --> B["收集所有 case 的 channel"]
|
||
B --> C{"有 nil channel 的 case?"}
|
||
|
||
C -->|是| D["移除 nil channel 对应的 case<br/>永远不会匹配"]
|
||
|
||
D --> E{"至少还有一个非 nil case?"}
|
||
E -->|否| F["永远阻塞 (deadlock)"]
|
||
|
||
E -->|是| G{"有任何 case 立即可选?"}
|
||
G -->|是| H["pollRandom: <br/>从可选 case 中随机选择一个"]
|
||
|
||
G -->|否| I["将当前 goroutine 加入各<br/>channel 的 waitq"]
|
||
I --> J["gopark 进入睡眠"]
|
||
J --> K["netpoll 检测到某个 channel 就绪"]
|
||
K --> L["唤醒 goroutine"]
|
||
|
||
H --> M["执行匹配的 case 分支"]
|
||
L --> M
|
||
|
||
style F fill:#ffebee
|
||
style H fill:#e8f5e9
|
||
style J fill:#fff3e0
|
||
```
|
||
|
||
### 随机性保证公平性
|
||
|
||
这是 select 最精妙的设计之一。当多个 case 同时满足条件(即多个 channel 同时有数据可读或有空间可写)时,Go 会随机选择一个 case 执行。
|
||
|
||
如果没有这个随机性,以下场景会出现严重不公平:
|
||
|
||
```
|
||
Case A: chA <- val (总是优先匹配)
|
||
Case B: <-chB (永远等不到机会)
|
||
```
|
||
|
||
想象 chA 几乎总有空间可写、chB 偶尔有数据可读的情况——如果没有随机性,goroutine 可能永远只匹配 Case A。rand() 随机选择打破了这种确定性偏斜。
|
||
|
||
> [!TIP] 面试常考点
|
||
> 面试官可能会问:"如果 select 总是按顺序从上到下匹配会发生什么?"答案是在循环调用 select 的场景下会导致某些 case 被系统性忽略,形成 starvation。
|
||
|
||
### nil channel 在 select 中的行为
|
||
|
||
nil channel 是一个特殊状态。对 nil channel 做 send 或 receive 会永久阻塞:
|
||
|
||
```go
|
||
var nilCh chan int = nil
|
||
|
||
select {
|
||
case v := <-nilCh: // 永远不匹配
|
||
fmt.Println(v)
|
||
default: // 正常执行
|
||
fmt.Println("default")
|
||
}
|
||
```
|
||
|
||
在 select 中,nil channel 对应的 case 会被编译器/运行时标记为永不触发。这常被用作**动态控制 case 启停**的技巧:
|
||
|
||
```go
|
||
var readCh <-chan int
|
||
var writeCh chan<- int
|
||
|
||
for i := 0; i < 10; i++ {
|
||
select {
|
||
case <-readCh: // 只有 readCh != nil 时才可能匹配
|
||
// read logic
|
||
case writeCh <- i: // 只有 writeCh != nil 时才可能匹配
|
||
// write logic
|
||
}
|
||
}
|
||
|
||
readCh = nil // 禁用读通道
|
||
writeCh = nil // 禁用写通道
|
||
```
|
||
|
||
> [!WARNING] 陷阱
|
||
> nil channel 在普通收发中 panic(其实不会,是死锁),但在 select 中只是简单地不被选中。不要依赖这种隐式行为来处理复杂的条件分支逻辑。
|
||
|
||
### select default 分支
|
||
|
||
```go
|
||
select {
|
||
case v := <-ch:
|
||
fmt.Println(v)
|
||
default:
|
||
fmt.Println("no data available")
|
||
}
|
||
```
|
||
|
||
default 使 select 变为非阻塞模式:如果没有任何 case 立即可选,立即执行 default。这等价于带超时的 select:
|
||
|
||
```go
|
||
select {
|
||
case v := <-ch:
|
||
fmt.Println(v)
|
||
case <-time.After(0): // 超时时间为 0,等价于 default
|
||
fmt.Println("timeout")
|
||
}
|
||
```
|
||
|
||
> [!NOTE] 性能提示
|
||
> time.After() 会在背后创建一个定时器 goroutine,即使超时时间为 0 也有额外开销。无延迟场景下 prefer `default` 而非 `time.After(0)`。
|
||
|
||
### Goroutine Leak 常见场景
|
||
|
||
select 是最容易引发 goroutine leak 的语言特性之一:
|
||
|
||
| 场景 | 原因 | 修复方案 |
|
||
|------|------|---------|
|
||
| 单向读取关闭的 channel | range 自动退出,但 select 不会 | 用 `ok` 检测关闭信号 |
|
||
| 无限循环中 select 缺少取消路径 | goroutine 永远无法退出 | 添加 ctx.Done() case |
|
||
| buffered channel 未 drained | 生产者退出后 buffer 残留 | close + drain |
|
||
| timer 未 Stop | Timer goroutine 泄漏 | defer timer.Stop() |
|
||
|
||
```go
|
||
// ❌ goroutine leak: 无限循环但没有 ctx 退出路径
|
||
for {
|
||
select {
|
||
case v := <-ch:
|
||
process(v)
|
||
}
|
||
}
|
||
|
||
// ✅ 正确: 有明确的退出路径
|
||
func worker(ctx context.Context, ch <-chan int) {
|
||
for {
|
||
select {
|
||
case v, ok := <-ch:
|
||
if !ok {
|
||
return // channel closed
|
||
}
|
||
process(v)
|
||
case <-ctx.Done():
|
||
return // context cancelled
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 代码示例
|
||
|
||
### select 公平性演示
|
||
|
||
```go
|
||
func main() {
|
||
chA := make(chan int, 100)
|
||
chB := make(chan int, 100)
|
||
|
||
for i := 0; i < 100; i++ {
|
||
chA <- i // chA 塞满
|
||
chB <- i * 2
|
||
}
|
||
|
||
countA, countB := 0, 0
|
||
for i := 0; i < 50; i++ {
|
||
select {
|
||
case <-chA:
|
||
countA++
|
||
case <-chB:
|
||
countB++
|
||
}
|
||
}
|
||
|
||
// 由于随机性,countA ≈ countB ≈ 25
|
||
fmt.Printf("A: %d, B: %d\n", countA, countB)
|
||
}
|
||
```
|
||
|
||
多次运行的输出中,countA 和 countB 的值大致均衡(可能有小幅偏差),证明了随机选择的公平性。
|
||
|
||
### Select 与 Context 组合
|
||
|
||
```go
|
||
func withTimeout(ctx context.Context, d time.Duration) error {
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(d):
|
||
return errors.New("timeout exceeded")
|
||
}
|
||
}
|
||
```
|
||
|
||
这是最经典的 select 用法模式:context 取消优先于超时,两者都能作为退出条件。
|
||
|
||
## 实践场景
|
||
|
||
### 面试高频问题
|
||
|
||
**Q: select 可以不带任何 case 吗?**
|
||
可以,写成空的 `select {}`,它会永久阻塞。常用于 main goroutine 保持存活(虽然更好的做法是用 `sync.WaitGroup`)。
|
||
|
||
**Q: select 能处理自定义类型吗?**
|
||
不能。select 只能用于 channel 相关操作。如果需要自定义类型的条件判断,改用 if/else 或者先把结果写入 channel 再用 select。
|
||
|
||
**Q: select 中有多个 default 分支怎么办?**
|
||
语法错误。一个 select 最多只能有一个 default 分支。
|
||
|
||
### 实战建议
|
||
|
||
- **永远给长生命周期 goroutine 加 ctx.Done()**:防止 goroutine leak
|
||
- **善用 nil channel 做 case 启用/禁用**:比 if-else 嵌套更清晰
|
||
- **不要用 select 模拟 if-else**:语义不同,select 强调并发等待
|
||
- **监控 goroutine 增长**:pprof 中观察 goroutine 数量变化趋势
|
||
|
||
## 扩展阅读
|
||
|
||
- [[Channel 底层实现]] — select 建立在 channel 的 waitq 之上
|
||
- [[Context 包详解]] — ctx.Done() 是 select 中最常用的取消信号
|
||
- [[Goroutine 调度模型]] — select 导致的 goroutine 休眠/唤醒由 GMP 调度器管理
|