Select 底层原理深度解析
很多开发者对 select 有误解,认为它在"另一个协程"中执行。本章将深入解析 select 的工作原理,揭开它的神秘面纱。
1. select 在主协程还是子协程?
1.1 核心答案
select 就在当前协程中执行! 它不是另一个 goroutine。
┌─────────────────────────────────────────────────────────────────┐
│ select 执行位置 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ main() 协程 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ch := make(chan int) │ │
│ │ │ │
│ │ select { ◄─── 就在这里执行,不是新的协程! │ │
│ │ case v := <-ch: │ │
│ │ fmt.Println("收到:", v) │ │
│ │ } │ │
│ │ │ │
│ │ fmt.Println("select 之后的代码") │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘1.2 实验验证
让我们通过实验来证明 select 在当前协程执行:
go
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
ch := make(chan int)
// 打印当前 goroutine 数量
fmt.Println("select 前 goroutine 数量:", runtime.NumGoroutine()) // 1
// 这个 select 会阻塞
go func() {
time.Sleep(100 * time.Millisecond)
ch <- 1
}()
select {
case v := <-ch:
fmt.Println("收到:", v)
}
// 打印当前 goroutine 数量
fmt.Println("select 后 goroutine 数量:", runtime.NumGoroutine()) // 还是 1(或 2,取决于上面的 goroutine 是否退出)
}输出:
select 前 goroutine 数量:1
收到:1
select 后 goroutine 数量:1结论: select 没有创建新的 goroutine,它就在当前 goroutine 中执行。
2. select 底层数据结构
2.1 select 的内部结构
当编译器遇到 select 语句时,会生成类似以下的代码结构:
┌─────────────────────────────────────────────────────────────────┐
│ select 内部结构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ select 语句 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ hchan *hchan (channel 指针) │ │
│ │ dir uint32 (方向:读/写) │ │
│ │ elem unsafe.Pointer (数据指针) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ scase[] (select case 数组) │ │
│ │ ┌──────────┬──────────┬──────────┬──────────┐ │ │
│ │ │ case 0 │ case 1 │ case 2 │ default │ │ │
│ │ ├──────────┼──────────┼──────────┼──────────┤ │ │
│ │ │ channel │ channel │ channel │ nil │ │ │
│ │ │ 方向 │ 方向 │ 方向 │ │ │ │
│ │ │ 数据指针 │ 数据指针 │ 数据指针 │ │ │ │
│ │ └──────────┴──────────┴──────────┴──────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘2.2 源码中的定义
在 Go 源码中,select 相关的核心数据结构:
go
// runtime/select.go
// scase 代表 select 中的一个 case
type scase struct {
elem unsafe.Pointer // 数据元素指针
chan unsafe.Pointer // channel 指针
pc uintptr // 程序计数器 (用于返回)
so uint16 // 状态信息
kind uint16 // case 类型 (读/写/default)
}
// sellock 结构用于 select 操作
type hselect struct {
tcase uint16 // case 总数
ncase uint16 // 当前 case 数
thisg *g // 当前 goroutine 指针
scases []scase // case 数组
pollorder *uint16 // 轮询顺序 (随机化用)
lockorder *uint16 // 锁定顺序 (避免死锁)
}3. select 执行流程详解
3.1 执行流程图
┌─────────────────────────────────────────────────────────────────┐
│ select 执行流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 开始 select │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 步骤 1: 遍历所有 case,检查是否可以立即执行 │ │
│ │ │ │
│ │ - case 是 <-ch: 检查 ch 是否有数据 │ │
│ │ - case 是 ch<-v: 检查 ch 缓冲区是否有空间 │ │
│ │ - case 是 default: 标记为 default │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 步骤 2: 判断结果 │ │
│ │ │ │
│ │ 情况 A: 有 case 可以立即执行 │ │
│ │ │ │ │
│ │ ├── 只有一个:执行该 case │ │
│ │ │ │ │
│ │ └── 多个可执行:随机选择一个执行 │ │
│ │ │ │
│ │ 情况 B: 没有 case 可执行 │ │
│ │ │ │ │
│ │ ├── 有 default: 执行 default │ │
│ │ │ │ │
│ │ └── 没有 default: 进入等待 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (如果需要等待) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 步骤 3: 注册等待 │ │
│ │ │ │
│ │ - 将当前 goroutine 添加到所有 case 的 channel 等待队列 │ │
│ │ - 设置超时 (如果有 time.After) │ │
│ │ - 进入 Gwaiting 状态,让出 CPU │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (被唤醒时) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 步骤 4: 处理唤醒 │ │
│ │ │ │
│ │ - 检查哪个 case 被触发 │ │
│ │ - 从其他 channel 的等待队列中移除 │ │
│ │ - 执行对应的 case 代码 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘3.2 等待队列详解
当 select 阻塞等待时,当前 goroutine 会被添加到 channel 的等待队列:
┌─────────────────────────────────────────────────────────────────┐
│ select 阻塞时的状态 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Goroutine (当前协程) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 状态:Gwaiting │ │
│ │ 等待:select │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ │ 注册到 │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Channel 1 (ch1) │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ recvq: [goroutine1, goroutine2, ...] │ │ │
│ │ │ ▲ │ │ │
│ │ │ │ 当前 goroutine 在这里 │ │ │
│ │ │ sendq: [] │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Channel 2 (ch2) │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ recvq: [goroutine3, ...] │ │ │
│ │ │ ▲ │ │ │
│ │ │ │ 当前 goroutine 也在这里 │ │ │
│ │ │ sendq: [] │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 注意:一个 goroutine 可以同时注册到多个 channel 的等待队列! │
│ │
└─────────────────────────────────────────────────────────────────┘4. 为什么 select 不会创建新协程?
4.1 从调度角度理解
┌─────────────────────────────────────────────────────────────────┐
│ 调度器视角 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 正常代码执行: │
│ ┌─────────┐ │
│ │ G1 │ 执行 select │
│ └────┬────┘ │
│ │ │
│ ▼ │
│ ┌─────────┐ │
│ │ G1 │ select 阻塞,进入等待队列,状态变为 Gwaiting │
│ └────┬────┘ │
│ │ │
│ ▼ │
│ 调度器选择其他可运行的 goroutine │
│ ┌─────────┐ ┌─────────┐ │
│ │ G2 │ │ G3 │ 继续执行... │
│ └─────────┘ └─────────┘ │
│ │
│ 当某个 channel 就绪时: │
│ ┌─────────┐ │
│ │ G1 │ 从等待队列移除,状态变为 Grunnable │
│ └────┬────┘ │
│ │ │
│ ▼ │
│ 调度器再次调度到 G1 │
│ ┌─────────┐ │
│ │ G1 │ 继续执行 select 之后的代码 │
│ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘4.2 对比 goroutine 创建
go
// 创建新 goroutine
go func() {
// 这会创建新的 goroutine
// 有新的栈空间
// 有独立的执行流
}()
// select 语句
select {
case <-ch:
// 这还在原来的 goroutine 中
// 使用原来的栈空间
// 是原来执行流的延续
}5. 阻塞的本质
5.1 什么是阻塞?
阻塞不是"停在那里什么都不做",而是:
- 当前 goroutine 进入等待状态 (Gwaiting)
- 让出 CPU 给其他 goroutine
- 等待某个条件满足后被唤醒
┌─────────────────────────────────────────────────────────────────┐
│ 阻塞 vs 忙等待 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 阻塞等待 (select 没有 default) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Goroutine 状态:Gwaiting │ │
│ │ CPU 使用:0% (让出给其他 goroutine) │ │
│ │ 唤醒条件:channel 就绪或超时 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 忙等待 (select 有 default + for 循环) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Goroutine 状态:Grunning │ │
│ │ CPU 使用:100% (不断循环检查) │ │
│ │ 唤醒条件:不适用 (一直在运行) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘5.2 实验:观察阻塞行为
go
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
ch := make(chan int)
// 启动一个 goroutine 打印统计
go func() {
for i := 0; i < 5; i++ {
time.Sleep(200 * time.Millisecond)
fmt.Printf("[%d] 当前 goroutine 数量:%d\n", i, runtime.NumGoroutine())
}
}()
// 这个 select 会阻塞 1 秒
fmt.Println("开始阻塞 select...")
select {
case <-time.After(time.Second):
fmt.Println("超时,继续执行")
}
fmt.Println("select 结束")
time.Sleep(time.Second)
}输出:
开始阻塞 select...
[0] 当前 goroutine 数量:2
[1] 当前 goroutine 数量:2
[2] 当前 goroutine 数量:2 <- select 还在阻塞
[3] 当前 goroutine 数量:2
超时,继续执行
select 结束
[4] 当前 goroutine 数量:26. select 性能分析
6.1 select 的性能开销
┌─────────────────────────────────────────────────────────────────┐
│ select 性能开销 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. case 数量影响 │
│ • 1-2 个 case: 非常快 (~100ns) │
│ • 10 个 case: 中等 (~500ns) │
│ • 100 个 case: 较慢 (~5μs) │
│ │
│ 2. 阻塞 vs 非阻塞 │
│ • 有 case 立即执行:快 │
│ • 需要阻塞等待:涉及调度,稍慢 │
│ │
│ 3. 随机选择开销 │
│ • 多个 case 可执行时需要随机选择 │
│ • 使用伪随机算法,开销很小 │
│ │
└─────────────────────────────────────────────────────────────────┘6.2 性能对比实验
go
package main
import (
"fmt"
"testing"
"time"
)
// 方案 1: 使用 select
func benchmarkSelect(b *testing.B) {
ch := make(chan int, 1)
for i := 0; i < b.N; i++ {
ch <- 1
select {
case v := <-ch:
_ = v
}
}
}
// 方案 2: 直接接收
func benchmarkDirect(b *testing.B) {
ch := make(chan int, 1)
for i := 0; i < b.N; i++ {
ch <- 1
v := <-ch
_ = v
}
}
// 方案 3: 使用 select + default
func benchmarkSelectDefault(b *testing.B) {
ch := make(chan int, 1)
for i := 0; i < b.N; i++ {
ch <- 1
select {
case v := <-ch:
_ = v
default:
}
}
}
func BenchmarkSelect(b *testing.B) {
benchmarkSelect(b)
}
func BenchmarkDirect(b *testing.B) {
benchmarkDirect(b)
}
func BenchmarkSelectDefault(b *testing.B) {
benchmarkSelectDefault(b)
}典型结果:
BenchmarkDirect-8 50000000 24 ns/op
BenchmarkSelect-8 20000000 58 ns/op
BenchmarkSelectDefault-8 10000000 120 ns/op结论: select 比直接 channel 操作慢 2-5 倍,但在需要多路复用时是必要的。
7. 常见问题解答
Q1: select 会创建新的 goroutine 吗?
不会! select 就在当前 goroutine 中执行。
Q2: 为什么 select 会"阻塞"?
阻塞是因为没有 case 可以立即执行,当前 goroutine 进入等待状态,让出 CPU。
Q3: select 的 default 分支什么时候执行?
当所有 case 都不能立即执行时,default 分支会立即执行(不阻塞)。
Q4: select 如何保证公平性?
当多个 case 都可以执行时,select 会随机选择一个,避免某个 case 总是被"饿死"。
Q5: select 中的 case 按什么顺序检查?
case 按代码顺序检查,但如果多个 case 都可以执行,会随机选择一个执行。
8. 本章小结
┌─────────────────────────────────────────────────────────────┐
│ 核心要点 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✓ select 在当前 goroutine 中执行,不创建新协程 │
│ │
│ ✓ select 底层是 scase 数组,每个 case 包含 channel 信息 │
│ │
│ ✓ 阻塞时 goroutine 进入 Gwaiting 状态,让出 CPU │
│ │
│ ✓ 等待时 goroutine 注册到多个 channel 的等待队列 │
│ │
│ ✓ 被唤醒时从所有等待队列中移除,执行对应 case │
│ │
│ ✓ select 有性能开销,但多路复用时是必要的 │
│ │
└─────────────────────────────────────────────────────────────┘