本文主要分析golang实现并发基础组件channel的实现原理;
主要内容分为几个部分
Section1:channel使用实例分析
Section2:源码分析
Golang-Channel原理解析
Section1 channel使用实例
channel主要是为了实现go的并发特性,用于并发通信的,也就是在不同的协程单元goroutine之间同步通信。
下面主要从三个方面来讲解:
- make channel,主要也就是hchan的数据结构原型;
- 发送和接收数据时,goroutine会怎么调度;
- 设计思考;
1.1 make channel
我们创建channel时候有两种,一种是带缓冲的channel一种是不带缓冲的channel。创建方式分别如下:
// buffered
ch := make(chan Task, 3)
// unbuffered
ch := make(chan int)
buffered channel
如果我们创建一个带buffer的channel,底层的数据模型如下图:
当我们向channel里面写入数据时候,会直接把数据存入circular queue(send)。当Queue存满了之后就会是如下的状态:
当dequeue一个元素时候,如下所示:
从上图可知,recvx自增加一,表示出队了一个元素,其实也就是循环数组实现FIFO语义。
那么还有一个问题,当我们新建channel的时候,底层创建的hchan数据结构是在哪里分配内存的呢?其实Section2里面源码分析时候已经做了分析,hchan是在heap里面分配的。
如下图所示:
当我们使用make去创建一个channel的时候,实际上返回的是一个指向channel的pointer,所以我们能够在不同的function之间直接传递channel对象,而不用通过指向channel的指针。
1.2 sends and receives
不同goroutine在channel上面进行读写时,涉及到的过程比较复杂,比如下图:
上图中G1会往channel里面写入数据,G2会从channel里面读取数据。
G1作用于底层hchan的流程如下图:
- 先获取全局锁;
- 然后enqueue元素(通过移动拷贝的方式);
- 释放锁;
G2读取时候作用于底层数据结构流程如下图所示:
- 先获取全局锁;
- 然后dequeue元素(通过移动拷贝的方式);
- 释放锁;
上面的读写思路其实很简单,除了hchan数据结构外,不要通过共享内存去通信;而是通过通信(复制)实现共享内存。
写入满channel的场景
如下图所示:channel写入3个task之后队列已经满了,这时候G1再写入第四个task的时候会发生什么呢?
G1这时候会暂停直到出现一个receiver。
这个地方需要介绍一下Golang的scheduler的。我们知道goroutine是用户空间的线程,创建和管理协程都是通过Go的runtime,而不是通过OS的thread。
但是Go的runtime调度执行goroutine却是基于OS thread的。如下图:
具体关于golang的scheduler的原理,可以看前面的一篇博客,关于go的scheduler原理分析。
当向已经满的channel里面写入数据时候,会发生什么呢?如下图:
上图流程大概如下:
- 当前goroutine(G1)会调用gopark函数,将当前协程置为waiting状态;
- 将M和G1绑定关系断开;
- scheduler会调度另外一个就绪态的goroutine与M建立绑定关系,然后M 会运行另外一个G。
所以整个过程中,OS thread会一直处于运行状态,不会因为协程G1的阻塞而阻塞。最后当前的G1的引用会存入channel的sender队列(队列元素是持有G1的sudog)。
那么blocked的G1怎么恢复呢?当有一个receiver接收channel数据的时候,会恢复 G1。
实际上hchan数据结构也存储了channel的sender和receiver的等待队列。数据原型如下:
等待队列里面是sudog的单链表,sudog持有一个G代表goroutine对象引用,elem代表channel里面保存的元素。当G1执行ch<-task4
的时候,G1会创建一个sudog然后保存进入sendq队列,实际上hchan结构如下图:
这个时候,如果G1进行一个读取channel操作,读取前和读取后的变化图如下图: