ᕕ( ᐛ )ᕗ Jimyag's Blog

Go select 底层实现原理全解析

· 1704 words · ~ 8 min read

在前面的几篇文章里,我们已经分别看过 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 := <-chv, ok := <-ch 或单纯 <-ch

再加上一种特殊分支:

  • default

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

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


编译器如何改写 select

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

1
2
3
4
5
func walkSelect(sel *ir.SelectStmt) {
    init := ir.TakeInit(sel)
    init = append(init, walkSelectCases(sel.Cases)...)
    sel.Compiled = init
}

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

  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 是:

1
select {}

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

1
2
3
if ncas == 0 {
    return []ir.Node{mkcallstmt("block")}
}

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

1
runtime.block()

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

单 case select

如果只有一个通信分支,没有 default

1
2
3
4
select {
case v := <-ch:
    use(v)
}

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

1
2
v := <-ch
use(v)

发送分支也是一样:

1
2
3
4
select {
case ch <- v:
    use()
}

会变成普通发送:

1
2
ch <- v
use()

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

一个通信 case 加 default

这是日常最常见的一类:

1
2
3
4
5
6
select {
case ch <- v:
    onSend()
default:
    onMiss()
}

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

1
2
3
4
5
if selectnbsend(ch, &v) {
    onSend()
} else {
    onMiss()
}

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

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

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

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

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

通用情况:真正调用 selectgo

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

1
2
3
4
5
6
7
8
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 侧接收的签名是:

1
2
3
4
5
6
7
func selectgo(
    cas0 *scase,
    order0 *uint16,
    pc0 *uintptr,
    nsends, nrecvs int,
    block bool,
) (int, bool)

先看 scase

1
2
3
4
type scase struct {
    c    *hchan
    elem unsafe.Pointer
}

只有两个字段:

  • c:对应的 channel
  • elem:发送时指向源数据,接收时指向目标地址

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

walkSelectCases 会把:

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

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

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

1
2
3
4
5
6
7
8
chosen, recvOK := runtime.selectgo(
    [b <- x, v := <-a], // send 在前,recv 在后
    order,
    nil,
    1,
    1,
    false,              // 因为有 default,所以不能阻塞
)

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

1
2
3
4
5
6
7
8
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*nuint16 数组,前半段给 pollorder,后半段给 lockorder
  3. block:是否允许阻塞;没有 default 时为 true,有 default 时为 false

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


selectgo 的整体执行流程

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

  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

1
2
3
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 可能以不同源码顺序写它们:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 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 的加锁顺序一致。

1
2
3
func (c *hchan) sortkey() uintptr {
    return uintptr(unsafe.Pointer(c))
}

然后统一走:

1
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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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. 缓冲区是否还有空位。

对应逻辑是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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. defaultselect 只在「没有任何立即可执行 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 的 sendqrecvq 上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 被唤醒后统一清理。

可以把它理解成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 调用:

1
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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 更复杂:它不仅要处理「怎么等」,还要处理「赢了一个以后,怎么把剩下的报名表全部撤掉」。


一个完整的时序例子

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

1
2
3
4
5
6
7
8
select {
case v := <-a:
    fmt.Println("a", v)
case b <- x:
    fmt.Println("b")
default:
    fmt.Println("none")
}

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

  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 表达式和右值,都会按源码顺序求值一次,然后才开始做选择。

比如这段代码:

1
2
3
4
5
6
7
8
select {
case ch1 <- f():
    use1()
case <-g():
    use2()
default:
    use3()
}

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

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

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

default 很容易写成忙等

下面这种代码:

1
2
3
4
5
6
7
for {
    select {
    case v := <-ch:
        use(v)
    default:
    }
}

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

break 默认只跳出 select,不会跳出外层 for

这也是很常见的误判:

1
2
3
4
5
6
7
8
for {
    select {
    case <-done:
        break
    case v := <-ch:
        use(v)
    }
}

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

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

1
2
3
4
5
6
7
8
9
Outer:
for {
    select {
    case <-done:
        break Outer
    case v := <-ch:
        use(v)
    }
}

或者直接 return

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

很多人会误以为:

1
2
3
4
select {
case ch <- v:
default:
}

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

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

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

把某个 case 的 channel 设成 nil

1
2
3
4
5
6
7
8
var ch chan int

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

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

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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

像下面这样:

1
2
3
4
5
6
7
8
for {
    select {
    case v := <-work:
        use(v)
    case <-time.After(time.Second):
        timeout()
    }
}

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

这里不要把 TickerTimer 混为一谈:

  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 队列、sudoggopark、锁顺序和关闭语义。

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


参考资料

#Go #Select #Channel #编译器 #Runtime #并发