Go中select的实现
摘要
解释Go语言select语句的实现,涵盖编译器重写和运行时的selectgo函数。
<p><a href="https://lobste.rs/s/xc3bsr/implementation_select_go">评论</a></p>
查看缓存全文
缓存时间: 2026/05/18 16:32
# select 语句
来源:https://internals-for-interns.com/posts/go-runtime-select
在之前那篇文章(https://internals-for-interns.com/posts/go-runtime-slices-maps-channels/)中,我们逐层深入了切片、映射和通道,以及它们在底层是如何被构造的。在这三者中,通道可能是实际使用起来最复杂的一个——而在处理通道时,有一个语言结构显得尤为突出:`select` 语句。这正是我们这篇博文要讨论的内容。或许我有点偷懒,因为这篇文章严格来说并不只涉及运行时——`select` 介于运行时和编译器之间——但这正是它成为 Go 语言中有趣角落的原因:它看起来像 `switch`,行为也像 `switch`,但底层却是**编译器**和**运行时**之间的一场协调共舞。
我希望你从这篇文章中记住的关键点是:`select` 不是一个特性,而是两个。其中一半存在于编译器中——具体来说是 `walkSelectCases`(https://github.com/golang/go/blob/go1.26.0/src/cmd/compile/internal/walk/select.go#L33)——它会查看 `select` 的*形状*,在大多数常见情况下,将其重写为更简单的东西——通常是与 `select` 完全无关的代码。另一半则存在于运行时中,位于一个名为 `selectgo`(https://github.com/golang/go/blob/go1.26.0/src/runtime/select.go#L122)的函数里,该函数只会在编译器无法简化的那些情况下执行。
因此,我们同样将本文分为两部分:首先我们将看到编译器所做的所有重写(以及它们变成了什么),然后我们将深入 `selectgo`,看看那些幸存下来的情况。
## 第一部分:编译器重写的内容
在深入重写之前,有一件事值得指出:如果你查看语言规范(或者只看实际代码),`select` 的 case 只能包含两件事之一——向通道**发送**(`ch <- v`)或从通道**接收**(`v := <-ch`,或者只是 `<-ch`)。仅此而已。没有“case x == 5”或“case 某个任意表达式”。编译器在编译时强制执行这一点,这就是下面所有重写成为可能的原因——它静态地知道每个 case 都是一个通道操作,并且确切地知道是哪一种。
有了这个前提:当编译器看到一个 `select` 语句时,它首先会查看你有多少个 case 以及它们是什么类型。基于此,它会选择四种策略之一。其中三种完全避免了运行时的 `selectgo`。只有第四种——“通用情况”——才会真正调用它。
让我们按顺序依次过一遍,从“几乎不是 select”到“真正的 select”。对于每一种,我都会展示你编写的代码以及编译器最终将其转化为的代码。
### 情况 1:空 select
你可以编写的最简单的 `select` 语句是一个空的 `select`,完全没有 case。这是一种“永远暂停此 goroutine”的习惯用法:没有任何东西可以等待,所以 goroutine 会一直阻塞直到程序结束。编译器在这里不会浪费任何周期——它会**丢弃** `select`(https://github.com/golang/go/blob/go1.26.0/src/cmd/compile/internal/walk/select.go#L38-L40)并用对运行时的一个简单调用来替换它:
空 select 重写:select {} 变为 runtime.block()
那个 `block`(https://github.com/golang/go/blob/go1.26.0/src/runtime/select.go#L103-L105)函数只是调用 `gopark`,并传入一个 `waitReasonSelectNoCases` 原因,而且永远不会返回。没有通道,没有 case,没有算法。只有一句“永远沉睡”。
再往上一步,事情看起来仍然不像 `select`。
### 情况 2:单 case 的 select
接下来是一个正好有一个 case 且没有 `default` 的 `select`。如果你思考一下,这不过是一种编写普通通道操作的慢速方法——无需做选择,没有公平性问题,没有在多个东西上挂起。编译器一眼就能看穿它,并**完全去掉** `select`(https://github.com/golang/go/blob/go1.26.0/src/cmd/compile/internal/walk/select.go#L43-L72),只留下普通的通道操作:
单 case select 重写:带一个接收 case 的 select 变为普通的接收
同样的想法也适用于单个发送 case(`ch <- v` 变为正常的发送)和单个 `default` case(它变成……什么也没有——只是主体)。编译器实质上是在说:“你这里根本不需要 `select`,所以我要假装你没写过这个。”
不过,一旦我们在其中加入一个 `default`,重写就不再是纯粹的无操作,而是开始做一些更有用的事情。
### 情况 3:“一个 case + default”的 select
这里的模式是“尝试发送(或接收),如果会阻塞则放弃”——这非常常见,以至于编译器有一个**专门的重写**(https://github.com/golang/go/blob/go1.26.0/src/cmd/compile/internal/walk/select.go#L100-L140)来处理它。编译器不会构建完整的 `select` 机制,而是将其转换为一个简单的 `if/else`,内含一个专用的非阻塞运行时辅助函数:
一个 case 加 default 的 select 重写:带发送 case 和 default 的 select 变为围绕 runtime.selectnbsend 的 if/else
`selectnbsend`(https://github.com/golang/go/blob/go1.26.0/src/runtime/chan.go#L784-L786)位于 `runtime/chan.go` 中,是一个单行函数:
```go
func selectnbsend(c *hchan, elem unsafe.Pointer) bool {
return chansend(c, elem, false, sys.GetCallerPC())
}
```
它只是一个普通的通道发送,但带有一个参数表明“不要阻塞”(`chansend` 的第三个参数,此处设为 `false`)。如果发送可以立即完成(有接收方在等待,或者缓冲区有空间),则成功并返回 `true`。否则放弃并返回 `false`,然后 `else` 分支——也就是你的 `default`——会执行。
对于“尝试接收”的版本(`case x, ok := <-ch: ...; default: ...`),编译器会发出类似的 `selectnbrecv`(https://github.com/golang/go/blob/go1.26.0/src/runtime/chan.go#L804-L806),它会返回两个布尔值:是否选择了任何东西,以及值是否来自真实的发送(相对于已关闭的通道)。
到目前为止,在这三种情况中有一条值得注意,那就是**没有**发生什么:我们从未需要决定多条路径中的哪一条,我们从未需要同时在多个通道上等待,我们从未需要与其他执行自己 select 的 goroutine 进行协调。编译器已经意识到这些都不需要,并将每个 `select` 归约为简单、普通的代码。
现在,我们来到了真正证明 `selectgo` 存在合理性的情况。
### 情况 4:通用情况
一旦你有一个以上的真实通道操作需要监视,编译器就用尽了捷径。没有任何巧妙的重写能隐藏我们需要同时在多个通道上等待的事实,所以编译器放弃了尝试——相反,它会发出对运行时中一个名为 `selectgo` 的函数的调用,后者在运行时才真正知道如何做到这一点。
但是编译器不能凭空发出一个裸的 `selectgo` 调用。`selectgo` 需要知道涉及哪些通道、每个操作的方向、是否有 `default` 以及其他一些事情。因此,除了**调用点**(https://github.com/golang/go/blob/go1.26.0/src/cmd/compile/internal/walk/select.go#L228-L230)之外,编译器还会在它之前发出准备所有这些数据的代码。要看它需要准备什么,最简单的方法是看一下将在运行时被调用的函数的签名:
```go
func selectgo(
cas0 *scase,
order0 *uint16,
pc0 *uintptr,
nsends, nrecvs int,
block bool,
) (int, bool)
```
这个签名看起来有点臃肿,但每个参数都有明确的职责。让我们按照它们出现的顺序逐一过一遍。
第一个参数 `cas0` 是一个指向 **case 数组** 的指针——运行时称之为 `scases`(https://github.com/golang/go/blob/go1.26.0/src/runtime/select.go#L20-L23)。每个条目是一个只有两个字段的小结构体:
```go
type scase struct {
c *hchan // 通道
elem unsafe.Pointer // 数据元素(发送:来源,接收:目标)
}
```
只是一个通道和一个指向待发送或接收数据的指针。注意,没有字段说明它是发送还是接收——这正是接下来两个参数的用途。编译器**布局数组**(https://github.com/golang/go/blob/go1.26.0/src/cmd/compile/internal/walk/select.go#L184-L195),将**所有发送放在前面,所有接收放在后面**(在一次遍历中从索引 0 向上填充发送,从末尾向下填充接收,使它们在中间相遇),然后通过 `nsends` 和 `nrecvs` 告诉 `selectgo` 分别有多少个。因此,如果一个条目位于低于 `nsends` 的索引处,它就是发送;否则,它是接收。方向通过位置来编码,这使每个 `scase` 保持小巧。
第二个参数 `order0` 是一个临时数组,长度是 `scases` 的两倍,运行时用它来记录检查 case 的顺序以及锁定它们的顺序。编译器不填充它;它只分配内存并传递过去。
然后是 `block`。这就是 `default` case 如何潜入画面的。`default` 不会在 `scases` 中有自己的条目;相反,当有 `default` 时(“不要挂起我,如果什么都没有准备好就放弃”),编译器传递 `block = false`;当没有 `default` 时(“挂起我直到有事情发生”),编译器传递 `block = true`。
第三个参数 `pc0` 是一个小小的旁门,仅在启用竞态检测器时使用——编译器可以选择传递一个程序计数器数组,以便运行时将同步事件归因于代码的正确行。大多数时候它只是 `nil`,你可以忽略它的存在。
最后,`selectgo` 返回两个值。`chosen` 是获胜 case 的索引(如果 `default` 被触发则为 `-1`),`recvOK` 是接收 case 的 `ok` 值——与你在 `v, ok := <-ch` 中得到的 `ok` 相同。
有了这些理解,让我们看看编译器对一个具体例子做了什么。假设你编写了:
```go
select {
case v := <-a:
fmt.Println("a", v)
case b <- x:
fmt.Println("b")
default:
fmt.Println("none")
}
```
编译器会将其转换为大致如下:
```go
chosen, recvOK := runtime.selectgo(
[b <- x, v := <-a], // scases(发送在前,接收在后)
[0, 0, 0, 0], // order(运行时使用的临时空间)
nil, // pc0(仅竞态检测器)
1, // nsends
1, // nrecvs
false, // block(因为有 default,所以为 false)
)
switch {
case chosen < 0:
fmt.Println("none") // default
case chosen == 0:
fmt.Println("b") // 发送
default:
fmt.Println("a", v) // 接收
}
```
值得仔细品味的是底部的 `switch`。编译器在 `selectgo` 调用点之后直接发出这个分发代码,因此在运行时,一旦 `selectgo` 返回,我们的程序只需查看 `chosen` 并跳转到正确的主体。当 `chosen < 0` 时,会执行 `default` 主体;其余情况与 `scases` 数组中的索引匹配。(在底层,编译器发出一连串的 `if`(https://github.com/golang/go/blob/go1.26.0/src/cmd/compile/internal/walk/select.go#L263-L274)而不是真正的 `switch`/跳转表,最后一个 case 是无条件 fall-through 以节省一次比较,但概念上它就是一个对 `chosen` 的 `switch`。)
这就是整个编译器端的工作:三个捷径加上一个真实路径,后者构建 `scases` 数组,填充计数和 `block` 标志,将它们交给 `selectgo`,并根据返回结果进行分发。是时候顺着这个真实路径进入运行时,看看 `selectgo` 究竟如何处理所有这些信息了。
## 第二部分:运行时做的事情
编译器已经构建了我们的二进制文件,现在我们正在运行其中的代码。最终,当我们的代码遇到一个逃脱了所有编译器捷径的 `select` 语句时,它会落在编译器在上一步为我们设置的 `runtime.selectgo` 调用上——这就是魔法开始的地方。
一旦 `selectgo` 接手,它需要经历一系列步骤,让我们一步步来。
### 轮询顺序:为公平性随机化
如果 `selectgo` 总是按源码顺序检查 case,那么当多个 case 同时就绪时,第一个 case 会永远获胜。这将是一场公平性灾难——一个在 `[fastChan, slowChan]` 上执行 select 的 goroutine 将基本上永远无法从 `slowChan` 中读取。
因此,Go 在每次调用时**打乱**轮询顺序(https://github.com/golang/go/blob/go1.26.0/src/runtime/select.go#L167-L195)。运行时将 case 索引的一个随机排列写入我们之前提到的那个 `order` 数组的前半部分,这就是在检查 case 时将要遍历的顺序。
这是 `select` 在公平性方面给你的唯一正式保证:当多个 case 同时就绪时,选择是均匀随机的。
轮询顺序是 `selectgo` 用来*检查* case 的顺序,但在检查任何内容之前,它需要安全地获取通道的锁——这是一个单独的问题,有其自身的顺序。
### 锁定顺序:按通道地址排序
在 `selectgo` 可以真正开始查看 case 之前,它必须获取每个涉及的通道上的 `c.lock`。没有锁,你就不能安全地查看通道的内部状态。
但是同时锁定多个通道是一个经典的死锁配方。想象两个 goroutine:
- Goroutine A:`select { case <-ch1: ...; case <-ch2: ... }`
- Goroutine B:`select { case <-ch2: ...; case <-ch1: ... }`
如果 A 在同一时刻获取了 `ch1` 的锁,而 B 获取了 `ch2` 的锁,那么每个都在等待另一个。典型的死锁。
解决方案是标准的:定义一个**全局全序**的锁,并且总是按这个顺序获取它们。Go 使用通道的内存地址(https://github.com/golang/go/blob/go1.26.0/src/runtime/select.go#L545-L547)(`*hchan` 的 `uintptr`)作为排序键(https://github.com/golang/go/blob/go1.26.0/src/runtime/select.go#L206-L240)。任何两个在相同通道上执行 select 的 goroutine 都会计算出相同的顺序,因此它们不会彼此死锁。
在安全地持有锁并且轮询顺序被打乱后,真正的工作开始了。
### 第 1 遍:寻找立即可就绪的 case
在持有所有锁的情况下,`selectgo` 按**轮询顺序**(随机的那个——*不是*锁定顺序)遍历 case(https://github.com/golang/go/blob/go1.26.0/src/runtime/select.go#L264-L301),并向每个 case 提问:“你现在能继续吗?”
对于**接收** case,如果通道的另一端已经有一个发送者在等待,或者通道是带缓冲的并且缓冲区中有数据,或者通道已被关闭(https://github.com/golang/go/blob/go1.26.0/src/runtime/select.go#L504-L514)(此时接收立即产生零值,`recvOK = false`),则答案为是。
对于**发送** case,如果已经有一个接收者在等待,或者通道是带缓冲的并且缓冲区中有空间,则答案为是。还有第三种值得指出的可能性:如果通道已关闭,发送不会静默失败或跳过——它会 `panic`(https://github.com/golang/go/blob/go1.26.0/src/runtime/select.go#L539-L542),就像普通的 `ch <- v` 那样。没有办法通过 select 来“绕过”对已关闭通道的发送。
一旦 `selectgo` 找到一个可以继续的 case,它就执行通道操作,释放所有锁,并返回该 case 的索引。完成。
如果什么都没有就绪并且 `block == false`(源码中包含 `default`),它会**释放锁并返回 `-1`**(https://github.com/golang/go/blob/go1.26.0/src/runtime/select.go#L303-L307),由编译器发出的分发器解释为“执行 default 主体”。
如果什么都没有就绪并且 `block == true`,我们就进入第 2 遍。
### 第 2 遍:在每个通道上入队,然后挂起
这就是 `select` 做一些真正不寻常的事情的地方。goroutine 需要同时在多个通道上等待,所以需要入队到每个通道的等待队列中。`selectgo` 会遍历每个 case,将当前 goroutine 添加到相应的等待队列(发送队列或接收队列)中。但为了避免重复入队,它使用 `order` 数组的另一半来记录已经处理过的 case。
入队完成后,`selectgo` 调用 `gopark` 将当前 goroutine 挂起。当某个通道操作就绪时,对应的唤醒机制会找到这个 goroutine 并将其重新调度。然后 `selectgo` 再次获取所有锁,遍历所有 case 以找出是哪个 case 被唤醒了,执行对应的操作,释放锁,并返回该 case 的索引。
(注:原文在此处截断,但根据上下文,后续应描述唤醒后的处理。由于原文不完整,我们按原文结构翻译。)
---
以上就是 `select` 语句的完整内部机制:编译器会尽可能地将其简化为普通代码,只有在真正需要时才调用运行时的 `selectgo`,而 `selectgo` 通过随机化轮询顺序、按地址排序锁、两遍扫描以及入队/挂起机制来实现多路复用。希望这篇深度解析能帮助你更好地理解 Go 中这个既熟悉又复杂的语言特性。
相似文章
依赖 Go
Solod 是一种新的系统编程语言,它是 Go 的一个子集,复用 Go 的工具链和标准库,同时编译为 C11,提供手动内存管理。
并发服务器:第 8 部分 - Go
本文是编写并发网络服务器系列文章的第 8 部分,重点介绍如何在 Go 中使用 goroutines 和 Go 运行时调度来实现并发。
Go 语言服务器可以实现令人印象深刻的代码导航
Go 语言服务器 (gopls) 为 Go 开发者提供了令人印象深刻的代码导航功能,增强了 IDE 的能力。
深入理解Go运行时:性能分析
深入探讨Go的性能分析机制,解释运行时如何收集CPU、堆、阻塞、互斥锁和协程的性能分析数据,以及这些数据在pprof格式中的表示方式。
反向而行
本文探讨了 Go 的 slices.Backward 函数的设计演变,展示了从简单反转实现到基于回调的迭代器的各种实现,并解释了标准库迭代器签名为何如此设计。