Golang底层原理剖析之多路select、channel数据结构和阻塞与非阻塞

channel

在这里插入图片描述
在这里插入图片描述

我们通过make创建一个缓冲区大小为5,元素类型为int的channel。ch是存在于函数栈帧上的一个指针,指向堆上的hchan数据结构。

在这里插入图片描述

type hchan struct {
    
    
    qcount   uint           // 数组长度,即已有元素个数
    dataqsiz uint           // 数组容量,即可容纳元素个数
    buf      unsafe.Pointer // 数组地址
    elemsize uint16         // 元素大小
    closed   uint32			// 关闭状态
    elemtype *_type // 元素类型
    sendx    uint   // 下一次写下标位置
    recvx    uint   // 下一次读下标位置
    recvq    waitq  // 读等待队列
    sendq    waitq  // 写等待队列
    lock     mutex  // 锁
}

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

在这里插入图片描述

接下来,我们继续使用ch,初始状态下,ch的缓冲区为空,读、写下标都指向下标0的位置,等待队列也都为空。

在这里插入图片描述

然后一个协程g1向ch中发送数据,因为没有协程在等待接收数据,所以元素都被存到缓冲区中,sendx从0开始向后挪,第5个元素会放到下标为4的位置,然后sendx重新回到0,此时缓冲区已经没有空闲位置了。

在这里插入图片描述

所以接下来发送的第6个元素无处可放,g1会进到ch的发送等待队列中。这是一个sudog类型的链表,里面会记录哪个协程在等待,等待哪个channel,等待发送的数据在哪里,等等消息。

在这里插入图片描述

接下来协程g2从ch接收一个元素,recv指向下个位置,第0个位置就空出来了,所以会唤醒sendq中的g1,将elem指向的数据发送给ch,然后缓冲区再次满了,sendq队列为空。

在这里插入图片描述

在这一过程中,可以看到sendx和recvx,都会从0到4再到0,所以channel的缓冲区,被称为"环形"缓冲区。
在这里插入图片描述

如果像这样给channel发送数据,只有在缓冲区还有空闲位置,或者有协程在等着接收数据的时候,才不会发送阻塞。碰到ch为nil,或者ch没有缓冲区,而且也没有协程等着接收数据,又或者,ch有缓冲区单缓冲区已用尽的情况,都会发送阻塞 。
在这里插入图片描述

那如果不想阻塞的话,就可以使用select,使用select这种写法时,如果检测到ch可以发送数据,就会执行case分支,如果会阻塞,就会执行default分支了。

在这里插入图片描述

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

在这里插入图片描述

如果无论如何都不想阻塞,同样可以采用非阻塞式写法,这样在检测到ch的recv操作不会阻塞时,就会执行case分支,如果会阻塞,就会执行default分支。

多路select

在这里插入图片描述

多路select指的是存在两个或者更多的case分支,每个分支可以是一个channel的send或recv操作。例如一个协程通过多路select等待ch1和ch2。这里的default分支是可选的。

在这里插入图片描述

我们暂且把这个协程记为g1,多路select会被编译器转换为runtime.selectgo函数调用。

  • 第一个参数cas0指向一个数组,数组里装的是select中所有的case分支,顺序是send在前,recv在后。
  • 第二个参数order0指向一个uint16类型的数组,数组大小等于case分支的两倍。实际上被用作两个数组,第一个数组用来对所有channel的轮询进行乱序,第二个数组用来对所有channel的加锁操作进行排序。轮询需要乱序才能保障公平性,而按照固定算法确定加锁顺序才能避免死锁。
  • 第三个参数pc0和race检测相关,我们不关心。
  • 第四、五个参数nsends和nrecvs分别表示所有case中执行send和recv操作的分支分别有多少个。
  • 第六个参数block表示多路select是否要阻塞等待,对应到代码中,就是有default分支的不会阻塞,没有的会阻塞。

在这里插入图片描述

再来看第一个返回值,它代表最终哪个case分支被执行了,对应到参数cas0数组的下标。但是如果进到default分支则对应-1。第二个返回值用于在执行recv操作的case分支时,表明是实际接收到了一个值,还是因channel关闭而得到了零值。

在这里插入图片描述

多路select需要进行轮询来确定哪个case分支可操作了,但是轮询前要先加锁,所以selectgo函数执行时,会先按照有序的加锁顺序,对所有channel加锁,然后按照乱序的轮询顺序检查所有channel的等待队列和缓冲区。

在这里插入图片描述

假如检查到ch1时,发现有数据可读,那就直接拷贝数据,进入对应分支。

在这里插入图片描述

假如所有channel都不可操作,就把当前协程添加到所有channel的sendq或recvq中。对应到本例中,g1会被添加到ch1的recvq,以及ch2的sendq中。之后g1会挂起,并解锁所有的channel的锁。
在这里插入图片描述

假如接下来ch1有数据可读了,g1就会被唤醒,完成对应分支的操作。
在这里插入图片描述

完成对应分支的操作后,会再次按照加锁顺序对所有channel加锁,然后从所有sendq或recvq中将自己移除,最后全部解锁,然后返回。

在这里插入图片描述
在这里插入图片描述

浅谈channel send操作

在这里插入图片描述
channel的常规send操作,会被编译器转换为对runtime.chansend1()的调用 ,而它内部只是调用了runtime.chansend()。

在这里插入图片描述

非阻塞式(select)的send操作,会被编译器转换为对runtime.selectnbsend()的调用,它也仅仅是调用了runtime.chansend() 。

在这里插入图片描述
所以send操作主要是通过这个函数实现的。

浅谈channel recv操作

在这里插入图片描述

同样的,常规recv操作,会被编译器转换为对runtime.chanrecv1()的调用,而它内部只是调用了runtime.chanrecv(),comma ok风格的写法会被编译器转换为对runtime.chanrecv2()的调用,它的内部也是调用chanrecv() 只不过比chanrecv1()多了一个返回值。

在这里插入图片描述

非阻塞式的recv操作,会根据是否为comma ok风格,被编译器转换为对runtime.selectnbrecv(),或者selectnbrecv2()的调用,而它们两个也仅仅是调用了runtime.chanrecv(),所以recv操作主要是通过chanrecv()函数实现的。

Guess you like

Origin blog.csdn.net/qq_42956653/article/details/121367875