Skip to content

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 什么是阻塞? ​

阻塞不是"停在那里什么都不做",而是:

  1. 当前 goroutine 进入等待状态 (Gwaiting)
  2. 让出 CPU 给其他 goroutine
  3. 等待某个条件满足后被唤醒
┌─────────────────────────────────────────────────────────────────┐
│                    阻塞 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 数量:2

6. 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 有性能开销,但多路复用时是必要的                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

上一章:← Select 基础用法 | 下一章:Select 生产实战 →