Go 协程通信channel 的内部实现原理

大多数的编程语言的并发编程模型是基于线程和内存同步,而Golang 的并发编程的模型则用 goroutine 和 channel 来替代,groutine用于执行并发任务,channel用于并发控制以及goroutine的通信。这次跟随一个demo探索一下channel底层的奥秘。

channel数据结构

type hchan struct {
    
    
   // chan里元素数量
   qcount   uint
   // chan维护的数组的长度
   dataqsiz uint
   // 维护的数组的指针
   buf      unsafe.Pointer
   // chan中元素大小
   elemsize uint16
   // chan是否被关闭的标志
   closed   uint32
   // chan 中元素类型
   elemtype *_type
   // 已发送元素在循环数组中的索引
   sendx    uint
   // 已接收元素在循环数组中的索引
   recvx    uint
   // 等待接收的goroutine队列
   recvq    waitq
   // 等待发送的goroutine队列
   sendq    waitq
   // 保证对chan的读写是原子操作
   lock mutex
}

可将其抽象化为下图,便于理解。
在这里插入图片描述

示例代码

/*
示例说明:channel容量为3,设置六个发送,构造goroutine被阻塞状态,
八个接收,构造接收的G阻塞状态场景,便于探秘channel运作过程。
*/
func main() {
    
    

   ch := make(chan int, 3)
   CheckChannel(ch)
}

func CheckChannel(ch chan int) {
    
    
   //6个发送
   go func() {
    
    ch <- 1}()
   go func() {
    
    ch <- 2}()
   go func() {
    
    ch <- 3}()
   go func() {
    
    ch <- 4}()
   go func() {
    
    ch <- 5}()
   go func() {
    
    ch <- 6}()
   //8个接收
   go func() {
    
    <- ch}()
   go func() {
    
    <- ch}()
   go func() {
    
    <- ch}()
   go func() {
    
    <- ch}()
   go func() {
    
    <- ch}()
   go func() {
    
    <- ch}()
   go func() {
    
    <- ch}()
   go func() {
    
    <- ch}()

   time.Sleep(time.Second * 5)
   fmt.Println("stop")
}

调试阶段分析

就缓冲性channel来debug该代码。
首先缓冲型:当执行到CheckChannel时,已经初始化了一块内存。
在这里插入图片描述

打开dehug界面查看该数据结构:

在这里插入图片描述
此时刚初始化,底层数组的元素数量为0。发送索引与接收索引都指向数组索引0,发送与接收队列都没有G存在。

1、一个元素发送

接下来step over,执行完第一个G,观察debug在这里插入图片描述
观察红框内容,看到底层数组的0位置接收到了第一个groutine的数据,底层的sendx指针指向了数组的索引1,表示该再次发送数据会被数组索引1接收,recvx为0,说明有G接收数据时会接收数组索引0处的数据。此时结构如下图:
在这里插入图片描述

2、发送阻塞

继续step over,到发送数据结束,查看debug:
在这里插入图片描述
可以看到由于没有接收者,底层数组里面已经塞满了,查看sendx和recvx的值都为0,说明如果有了接收者就取出索引0处的数据。有发送者就会把数据拷贝到0处(如果0处数据被取出)。

另外查看sendq,可以查看里面已经积压了G队列,这是由于底层数组已塞满,channel会创建一个sudog数据结构,获取G的指针,并将G放入自己的等待队列,此时的积压G处于Gwaiting状态,既不在全局运行队列,也不在某个P(调度器)的运行队列,等待有接受者接收数据,触发goready函数使该G进入可调度即Grunnable状态。

在这里插入图片描述
可以看出,该结构里面积压了三个G,通过next连接下一个G,当然也有pre指针连接前一个G,到第一个或最后一个G的指针指向一个nil。此时结构如下图:

在这里插入图片描述

3、接收第一个元素

继续step over到第一个接收的G,观察该channel结构:

在这里插入图片描述
可以看到,索引0处的1已经被接收,由于有了接收者,等待队列中的G被唤醒,进入可调度即Grunnable状态,调度执行结束后被释放。而积压在sendq的队列第二个G作为了队首,并且sendx和recvx指向索引1,channel发送数据和接收数据都会在数组索引1进行。

查看sendq中的G队列:
在这里插入图片描述
可以看到队首的groutine已经被释放,队列中只剩两个G;
在这里插入图片描述

4、第一批接收结束

继续step over,到第三个接收G执行结束:
在这里插入图片描述
可以看到数组中的元素全部变成了第一次积压在sendq中的G要发送的元素,而且由于已经发送,积压的G全部被释放,索引指针全部指向了0。
在这里插入图片描述

5、数组开始有空闲位

继续step over一下,观察sendx与recvx的指针位置:
在这里插入图片描述
可以看到,由于没有发送方,导致sendx的指针指向索引0,recvx则后移,对剩余数组的元素进行赋值,此时已经没有积压的G,来一个接收者释放一个索引位。

此时的数据结构:
在这里插入图片描述

6、数组已无元素

继续step over,到第六个接收G结束
在这里插入图片描述
恢复到初始的效果了,没有元素,没有积压的G。
此时数据结构如图:

在这里插入图片描述

7、接收阻塞

继续step over创建接收G,到创建G结束,观察:
在这里插入图片描述

recvq接收队列中有两个积压的G被阻塞住,陷入Gwaiting状态,由于程序后面不会有发送者,所以会一直阻塞到主协程退出。

此时数据结构:
在这里插入图片描述

8 调试结束

继续执行,主协程睡眠五秒,退出,子协程全部退出。

对于非缓冲型的channel,则是直接把值从发送的G拷贝到接收的G。

调试总结

说到底,通过channel传递消息就是值的拷贝,有缓冲的channel先把发送方G的值拷贝到自己维护的数组,再拷贝到接收G,而非缓冲型的则直接从发送栈数据拷贝到接收栈空间。

最后贴一个图
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/QiuHaoqian/article/details/108999754