Mastering Go’s Select: Beyond the Basics
1.1 select 简介:
在 Go 语言中,select
语句用于处理多个通道操作,从而简化并发编程中的通信和同步问题。select
语句类似于 switch
语句,但它的每个 case
都必须是一个通道操作。
以下是 select
语句的基本语法:
1 | select { |
使用场景
- 多通道选择:可以同时等待多个通道操作,当其中任何一个通道准备好时,程序就会执行相应的 case。
- 超时处理:通过结合
time.After
函数,可以实现超时机制。 - 非阻塞通信:通过使用
default
子句,可以实现非阻塞的通道操作。
关于超时处理,可以参考文章:Time Control You Should Know in Golang | by Wesley Wei | Programmer’s Career
1.2 select 与通道:
与 switch
不同的是,select
中虽然也有多个 case
,但是这些 case
中的表达式都必须与 Channel 的操作有关,也就是 Channel 的读写操作。 它可以帮助开发者避免因为等待某个阻塞的 Channel导致程序死锁。
示例 1:从多个通道接收数据
以下是一个例子,展示了如何使用 select
语句从两个通道中接收数据:
1 | package main |
在这个示例中,我们创建了两个通道 chan1
和 chan2
,并通过两个协程分别在 1 秒和 2 秒后向这两个不同的通道发送消息。select
语句会等待第一个通道准备好并接收消息,然后输出相应的信息。
示例 2:处理超时情况
以下是一个例子,展示了如何使用 select
语句与超时机制结合来处理多个通道操作:
1 | package main |
在这个示例中,select
语句会等待读取 chan1
和 chan2
中的数据。如果超过了 1 秒钟还没有通道准备好,select
语句会执行第三个 case
,打印 “Timeout, no channel was ready within 1 second”。
示例 3:非阻塞通信
最后,我们来看一个示例,展示了如何使用 default
子句来实现非阻塞通道操作:
1 | package main |
在这个示例中,如果 chan1
没有数据可接收,select
语句会执行 default
子句,打印 “Default case: Channel is not ready”。
1.3 select 的实现:
基于Go 1.23 分析
1.3.1 数据结构
select
在 Go 语言的源代码中不存在对应的结构体,但是我们使用 runtime.scase 结构体表示 select
控制结构中的 case
:
1 | type scase struct { |
因为非默认的 case
中都与 Channel 的发送和接收有关,所以 runtime.scase 结构体中也包含一个 runtime.hchan 类型的字段存储 case
中使用的 Channel。
1.3.2 常见流程
在默认的情况下,编译器会使用如下的流程处理 select
语句:
- 将所有的
case
转换成包含 Channel 以及类型等信息的 runtime.scase 结构体; - 调用运行时函数 runtime.selectgo 从多个准备就绪的 Channel 中选择一个可执行的 runtime.scase 结构体;
- 通过
for
循环生成一组if
语句,在语句中判断自己是不是被选中的case
;
1.3.3 随机轮训、加锁顺序
runtime.selectgo 函数首先会进行执行必要的初始化操作并决定处理 case
的两个顺序 — 轮询顺序 pollOrder
和加锁顺序 lockOrder
:
轮询顺序 pollOrder
和加锁顺序 lockOrder
分别是通过以下的方式确认的:
1 | func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) { |
- 轮询顺序:通过 runtime.cheaprandn 函数引入随机性;
- 加锁顺序:按照 Channel 的地址排序后确定加锁顺序;
随机的轮询顺序可以避免 Channel 的饥饿问题,保证公平性;而根据 Channel 的地址顺序确定加锁顺序能够避免死锁的发生。这段代码最后调用的 runtime.sellock 会按照之前生成的加锁顺序锁定 select
语句中包含所有的 Channel。
1.4 性能考量:
- 注册和选择的开销
当一个 select
语句被执行时,Go 运行时需要将每个通道操作注册到调度队列上,并在通道可用时从队列中解除阻塞。对于每个 select
语句,注册和选择的操作带来了额外的调度开销。
- 随机选择的开销
当有多个通道同时准备好时,Go 运行时会随机选择一个通道执行。这种随机选择涉及随机数生成和对所有就绪通道的扫描,这一过程带来了额外的开销。
- 阻塞和唤醒的开销
当 select
阻塞等待通道时,Go 运行时需要将当前的 goroutine 放入等待队列,等待通道有数据可处理。阻塞和唤醒的过程涉及 goroutine 的调度,这也会引入一定的上下文切换开销。
1.5 I/O 多路复用:
经过上面的分析,相信你会有熟悉的感觉;没错,我也想到了I/O 多路复用。在操作系统中select
是系统调用,我们经常会使用 select
、poll
和 epoll
等函数构建 I/O 多路复用模型提升程序的性能。
Go 语言的 select
与操作系统中的 select
比较相似,比如 C 语言的 select
系统调用可以同时监听多个文件描述符的可读或者可写的状态,而 Go 语言中的 select
也能够让 Goroutine 同时等待多个 Channel 可读或者可写。
他们的抽象形态类似下图:
1.6 总结
表面上来看:
Go 的select
语句是一种仅能用于channel
发送和接收消息的专用语句,此语句运行期间是阻塞的;当select
中没有case语句的时候,会阻塞当前的goroutine。所以,select
是用来阻塞监听goroutine
的。
深层次来理解的话:select
是 Go 在语言层面提供的I/O多路复用的机制,其专门用来检测多个可读或可写的 Channel 是否准备完毕。
1.7 参考
Go 语言 select 的实现原理 | Go 语言设计与实现
更多该系列文章,参考medium链接:
https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a
English post: https://programmerscareer.com/golang-select/
作者:Wesley Wei – Twitter Wesley Wei – Medium
发表日期:原文在 2024-09-21 16:32 时创作于 https://programmerscareer.com/zh-cn/golang-select/
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)
评论