
在前面的几篇文章里，我们已经分别看过 slice、map、channel 的底层实现。三者里面，channel 是最接近并发语义本身的那个；而一旦开始写 channel，几乎一定会碰到 `select`。

`select` 有意思的地方在于：它看起来像 `switch`，写起来也像 `switch`，但真正执行时并不是单纯的语法糖。它一半在编译器里，一半在 runtime 里。编译器先判断这个 `select` 是不是「简单到可以直接改写掉」；只有那些改写不掉的通用场景，才会在运行时进入 `runtime.selectgo`。

所以理解 `select` 的关键不是死记语法，而是建立下面这条主线：

1. 规范先把 `select` 限定成「只能做 channel 发送 / 接收 / default」。
2. 编译器利用这个限制，在 `walkSelectCases` 里消掉大部分简单形态。
3. runtime 只接手真正需要「同时等待多个 channel」的通用情形。

本文基于 Go 1.26.3 源码，按这条主线拆两半来讲：先看编译器如何改写 `select`，再看 `runtime/select.go` 里的 `selectgo` 怎么完成真正的调度。

---

## 语义边界：为什么编译器敢大胆改写

先看一个最容易被忽略的事实：`select` 的 case 并不是任意表达式。按照 Go 规范，`select` 的每个通信分支只能是下面两类操作之一：

- 向 channel 发送：`ch <- v`
- 从 channel 接收：`v := <-ch`、`v, ok := <-ch` 或单纯 `<-ch`

再加上一种特殊分支：

- `default`

也就是说，`select` 不是一个「并发版 switch」，而是一个「多路 channel 通信选择器」。正因为 case 的形状非常受限，编译器才可以在编译期做非常激进的改写：它不需要推断任意表达式的副作用，只需要识别发送、接收、default 三种模式即可。

这也是本文最重要的结论之一：**`select` 不是一个功能，而是编译器和 runtime 协作完成的一组机制。**

---

## 编译器如何改写 `select`

编译器侧的入口在 `src/cmd/compile/internal/walk/select.go`，核心函数是：

```go
func walkSelect(sel *ir.SelectStmt) {
    init := ir.TakeInit(sel)
    init = append(init, walkSelectCases(sel.Cases)...)
    sel.Compiled = init
}
```

真正决定改写策略的是 `walkSelectCases`。它会先看 case 个数和是否存在 `default`，然后在四种路径里选一种。前三种都不会进入 `runtime.selectgo`；只有第四种「通用路径」才需要 runtime 参与。

```mermaid
flowchart TD
    A["编译器遇到 select"] --> B{"case 形状"}
    B -->|0 个 case| C["改写为 runtime.block()"]
    B -->|1 个通信 case| D["改写为普通 send/recv 语句"]
    B -->|1 个通信 case + default| E["改写为 selectnbsend / selectnbrecv + if/else"]
    B -->|多个通信 case| F["构造 scase/order 数组"]
    F --> G["调用 runtime.selectgo"]
    G --> H["按 chosen 分发到对应 case body"]
```

下面分开看。

### 空 `select`

最简单的 `select` 是：

```go
select {}
```

这在语义上等价于「当前 goroutine 永久阻塞」。`walkSelectCases` 对这种情况直接返回：

```go
if ncas == 0 {
    return []ir.Node{mkcallstmt("block")}
}
```

也就是把整条语句改写成：

```go
runtime.block()
```

而 `block` 在 runtime 里只是调用 `gopark`，等待原因是 `waitReasonSelectNoCases`。这里完全没有 channel，也没有选择逻辑，本质上就是一句「睡到天荒地老」。

### 单 case `select`

如果只有一个通信分支，没有 `default`：

```go
select {
case v := <-ch:
    use(v)
}
```

那它其实根本没有「选」的必要。编译器会把它直接还原成普通接收：

```go
v := <-ch
use(v)
```

发送分支也是一样：

```go
select {
case ch <- v:
    use()
}
```

会变成普通发送：

```go
ch <- v
use()
```

原因很直接：只有一个通信动作时，`select` 只是多包了一层壳。不存在公平性问题，也不存在多个 channel 的协调问题，runtime 没必要插手。

### 一个通信 case 加 `default`

这是日常最常见的一类：

```go
select {
case ch <- v:
    onSend()
default:
    onMiss()
}
```

编译器会把它改写成「非阻塞 channel 操作 + if/else」。对应发送时，用的是 `selectnbsend`：

```go
if selectnbsend(ch, &v) {
    onSend()
} else {
    onMiss()
}
```

源码里对应的逻辑大致是：

```go
cond = mkcall1(chanfn("selectnbsend", 2, ch.Type()), ...)
```

`selectnbsend` 本身非常薄，只是把 channel 发送走到 `chansend(..., block=false, ...)`：

```go
func selectnbsend(c *hchan, elem unsafe.Pointer) bool {
    return chansend(c, elem, false, sys.GetCallerPC())
}
```

接收版本同理，编译器会生成 `selectnbrecv`。关键点不在于 helper 名字，而在于语义：**这已经不是一个真正的多路等待，而是一条单次尝试的非阻塞发送 / 接收。**

### 通用情况：真正调用 `selectgo`

一旦 `select` 里有多个真实的通信 case，编译器就没法再把它改写成普通语句了。例如：

```go
select {
case v := <-a:
    fmt.Println("a", v)
case b <- x:
    fmt.Println("b")
default:
    fmt.Println("none")
}
```

这时编译器会走通用路径，核心动作有三步：

1. 构造 `scase` 数组，描述每个 channel case。
2. 准备 `order` 临时数组，交给 runtime 当 scratch space。
3. 调用 `runtime.selectgo`，再按返回值跳到对应的 case body。

runtime 侧接收的签名是：

```go
func selectgo(
    cas0 *scase,
    order0 *uint16,
    pc0 *uintptr,
    nsends, nrecvs int,
    block bool,
) (int, bool)
```

先看 `scase`：

```go
type scase struct {
    c    *hchan
    elem unsafe.Pointer
}
```

只有两个字段：

- `c`：对应的 channel
- `elem`：发送时指向源数据，接收时指向目标地址

看起来奇怪的一点是：`scase` 里并没有「这是 send 还是 recv」这个字段。方向信息不是存在结构体里，而是由数组布局编码出来的。

`walkSelectCases` 会把：

- 所有 send case 从前往后填
- 所有 recv case 从后往前填

然后通过 `nsends` 和 `nrecvs` 告诉 runtime 边界在哪。于是 runtime 只要判断 `index < nsends`，就知道当前 case 是发送；否则就是接收。

对应上面的例子，编译器大致会生成这样的调用：

```go
chosen, recvOK := runtime.selectgo(
    [b <- x, v := <-a], // send 在前，recv 在后
    order,
    nil,
    1,
    1,
    false,              // 因为有 default，所以不能阻塞
)
```

然后再生成一段分发逻辑：

```go
switch {
case chosen < 0:
    fmt.Println("none")
case chosen == 0:
    fmt.Println("b")
default:
    fmt.Println("a", v)
}
```

注意这里 `default` 并不会作为一个 `scase` 进入 runtime；它只是通过 `block=false` 这个参数传进去。也就是说，`default` 的本质不是「一个特殊 case」，而是「告诉 runtime：如果没有立即可执行的通信，就别 park，直接返回」。

---

## `selectgo` 运行前，编译器到底准备了什么

理解 `selectgo` 最容易混乱的地方，是把编译器和 runtime 的职责搅在一起。可以先把它们拆开：

| 组件 | 职责 |
|---|---|
| 编译器 `walkSelectCases` | 识别 `select` 形状；能消掉的直接消掉；不能消掉的就构造运行时所需的数据 |
| runtime `selectgo` | 在真正运行时，检查 case 是否就绪、决定是否阻塞、把 goroutine 挂到多个 channel 上并在唤醒后清理 |

对通用 `select`，编译器主要准备三类数据：

1. `scase[]`：每个通信 case 的 channel 和数据地址。
2. `order[]`：长度是 `2*n` 的 `uint16` 数组，前半段给 `pollorder`，后半段给 `lockorder`。
3. `block`：是否允许阻塞；没有 `default` 时为 `true`，有 `default` 时为 `false`。

如果 race detector 打开，编译器还会额外准备 `pc0`，让 runtime 能把同步事件归因到具体源码位置。普通情况下它是 `nil`，可以忽略。

---

## `selectgo` 的整体执行流程

一旦进入 `runtime.selectgo`，算法主线其实很清晰：先随机化检查顺序，再统一锁顺序，然后分三轮处理。

```mermaid
flowchart TD
    A["进入 selectgo"] --> B["扫描 scases，生成随机 pollorder"]
    B --> C["按 channel 地址排序，生成 lockorder"]
    C --> D["按 lockorder 给所有 channel 加锁"]
    D --> E["pass 1：按 pollorder 查找立即可执行 case"]
    E --> F{"找到可执行 case?"}
    F -->|是| G["执行 send/recv/close 路径"]
    G --> H["解锁并返回 chosen / recvOK"]
    F -->|否，且 block=false| I["解锁并返回 -1"]
    F -->|否，且 block=true| J["pass 2：为每个 case 创建 sudog 并入队"]
    J --> K["gopark 阻塞当前 goroutine"]
    K --> L["被某个 case 唤醒后重新加锁"]
    L --> M["pass 3：从未命中的 channel 队列里删除自己的 sudog"]
    M --> N["解锁并返回命中的 case"]
```

下面按源码顺序展开。

### 第一步：生成随机 `pollorder`

如果 `selectgo` 总是按源码顺序检查 case，那么前面的 case 会天然占便宜：多个 case 同时 ready 时，靠前的那个几乎总是先命中。

所以 runtime 在函数一开始会构造一个随机排列的 `pollorder`：

```go
j := cheaprandn(uint32(norder + 1))
pollorder[norder] = pollorder[j]
pollorder[j] = uint16(i)
```

这段代码本质上是在原地洗牌。后面 `pass 1` 检查 case 是否就绪时，遍历的是 `pollorder`，不是源码顺序。

这能带来的效果是：

1. 多个 case 同时 ready 时，不会永久偏向源码里排在前面的分支。
2. `select` 提供的是「近似公平」而不是「轮询公平」。
3. 它并不保证每个 case 一定均匀命中，只是避免固定顺序导致的系统性饥饿。

很多人会误以为 `select` 的公平性等价于 round-robin，这其实不对。Go 给你的保证更接近「随机打散」，而不是「严格排班」。

### 第二步：生成 `lockorder`

随机检查顺序解决的是公平性；但真正开始看 channel 内部状态前，runtime 还得先把相关 channel 的锁拿到手。

问题是：一个 `select` 可能同时涉及多个 channel，而不同 goroutine 可能以不同源码顺序写它们：

```go
// goroutine A
select {
case <-ch1:
case <-ch2:
}

// goroutine B
select {
case <-ch2:
case <-ch1:
}
```

如果 A 先拿 `ch1.lock` 再拿 `ch2.lock`，B 反过来先拿 `ch2.lock` 再拿 `ch1.lock`，就会产生经典死锁。

所以 `selectgo` 会额外生成一个和 `pollorder` 不同的 `lockorder`：按 channel 地址排序，保证所有 goroutine 对同一组 channel 的加锁顺序一致。

```go
func (c *hchan) sortkey() uintptr {
    return uintptr(unsafe.Pointer(c))
}
```

然后统一走：

```go
sellock(scases, lockorder)
```

这里要区分两个顺序：

- `pollorder`：为了公平性，决定先检查哪个 case。
- `lockorder`：为了死锁安全，决定先锁哪个 channel。

两者服务的是完全不同的问题。

### 第三步：`pass 1` 检查是否有立即可执行的 case

锁都拿到之后，`selectgo` 会按 `pollorder` 遍历每个 case，看它能不能「现在立刻执行」。

对接收 case，runtime 依次看三件事：

1. `sendq` 里有没有等待发送者。
2. 缓冲区 `qcount > 0`，也就是 channel 里已经有数据。
3. channel 是否已关闭；如果已关闭，接收立即成功，但得到零值，`recvOK=false`。

源码里对应的跳转大概是：

```go
sg = c.sendq.dequeue()
if sg != nil {
    goto recv
}
if c.qcount > 0 {
    goto bufrecv
}
if c.closed != 0 {
    goto rclose
}
```

对发送 case，则看：

1. channel 是否已关闭；已关闭直接 panic。
2. `recvq` 里有没有等待接收者。
3. 缓冲区是否还有空位。

对应逻辑是：

```go
if c.closed != 0 {
    goto sclose
}
sg = c.recvq.dequeue()
if sg != nil {
    goto send
}
if c.qcount < c.dataqsiz {
    goto bufsend
}
```

这里有三个很关键的行为边界：

1. **从已关闭 channel 接收是可立即完成的。**  
   它不会阻塞，而是返回零值和 `ok=false`。
2. **向已关闭 channel 发送不会被 `select` 悄悄吞掉。**  
   一旦该发送 case 被选中，仍然会 panic，和普通 `ch <- v` 完全一致。
3. **有 `default` 的 `select` 只在「没有任何立即可执行 case」时才走 default。**  
   不是先看 default，再看其它 case。

如果 `pass 1` 找到了可执行 case，`selectgo` 会直接完成数据拷贝、解锁并返回；整个 `select` 到这里就结束了。

### 第四步：没有 ready case，且允许阻塞，就把自己挂到所有 channel 上

如果 `pass 1` 一个 ready case 都没找到：

- `block=false`：说明源码里有 `default`，直接返回 `chosen=-1`
- `block=true`：说明没有 `default`，当前 goroutine 需要真正阻塞

阻塞型 `select` 最特别的地方就在这里：**一个 goroutine 会同时把自己注册到多个 channel 的等待队列里。**

runtime 的做法是，为每个 case 分配一个 `sudog`，然后把这些 `sudog` 依次挂到不同 channel 的 `sendq` 或 `recvq` 上：

```go
sg := acquireSudog()
sg.g = gp
sg.isSelect = true
sg.elem.set(cas.elem)
sg.c.set(c)

if casi < nsends {
    c.sendq.enqueue(sg)
} else {
    c.recvq.enqueue(sg)
}
```

同时，这些 `sudog` 还会串到 `gp.waiting` 链表上，方便当前 goroutine 被唤醒后统一清理。

可以把它理解成这样：

```text
goroutine G 执行：

select {
case <-ch1:
case ch2 <- x:
case <-ch3:
}

阻塞后变成：

ch1.recvq -> sudog(G)
ch2.sendq -> sudog(G)
ch3.recvq -> sudog(G)
```

也就是说，G 同时在三个地方排队。谁先配对成功，谁就赢。

所有 `sudog` 都入队之后，runtime 调用：

```go
gopark(selparkcommit, nil, waitReasonSelect, traceBlockSelect, 1)
```

当前 goroutine 正式睡眠，等待其它 goroutine 来把某个 case 配对成功。

这里还有一个容易漏掉的竞争细节：一个 `select` 既然同时挂在多个 channel 上，就可能出现「多个对端几乎同时发现它可配对」的情况。runtime 会利用 `sudog.isSelect` 和 goroutine 上的 `selectDone` 状态做 CAS 竞争，保证最终只有一个 case 真正赢下这次唤醒，其它竞争到一半的路径会在后续清理阶段被撤销。

### 第五步：被唤醒后做 `pass 3` 清理失败分支

一旦某个 channel 上的发送、接收或关闭操作命中了当前 `select`，对应 goroutine 会把当前 goroutine 标记为可运行，并把命中的 `sudog` 塞进 `gp.param`。

此时当前 goroutine 被唤醒，重新回到 `selectgo` 里，会先重新加锁，然后进入 `pass 3`：

```go
sellock(scases, lockorder)
sg = (*sudog)(gp.param)
...
for _, casei := range lockorder {
    if sg == sglist {
        casi = int(casei)
        caseSuccess = sglist.success
    } else {
        if int(casei) < nsends {
            c.sendq.dequeueSudoG(sglist)
        } else {
            c.recvq.dequeueSudoG(sglist)
        }
    }
    releaseSudog(sglist)
}
```

这一步的意义是：**当前 goroutine 之前把自己挂到了所有 channel 上，现在既然已经有一个 case 赢了，就必须把其它 channel 上残留的等待节点全部删除。**

否则别的 goroutine 之后操作这些 channel 时，会看到一堆早就失效的 waiter，轻则增加额外开销，重则破坏同步语义。

这也是为什么 `select` 比普通单次 send/recv 更复杂：它不仅要处理「怎么等」，还要处理「赢了一个以后，怎么把剩下的报名表全部撤掉」。

---

## 一个完整的时序例子

假设我们有下面这段代码：

```go
select {
case v := <-a:
    fmt.Println("a", v)
case b <- x:
    fmt.Println("b")
default:
    fmt.Println("none")
}
```

它在编译期和运行期的分工，可以压缩成下面这个过程：

```mermaid
sequenceDiagram
    participant UserCode as 用户代码
    participant Compiler as 编译器 walkSelectCases
    participant Runtime as runtime.selectgo
    participant ChanA as channel a
    participant ChanB as channel b

    UserCode->>Compiler: 编译期分析 select
    Compiler->>Compiler: 构造 scases / order / block=false
    UserCode->>Runtime: 运行时调用 selectgo
    Runtime->>Runtime: 生成 pollorder / lockorder
    Runtime->>ChanA: 检查接收是否立即可执行
    Runtime->>ChanB: 检查发送是否立即可执行
    alt 有 ready case
        Runtime-->>UserCode: 返回 chosen / recvOK
        UserCode->>UserCode: 跳转到对应 case body
    else 没有 ready case
        Runtime-->>UserCode: 返回 chosen=-1
        UserCode->>UserCode: 执行 default body
    end
```

如果去掉 `default`，唯一的变化就是 `block=true`，然后在没有 ready case 时，runtime 不会返回 `-1`，而是进入入队和 `gopark` 的阻塞路径。

---

## `select` 里最容易误判的几个点

理解完编译器改写和 `selectgo` 之后，很多日常困惑其实都会自动消失。

### `select` 的公平性不是轮询公平

`selectgo` 通过随机 `pollorder` 避免固定源码顺序造成的偏置，但它不承诺严格的 round-robin，也不保证短时间内每个 case 命中次数相近。

所以如果业务要求的是强顺序、配额式消费或者严格优先级，不要把这种语义寄托在 `select` 自身上，而应该在协议层单独设计。

### case 里的 channel 表达式和发送值，会在进入 `select` 时先求值一次

这是规范里一个很容易漏掉的细节：进入 `select` 时，所有接收分支的 channel 表达式，以及所有发送分支的 channel 表达式和右值，都会**按源码顺序求值一次**，然后才开始做选择。

比如这段代码：

```go
select {
case ch1 <- f():
    use1()
case <-g():
    use2()
default:
    use3()
}
```

即使最后走的是 `default`，`f()` 和 `g()` 也已经执行过了。`select` 只是在这些值和 channel 都求出来之后，才决定哪个 case 真正被选中。

这个规则直接带来两个后果：

1. 不要把昂贵计算、日志副作用、指标上报随手塞进 case 表达式里。
2. 如果某个发送值构造成本高，而这个 case 大多数时候并不会命中，最好把构造动作移到 case body 之外重新设计。

### `default` 很容易写成忙等

下面这种代码：

```go
for {
    select {
    case v := <-ch:
        use(v)
    default:
    }
}
```

在没有数据时会不停空转，因为编译器把它改写成非阻塞接收；既没有 park，也没有调度让步。这类代码通常需要显式 `time.Sleep`、`runtime.Gosched`，或者直接改回阻塞式等待。

### `break` 默认只跳出 `select`，不会跳出外层 `for`

这也是很常见的误判：

```go
for {
    select {
    case <-done:
        break
    case v := <-ch:
        use(v)
    }
}
```

很多人以为 `done` 到来后循环就结束了，但这里的 `break` 只会跳出当前这一次 `select`，然后外层 `for` 立刻进入下一轮，结果通常是继续空转或者再次阻塞。

如果你的目标是退出整个循环，应该用：

```go
Outer:
for {
    select {
    case <-done:
        break Outer
    case v := <-ch:
        use(v)
    }
}
```

或者直接 `return`。

### 向已关闭 channel 发送，不会因为在 `select` 里就变安全

很多人会误以为：

```go
select {
case ch <- v:
default:
}
```

可以顺手避开关闭竞争。并不是。  
如果 `ch` 已经关闭，并且发送 case 被检查到，runtime 仍然会走 `sclose`，最终 panic `send on closed channel`。

`select` 解决的是「多路等待」问题，不解决「谁拥有 close 权」的问题。

### `nil` channel 之所以能动态禁用 case，是因为它永远不可能 ready

把某个 case 的 channel 设成 `nil`：

```go
var ch chan int

select {
case <-ch:
    ...
default:
    ...
}
```

对应的通信分支在运行时永远不会成功，所以它相当于被禁用。这不是编译器特判，而是 channel 语义本身决定的：对 `nil` channel 的发送和接收都永远阻塞。

在动态 fan-in、超时控制和状态机切换里，这个特性非常常见。

### 关闭后的接收分支会一直 ready，不处理好会把其它 case「饿死」

再看一个循环里的常见写法：

```go
for {
    select {
    case v, ok := <-ch:
        if !ok {
            continue
        }
        use(v)
    case <-ticker.C:
        tick()
    }
}
```

一旦 `ch` 被关闭，`<-ch` 就会永远立即返回零值和 `ok=false`。这意味着这个 case 之后会一直是 ready 状态，循环很容易持续命中它，导致 `ticker.C` 之类的其它分支长期抢不到机会。

更稳妥的做法通常是：

1. 关闭后直接 `return`。
2. 如果循环还要继续，就把 `ch = nil`，显式禁用这个 case。

### 在热循环里反复写 `time.After`，会持续创建新的 timer

像下面这样：

```go
for {
    select {
    case v := <-work:
        use(v)
    case <-time.After(time.Second):
        timeout()
    }
}
```

每次进入循环都会创建一个新的 timer 和对应 channel。语义上没错，但在高频循环里，这会带来额外分配和 timer 管理开销。

这里不要把 `Ticker` 和 `Timer` 混为一谈：

1. 如果你要表达的是「每隔一段时间触发一次」，那是固定周期语义，更适合 `time.NewTicker`。
2. 如果你要表达的是「从现在开始等一段时间，期间如果收到了别的事件就重新计时」，那是超时语义，更适合可复用的 `time.NewTimer` / `Reset`。

这段 `time.After(time.Second)` 更接近第二种，所以热点路径里通常应该改成复用 `Timer`，而不是直接换成 `Ticker`。

---

## 总结

`select` 最值得记住的不是语法，而是它的分层结构：

1. 规范把它限制成 channel 发送、接收和 `default` 三种 case。
2. 编译器在 `walkSelectCases` 里先消掉空 `select`、单 case `select`、单通信 case 加 `default` 这三类简单形态。
3. 只有通用场景才进入 `runtime.selectgo`。
4. `selectgo` 通过随机 `pollorder` 处理公平性，通过排序后的 `lockorder` 避免多 channel 加锁死锁，再用三轮流程完成立即执行、阻塞等待和失败分支清理。

所以从实现角度看，`select` 根本不是一个单独的语言设施，而是：

- 编译器负责「能不能简化」
- runtime 负责「简化不掉时怎样安全地同时等待多个 channel」

这也是为什么 `select` 看起来像一个很小的语法点，背后却同时牵扯到编译器 IR 改写、channel 队列、`sudog`、`gopark`、锁顺序和关闭语义。

如果前面已经理解了 channel 的 `hchan/sendq/recvq/sudog`，再回头看 `select.go`，会发现它本质上只是把「单 channel 阻塞」升级成了「多 channel 报名，再由其中一个 case 抢到资格」。

---

## 参考资料

- [Go compiler: walk/select.go](https://go.googlesource.com/go/+/refs/tags/go1.26.3/src/cmd/compile/internal/walk/select.go)
- [Go runtime: select.go](https://go.googlesource.com/go/+/refs/tags/go1.26.3/src/runtime/select.go)
- [Go runtime: chan.go](https://go.googlesource.com/go/+/refs/tags/go1.26.3/src/runtime/chan.go)
- [Go runtime: runtime2.go](https://go.googlesource.com/go/+/refs/tags/go1.26.3/src/runtime/runtime2.go)
- [Go spec: Select statements](https://go.dev/ref/spec#Select_statements)
- [Go spec: Send statements](https://go.dev/ref/spec#Send_statements)
- [Go spec: Receive operator](https://go.dev/ref/spec#Receive_operator)

