golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select


文章目录

  • GoLang之channel数据结构阻塞、非阻塞操作、多路select
    • 1.channel数据结构
    • 2.channel的阻塞式和非阻塞式操作
      • 2.1发送阻塞
      • 2.1解决发送阻塞
      • 2.2接收阻塞
      • 2.4解决接收阻塞
    • 3.多路select
    • 4.浅谈channel send操作
    • 5.浅谈channel recv操作

GoLang之channel数据结构阻塞、非阻塞操作、多路select 1.channel数据结构
type hchan struct { qcountuint// 数组长度,即已有元素个数 dataqsiz uint// 数组容量,即可容纳元素个数 bufunsafe.Pointer // 数组地址 elemsize uint16// 元素大小 closeduint32// 关闭状态 elemtype *_type // 元素类型 sendxuint// 下一次写下标位置 recvxuint// 下一次读下标位置 recvqwaitq// 读等待队列 sendqwaitq// 写等待队列 lockmutex// 锁 }

我们通过make创建一个缓冲区大小为5,元素类型为int的channel。ch是存在于函数栈帧上的一个指针,指向堆上的hchan数据结构。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

因为channel免不了支持协程间并发访问,所以要有一个锁(lock)来保护整个channel数据结构。
对于有缓冲区channel来讲,需要知道缓冲区在哪里(buf),已经存储了多少个元素(qcount),最多存储多少个元素(dataqsize),每个元素占多大空间(elemsize),所以实际上,缓冲区就是一个数组。因为Golang运行时中,内存复制,垃圾回收等机制,依赖数据的类型信息,所以hchan这里还要有一个指针,指向元素类型的类型元数据。此外,channel支持交替的读(接收),写(发送)。需要分别记录读,写 下标的位置,当读和写不能立即完成时,需要能够让当前协程在channel上等待,待到条件满足时,要能够立即唤醒等待的协程,所以要有两个等待队列,分别针对读和写。此外,channel能够close,所以还要记录它的关闭状态,综上所述,channel底层就长这样。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

2.channel的阻塞式和非阻塞式操作 2.1发送阻塞
接下来,我们继续使用ch,初始状态下,ch的缓冲区为空,读、写下标都指向下标0的位置,等待队列也都为空。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

然后一个协程g1向ch中发送数据,因为没有协程在等待接收数据,所以元素都被存到缓冲区中,sendx从0开始向后挪,
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

第5个元素会放到下标为4的位置,然后sendx重新回到0,此时缓冲区已经没有空闲位置了。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

所以接下来发送的第6个元素无处可放,g1会进到ch的发送等待队列中。这是一个sudog类型的链表,里面会记录哪个协程在等待,等待哪个channel,等待发送的数据在哪里,等等消息。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

接下来协程g2从ch接收一个元素,recv指向下个位置,第0个位置就空出来了,
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

所以会唤醒sendq中的g1,将elem指向的数据发送给ch,然后缓冲区再次满了,sendq队列为空。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

在这一过程中,可以看到sendx和recvx,都会从0到4再到0,所以channel的缓冲区,被称为"环形"缓冲区。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

如果像这样给channel发送数据,只有在缓冲区还有空闲位置,或者有协程在等着接收数据的时候,才不会发送阻塞。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

碰到ch为nil,或者ch没有缓冲区,而且也没有协程等着接收数据,又或者,ch有缓冲区但缓冲区已用尽的情况,都会发送阻塞 。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

2.1解决发送阻塞
那如果不想阻塞的话,就可以使用select,使用select这种写法时,如果检测到ch可以发送数据,就会执行case分支;如果会阻塞,就会执行default分支了。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

2.2接收阻塞
这是发送数据的写法,接收数据的写法要更多一点。第一种写法会将结果丢弃,第二种写法将结果赋给变量v,第三种是comma ok风格的写法,ok为false时表示ch已关闭,此时v是channel元素类型的零值。这几种写法都允许发生阻塞,只有在缓冲区种有数据,或者有协程等着发送数据时 ,才不会阻塞。如果ch为nil,或者ch无缓冲而且没有协程等着发送数据,又或者ch有缓冲但缓冲区无数据时,都会发生阻塞。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

2.4解决接收阻塞
如果无论如何都不想阻塞,同样可以采用非阻塞式写法,这样在检测到ch的recv操作不会阻塞时,就会执行case分支,如果会阻塞,就会执行default分支。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

3.多路select
上面的selec只是针对的单个channel的操作;
多路select指的是存在两个或者更多的case分支,每个分支可以是一个channel的send或recv操作。例如一个协程通过多路select等待ch1和ch2。这里的default分支是可选的。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

我们暂且把这个协程记为g1,多路select会被编译器转换为runtime.selectgo函数调用。
第一个参数cas0指向一个数组,数组里装的是select中所有的case分支,顺序是send在前,recv在后。
第二个参数order0指向一个uint16类型的数组,数组大小等于case分支的两倍。实际上被用作两个数组,第一个数组用来对所有channel的轮询进行乱序,第二个数组用来对所有channel的加锁操作进行排序。轮询需要乱序才能保障公平性,而按照固定算法确定加锁顺序才能避免死锁。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

第三个参数pc0和race检测相关,我们暂时不关心。
第四、五个参数nsends和nrecvs分别表示所有case中执行send和recv操作的分支分别有多少个。
第六个参数block表示多路select是否要阻塞等待,对应到代码中,就是有default分支的不会阻塞,没有的会阻塞。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

再来看第一个返回值,它代表最终哪个case分支被执行了,对应到参数cas0数组的下标。但是如果进到default分支则对应-1。第二个返回值用于在执行recv操作的case分支时,表明是实际接收到了一个值,还是因channel关闭而得到了零值。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

多路select需要进行轮询来确定哪个case分支可操作了,但是轮询前要先加锁,所以selectgo函数执行时,会先按照有序的加锁顺序,对所有channel加锁,然后按照乱序的轮询顺序检查所有channel的等待队列和缓冲区。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

假如检查到ch1时,发现有数据可读,那就直接拷贝数据,进入对应分支。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

假如所有channel都不可操作,就把当前协程添加到所有channel的sendq或recvq中。对应到本例中,g1会被添加到ch1的recvq,以及ch2的sendq中。之后g1会挂起,并解锁所有的channel的锁。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

假如接下来ch1有数据可读了,g1就会被唤醒,完成对应分支的操作。
完成对应分支的操作后,会再次按照加锁顺序对所有channel加锁,然后从所有sendq或recvq中将自己移除,最后全部解锁,然后返回。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

这一次我们看到了channel的底层数据结构,了解了环形缓冲区与等待队列,还了解了channel的阻塞与非阻塞式操作,以及多路select的逻辑处理,
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

4.浅谈channel send操作
虽然channel的读写操作写法众多,但事实上,channel的常规send操作,会被编译器转换为对runtime.chansend1()的调用 ,而它内部只是调用了runtime.chansend()。
非阻塞式(select)的send操作,会被编译器转换为对runtime.selectnbsend()的调用,它也仅仅是调用了runtime.chansend() 。
所以send操作主要是通过runtime.chansend()这个函数实现的。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

5.浅谈channel recv操作
【golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select】同样的,常规recv操作,会被编译器转换为对runtime.chanrecv1()的调用,而它内部只是调用了runtime.chanrecv(),comma ok风格的写法会被编译器转换为对runtime.chanrecv2()的调用,它的内部也是调用chanrecv() 只不过比chanrecv1()多了一个返回值。
非阻塞式的recv操作,会根据是否为comma ok风格,被编译器转换为对runtime.selectnbrecv(),或者selectnbrecv2()的调用,而它们两个也仅仅是调用了runtime.chanrecv(),所以recv操作主要是通过chanrecv()函数实现的。
golang|GoLang之channel数据结构及阻塞、非阻塞操作、多路select
文章图片

    推荐阅读