Go 的select语句是一种仅能用于channl发送和接收消息的专用语句,此语句运行期间是阻塞的;当select中没有case语句的时候,会阻塞当前的groutine。所以,有人也会说select是用来阻塞监听goroutine的。
还有人说:select是Golang在语言层面提供的I/O多路复用的机制,其专门用来检测多个channel是否准备完毕:可读或可写。
以上说法都正确。
I/O多路复用
我们来回顾一下是什么是I/O多路复用
。
普通多线程(或进程)I/O
每来一个进程,都会建立连接,然后阻塞,直到接收到数据返回响应。
普通这种方式的缺点其实很明显:系统需要创建和维护额外的线程或进程。因为大多数时候,大部分阻塞的线程或进程是处于等待状态,只有少部分会接收并处理响应,而其余的都在等待。系统为此还需要多做很多额外的线程或者进程的管理工作。
为了解决图中这些多余的线程或者进程,于是有了”I/O多路复用”
I/O多路复用
每个线程或者进程都先到图中”装置“中注册,然后阻塞,然后只有一个线程在”运输“,当注册的线程或者进程准备好数据后,”装置“会根据注册的信息得到相应的数据。从始至终kernel只会使用图中这个黄黄的线程,无需再对额外的线程或者进程进行管理,提升了效率。
select组成结构
select的实现经历了多个版本的修改,当前版本为:1.11
select这个语句底层实现实际上主要由两部分组成:case语句
和执行函数
。
源码地址为:/go/src/runtime/select.go
每个case语句,单独抽象出以下结构体:
type scase struct {
c *hchan // chan
elem unsafe.Pointer // 读或者写的缓冲区地址
kind uint16 //case语句的类型,是default、传值写数据(channel <-) 还是 取值读数据(<- channel)
pc uintptr // race pc (for race detector / msan)
releasetime int64
}
结构体可以用下图表示:
其中比较关键的是:hchan
,它是channel的指针。
在一个select中,所有的case语句会构成一个scase
结构体的数组。
然后执行select语句实际上就是调用func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)
函数。
func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)
函数参数:
- cas0 为上文提到的case语句抽象出的结构体
scase
数组的第一个元素地址 - order0为一个两倍cas0数组长度的buffer,保存scase随机序列pollorder和scase中channel地址序列lockorder。
- nncases表示
scase
数组的长度
selectgo
返回所选scase的索引(该索引与其各自的select {recv,send,default}调用的序号位置相匹配)。此外,如果选择的scase是接收操作(recv),则返回是否接收到值。
谁负责调用func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)
函数呢?
在/reflect/value.go
中有个func rselect([]runtimeSelect) (chosen int, recvOK bool)
函数,此函数的实现在/runtime/select.go
文件中的func reflect_rselect(cases []runtimeSelect) (int, bool)
函数中:
func reflect_rselect(cases []runtimeSelect) (int, bool) {
//如果cases语句为空,则阻塞当前groutine
if len(cases) == 0 {
block()
}
//实例化case的结构体
sel := make([]scase, len(cases))
order := make([]uint16, 2*len(cases))
for i := range cases {
rc := &cases[i]
switch rc.dir {
case selectDefault:
sel[i] = scase{kind: caseDefault}
case selectSend:
sel[i] = scase{kind: caseSend, c: rc.ch, elem: rc.val}
case selectRecv:
sel[i] = scase{kind: caseRecv, c: rc.ch, elem: rc.val}
}
if raceenabled || msanenabled {
selectsetpc(&sel[i])
}
}
return selectgo(&sel[0], &order[0], len(cases))
}
那谁调用的func rselect([]runtimeSelect) (chosen int, recvOK bool)
呢?
在/refect/value.go
中,有一个func Select(cases []SelectCase) (chosen int, recv Value, recvOK bool)
的函数,其调用了rselect
函数,并将最终Go中select语句的返回值的返回。
以上这三个函数的调用栈按顺序如下:
func Select(cases []SelectCase) (chosen int, recv Value, recvOK bool)
func rselect([]runtimeSelect) (chosen int, recvOK bool)
func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)
这仨函数中无论是返回值还是参数都大同小异,可以简单粗暴的认为:函数参数传入的是case语句,返回值返回被选中的case语句。
那谁调用了func Select(cases []SelectCase) (chosen int, recv Value, recvOK bool)
呢?
可以简单的认为是系统了。
来个简单的图:
前两个函数Select
和rselect
都是做了简单的初始化参数,调用下一个函数的操作。select真正的核心功能,是在最后一个函数func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)
中实现的。
selectgo函数做了什么
打乱传入的case结构体顺序
锁住其中的所有的channel
遍历所有的channel,查看其是否可读或者可写
如果其中的channel可读或者可写,则解锁所有channel,并返回对应的channel数据
假如没有channel可读或者可写,但是有default语句,则同上:返回default语句对应的scase并解锁所有的channel。
假如既没有channel可读或者可写,也没有default语句,则将当前运行的groutine阻塞,并加入到当前所有channel的等待队列中去。
然后解锁所有channel,等待被唤醒。
此时如果有个channel可读或者可写ready了,则唤醒,并再次加锁所有channel,
遍历所有channel找到那个对应的channel和G,唤醒G,并将没有成功的G从所有channel的等待队列中移除。
如果对应的scase值不为空,则返回需要的值,并解锁所有channel
如果对应的scase为空,则循环此过程。
select和channel之间的关系
在想想select和channel做了什么事儿,我觉得和多路复用是一回事儿
select 语法是配套于 channel 一起用的语法,核心点是实现了 IO 多路复用的原理,能够在应用层去监听多个 channel 是否已经准备就绪。
小结 select case:
把 select 里面每个 case 在编译后转成 scase 结构体
先用 runtime.fastrandn 打乱全部 case 的初始顺序
调用 select go 方法逐个锁住 channel 们。去遍历是否有就绪的 channel,有的话则命中其中一个就绪 channel。如果没有就绪的 channel 就会走 default 逻辑,若 default 逻辑都没有就会先解锁所有 channel,让 select 对应的协程就会 gopark 进行休眠,等待 channel 就绪的通知。当有 channel 就绪就会唤醒,重新走刚开始的步骤,命中其中一个就绪 channel