Go最吸引人的两个地方,除了goroutine,也就是channel了,同时,我一直很纳闷,select到底是怎么实现的?跟我之前的文章一样,部分无关的代码直接省略
结构概览
hchan
这个就是channel的结构体了
1 | type hchan struct { |
waitq
1 | type waitq struct { |
sudog
sudog 代表了一个在等待中的g
1 | type sudog struct { |
hcase
这个是 select 中一个case生成的结构体
1 | type scase struct { |
通过上面的结构,我们可以看出,channel的内部实质就是一个缓冲池+两个队列(send recv),那么数据是如何交互的呢,网上有个示意图,展示的还是比较形象的
图示
无缓冲(同步)
带缓冲(异步)
综合 上面的结构和图示,大概可以推测出 channel 的send recv流程
- 如果是recv(<-channel )请求,则先去判断一个sendq队列里有没有人等待这放数据
- 如果sendq队列不为空且缓冲池不为空,那么这个sendq队列是在等待着放数据,recv的这个g从缓冲池拿数据,然后把sendq的第一个g携带的数据放入到buf缓冲池里面即可
- 如果sendq不为空但是缓冲池为空,那么这个是不带缓冲池的chan,我从sendq里面拿第一个g的数据就ok了
- 如果sendq为空,那就去缓冲池看看,缓冲池有数据,那就拿了就走了
- 如果sendq为空,缓冲池也没有数据,那就在这等着吧
- 如果send,流程跟recv是一样的
- 如果此时 channel 被close了,唤醒所有等待的队列 (sendq 或 recvq)里面的等待的g,告诉他们channel.close = true
接下来就是跟踪源码,证明及纠正猜想了
源码分析
收发
main
我们使用 go tool 工具分析一下,channel 生成, c <- i, <- c 在底层都是通过什么方法实现的
1 | func main() { |
go build -gcflags=all=”-N -l” main.go
go tool objdump -s “main.main” main
我们把 CALL 过滤出来后
1 | ▶ go tool objdump -s "main\.main" main | grep CALL |
- makechan: 创建channel的函数,有无缓冲区的都是一样的
- chanrecv1: <- c1 时,调用的函数
- closechan: close(c1) 时调用的函数,关闭channel使用
- chansend1: c1 <- 1 时,也就是发送数据用到的函数
makechan
创建channel这一块主要就是给结构体和bug缓冲池分配内存,然后初始化一下hchan的结构体
1 | func makechan(t *chantype, size int) *hchan { |
chanrecv1
chanrecv1
调用了chanrecv
实现,chanrecv
监听channel并接收 channel里面的数据,并写入到 ep 里面
1 | func chanrecv1(c *hchan, elem unsafe.Pointer) { |
通过上面的逻辑,可以看出来数据传输的四种可能
- sendq队列不为空,但是buf为空(同步有阻塞g的情况): 获取sendq队列头的sudog,并把sudog.elem数据拷贝目标地址 ep
- sendq队列不为空,buf也不为空(异步有阻塞g的情况):把buf头元素拷贝到目标地址ep, 获取sendq队列头的sudog,然后把sudog.elem的数据拷贝到buf队尾,释放sudog
- sendq队列为空,但是buf不为空(异步无阻塞g的情况):把buf头元素拷贝到目标地址ep即可
- sendq队列为空,buf也为空(同步无阻塞g的情况):这时候就需要就需要阻塞自身,获取一个sudog的结构,放到channel的recvq队列里,等待send的g来唤醒自己,并把自己的数据拷贝到目标地址
这里细想一下,其实会发现一个问题,在上面L66 goparkunlock(&c.lock, waitReasonChanReceive, traceEvGoBlockRecv, 3)
休眠g后,g被唤醒后从这里开始继续往下执行,好像没有什么逻辑显示,这个recv g获取到了数据,这个g阻塞在这里是为了等数据来的,但是下面的逻辑,竟然没有一个是操作数据的?
接下来分析的 recv
这个方法就能理解了
recv
1 | func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { |
结合上面的逻辑就发现,g在被唤醒之前,跟g相关的sudog的数据就已经被channel使用掉了,所以当g被唤醒时,无需处理跟数据传输相关的逻辑了
2acquireSudog
获取一个sudog的结构,这里跟cache和scheduler调度待运行g的队列一样,使用了 p sched 的两级缓存,也就是本地缓存一个sudog的数组,同时在全局的 sched结构上面也维护了一个sudogcache的链表,当p本地的sudog不足或者过多的时候,就去跟全局的sched 进行平衡
1 | func acquireSudog() *sudog { |
releaseSudog
releaseSudog
就是释放当前使用的sudog,并平衡p本地缓存的sudog和全局队列的sudog
1 | func releaseSudog(s *sudog) { |
chansend1
发送逻辑跟接收的逻辑差不多
1 | func chansend1(c *hchan, elem unsafe.Pointer) { |
send
send
跟 recv
的逻辑也是大致相同的,而且因为从recvq里面拿到了一个sudog,所以说明缓冲区为空,那么send
方法就不需要考虑往缓冲区添加数据了,send
比recv
更加简单,只需要交换数据、唤醒g即可
1 | func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { |
closechan
收发数据已经结束了,最后就是关闭channel了
1 | func closechan(c *hchan) { |
chan close之后,所有阻塞的recvq 和 sendq(recvq和sendq只有有一个队列存在)中的sudog,清除sudog的一些数据和状态,设置 gp.param = nil
, 让上层逻辑知道这是因为 close chan导致的
唤醒所有的g之后,g就会 继续执行 chansend
或者 chanrecv
中剩余的逻辑,也就是释放sudog(这也就是为什么 closechan 不需要释放sudog的原因)
总结
语言的表述总是苍白的,在网上找资料的时候正好看到了两张流程图,可以结合着来看
发送流程(send)
接收流程(recv)
select
main
channel的收发流程在上面已经追踪了,流程也已经清晰了,但是跟channel一起使用的还有一个select,那select的流程又是什么呢
我们还是用go tool工具分析一下
1 | func main() { |
分析结果过滤一下CALL
1 | main.go:9 0x4a05c6 e81542f6ff CALL runtime.makechan(SB) |
可以看出来,select 的实现是靠 selectgo
函数的
以为就这样吗,然后我们就开始分析 selectgo
函数了,不,在我手贱的时候还发现了另一种情况
1 | func main() { |
分析结果如下:
1 | main.go:9 0x49eca8 e8335bf6ff CALL runtime.makechan(SB) |
可以看到,这里 select 的实现是依靠底层的 selectnbrecv
的函数的,如果,既然有 selectnbrecv
函数,会不会有 selectnbsend
函数呢,继续试验一下
1 | func main() { |
分析j结果
1 | main.go:9 0x49ecb3 e8285bf6ff CALL runtime.makechan(SB) |
这里就是用 selectnbsend
函数实现了 select 语句,然后继续试验,得出结论如下:
- 如果select语句中只有一个 case在等待从channel中接收数据,则调用
selectnbrecv
实现 - 如果select语句中只有一个 case在等待向channel发送数据,则调用
selectnbsend
实现 - 如果select语句中有多个case,在等待向一个或多个channel发送或接收数据,则调用
selectgo
实现
好了,我们开始从 selectgo
开始跟踪了,但是跟踪selectgo之前,我们需要选跟踪一下 reflect_rselect
, 不然看着 selectgo
函数的参数,完全就是一脸懵逼啊
reflect_rselect
1 | func reflect_rselect(cases []runtimeSelect) (int, bool) { |
selectgo
1 | func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) { |
selectnbrecv
当一个select里面只有一个 case,且这个case 是接收数据的操作的时候,select就会调用 selectnbrecv
函数来实现
1 | func selectnbrecv(elem unsafe.Pointer, c *hchan) (selected bool) { |
这里就会发现 selectnbrecv
就是调用了 chanrecv
来实现,也就是我们上面解析的 <- c1
是一样的,就相当于 select 退变 成单独的 <- c
的表达了
selectnbsend
同 selectnbrecv
一样,当select只有一个case,且这个case是发送数据到channel的,就会退变成 c <- 1
的表达了
1 | func selectnbsend(c *hchan, elem unsafe.Pointer) (selected bool) { |
总结
所以,select的流程大致如下
- 对每个case进行收发判断,是否需要阻塞,不需要,直接跳转执行
- 如果每个case的收发操作都需要阻塞等待,则判断有没有default,如果有,执行default
- 如果每个case的收发操作都需要徐瑟等待,且没有default,那就为每个case创建一个sudog,绑定到case对应的channel的sendq或recvq队列
- 如果某个sudog被临幸,然后被唤醒了,清空所有sudog的数据等属性,并把其他的sudog从队列中移除
- 至此,一个select操作结束
总结
我还是很像吐槽一下,selectgo
函数华丽丽的写了300多行,里面还使用了若干的 goto
去进行跳转,真的不可以分拆一下吗,不过大神的代码,还是真的需要膜拜的
参考文档
《Go语言学习笔记》– 雨痕