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


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

    1.channel数据结构

    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  // 锁
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

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

    在这里插入图片描述

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

    2.channel的阻塞式和非阻塞式操作

    2.1发送阻塞

    接下来,我们继续使用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有缓冲区但缓冲区已用尽的情况,都会发送阻塞 。

    在这里插入图片描述

    2.1解决发送阻塞

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

    在这里插入图片描述

    2.2接收阻塞

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

    在这里插入图片描述

    2.3解决接收阻塞

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

    在这里插入图片描述

    3.多路select

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

    image-20220915181353456

    我们暂且把这个协程记为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关闭而得到了零值。

    在这里插入图片描述

    再来看第一个返回值,它代表最终哪个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的底层数据结构,了解了环形缓冲区与等待队列,还了解了channel的阻塞与非阻塞式操作,以及多路select的逻辑处理,

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

    4.浅谈channel send操作

    虽然channel的读写操作写法众多,但事实上,channel的常规send操作,会被编译器转换为对runtime.chansend1()的调用 ,而它内部只是调用了runtime.chansend()。
    非阻塞式(select)的send操作,会被编译器转换为对runtime.selectnbsend()的调用,它也仅仅是调用了runtime.chansend() 。
    所以send操作主要是通过runtime.chansend()这个函数实现的。

    在这里插入图片描述

    5.浅谈channel recv操作

    同样的,常规recv操作,会被编译器转换为对runtime.chanrecv1()的调用,而它内部只是调用了runtime.chanrecv(),comma ok风格的写法会被编译器转换为对runtime.chanrecv2()的调用,它的内部也是调用chanrecv() 只不过比chanrecv1()多了一个返回值。
    非阻塞式的recv操作,会根据是否为comma ok风格,被编译器转换为对runtime.selectnbrecv(),或者selectnbrecv2()的调用,而它们两个也仅仅是调用了runtime.chanrecv(),所以recv操作主要是通过chanrecv()函数实现的。

    在这里插入图片描述

  • 相关阅读:
    【科研论文】Endnote入门指南
    [纯理论] FCOS
    asp.net企业生产管理系统VS开发sqlserver数据库web结构c#编程Microsoft Visual Studio
    大数据开发工程师要求高么?有前景么
    交叉编译BusyBox
    2023年建筑电工(建筑特殊工种)证考试题库及建筑电工(建筑特殊工种)试题解析
    本地Chatglm2-6b模型训练,deepspeed依赖安装报错。
    正则表达式提取http和http内容
    JUC并发编程--------AQS以及各类锁
    JDBC 连接数据库的四种方式
  • 原文地址:https://blog.csdn.net/qq_53267860/article/details/126884033