1.简介
Golang 中的 select 语句是用于多路复用的一种语言结构,用于同时等待多个通道上的数据,并执行相应的代码块。
也就是说 select 是用来监听和 channel 有关的 IO 操作,它与 select,poll,epoll 相似,当 IO 操作发生时,触发相应的动作,实现 IO 多路复用。
特性如下:
- case 必须是一个通信操作。
- select 语句中除 default 外,各 case 执行顺序是随机的。
- select 语句中如果没有 default 语句,则会阻塞等待任意一个 case。
- select 语句中除 default 外,每个 case 只能操作一个 channel,要么读要么写。
- 当 select 中的多个 case 同时被触发时,会随机执行其中的一个。
2.基本语法
select {
case <-channel1:
// 处理 channel1 上的数据
case data := <-channel2:
// 处理 channel2 上的数据
case channel3 <- data:
// 将数据写入 channel3
default:
// 没有任何 channel 可用
}
select 语句会等待多个通道中的数据,一旦某个通道上有数据可读或可写,就会执行相应的 case 子句。
如果多个 case 子句同时满足条件,则随机选择其中一个执行。
如果没有任何 case 子句满足条件,则执行 default 子句。
如果没有 default 子句,则 select 会一直阻塞,直到有 channel 可用。
注意,select 读操作要判断是否成功读取,因为关闭的 channel 也可以读取,此时 ok 为 false。
case elem, ok := <-chan1:
3.实现原理
3.1 概述
select 语句是基于 Golang 运行时的调度器实现的 IO 多路复用。可以同时监控多个通道的状态,并在某个通道就绪时将其对应的 case 子句加入调度队列中等待执行。当某个 case 子句执行完毕后,select 语句就会结束,并返回对应的结果。
Golang 运行时调度器是一种基于 goroutine 的协作式调度机制,它能够在多个 goroutine 之间进行高效的上下文切换,从而实现并发执行。在调度器的实现中,每个 goroutine 会绑定到一个系统线程上,而系统线程则会在操作系统层面上执行调度,以实现多线程并发。调度器会监控每个 goroutine 的状态,并在 goroutine 处于阻塞状态时,将其从系统线程上解绑,然后将系统线程用于执行其他 goroutine,从而避免了阻塞操作对整个程序的影响。
在 Golang 中,使用 select 语句可以轻松地实现 IO 多路复用。当 select 语句被执行时,运行时调度器会将所有 case 子句中的通道加入到一个调度器队列中,并监控这些通道的状态。当有数据可读或可写时,调度器就会选择其中一个 case 子句,并将其对应的代码块加入到调度队列中等待执行。
3.2 数据结构
Golang 实现 select 时,并没有一个数据结构表示 select,但是有一个数据结构表示 case 语句(含 defaut,default 实际上是一种特殊的 case)。
select 执行过程可以类比成一个函数,函数输入case 数组,输出选中的 case,然后程序流程转到选中的 case 块
我们先看一下 case 的数据结构(go 1.19 runtime/select.go)。
// Select case descriptor.
// Known to compiler.
// Changes here must also be made in src/cmd/compile/internal/walk/select.go's scasetype.
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}
因为 case 中都与 Channel 的发送和接收有关,所以 runtime.scase 结构体包含一个 runtime.hchan 类型的字段存储 case 中使用的 Channel。
elem 表示缓冲区地址,表示从 Channel 读出的数据存放地址或将要写入 Channel 的数据存放地址。
3.3 实现原理
编译优化
首先在编译期间,Go 语言会对 select 语句进行优化,它会根据 select 中 case 的不同选择不同的优化路径:
- 不存在任何 case。
空的 select 语句会被转换成调用 runtime.block() 函数,直接挂起当前 Goroutine。
- 只存在一个 case。
如果 select 语句中只包含一个 case,编译器会将其转换成普通的通信操作,而不是一个真正的 select 。
- 多个 case 其中有一个是 default。
如果 select 语句中只包含多个 case 并且其中一个是 default,那么会使用 runtime.selectnbrecv() 和 runtime.selectnbsend() 函数非阻塞地执行收发操作。
- 多个 case 无 default。
编译器会使用如下的流程处理 select 语句:
- 将所有的 case 转换成包含 channel 以及类型等信息的 runtime.scase 结构体。
- 调用运行时函数 runtime.selectgo() 从多个准备就绪的 channel 中选择一个可执行的 runtime.scase 结构体。
- 生成一组 if 语句,在语句中判断自己是不是被选中的 case,然后执行 case 对应的代码块。
一个包含三个 case 的正常 select 语句其实会被展开成如下所示的逻辑:
selv := [3]scase{}
order := [6]uint16
for i, cas := range cases {
c := scase{}
c.kind = ...
c.elem = ...
c.c = ...
}
chosen, revcOK := selectgo(selv, order, 3)
if chosen == 0 {
...
break
}
if chosen == 1 {
...
break
}
if chosen == 2 {
...
break
}
运行时
在编译器已经对 select 语句进行优化之后,Go 语言会在运行时执行编译期间展开的 runtime.selectgo() 函数,该函数的主要作用是用于选择就绪的 case。
源码 runtime.selectgo()(src/runtime/select.go)函数实现了 select 语句。
// selectgo implements the select statement.
//
// cas0 points to an array of type [ncases]scase, and order0 points to
// an array of type [2*ncases]uint16 where ncases must be <= 65536.
// Both reside on the goroutine's stack (regardless of any escaping in
// selectgo).
//
// For race detector builds, pc0 points to an array of type
// [ncases]uintptr (also on the stack); for other builds, it's set to
// nil.
//
// selectgo returns the index of the chosen scase, which matches the
// ordinal position of its respective select{recv,send,default} call.
// Also, if the chosen scase was a receive operation, it reports whether
// a value was received.
func selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int, block bool) (int, bool)
函数返回值:
- int: 选中 case 的编号,这个 case 编号跟代码一致。
- bool: 是否成功从channle中读取了数据,如果选中的 case 是从 channel 中读数据,则该返回值表示是否读取成功。
selectgo 执行流程如下:
- 初始化
打乱传入的 case 结构体顺序。随机生成一个遍历的轮询顺序 pollOrder,并根据 Channel 地址生成锁定顺序 lockOrder。
- 循环
根据 pollOrder 遍历所有的 case 查看是否有可以立刻处理的 Channel。
- 如果存在,直接获取 case 对应的索引并返回。
- 如果不存在,创建 runtime.sudog 结构体,将当前 Goroutine 加入到所有相关 Channel 的收发队列,并调用 runtime.gopark 挂起当前 Goroutine 等待调度器的唤醒。
- 唤醒
当调度器唤醒当前 Goroutine 时,会再次按照 lockOrder 遍历所有的 case,从中查找需要被处理的 runtime.sudog 对应的索引并返回。
其中被阻塞放到等待列表中的 G 由 runtime.sudog 来表示。
4.小结
总之,Golang 的 select 语句是一种基于运行时调度器实现的高效 IO 多路复用技术,可以轻松地实现多路复用和并发操作,从而提高程序效率和性能。
参考文献
OpenAI ChatGPT
Go 语言select 的实现原理 - 面向信仰编程
图解Go select语句原理 - 菜刚RyuGou的博客
Go select的使用和实现原理 - 博客园