深入解析Go Channel各状态下的操作结果
作者 lixuewei@it168.com 2023-06-21 14:45

  channel是golang中独有的特性,也是面试中经常被问到的。相信大家都看到过下面这张图,对于不同状态下通道,在操作时会有什么结果。

  这张图总结的非常好。但我们不能死记硬背这些结果。要了解其底层的基本原理,就能理解这些结果是怎么来的。

  我们分三部分来讲。先是channel的基础使用,基础使用提现了channel有哪些特性。再引出channel的底层数据结构。底层数据结构就是围绕这些特性而建立的。最后再看go是如何基于底层数据结构来实现这些特性的。

  channel的基础使用

  通道的定义和初始化

  通过var定义通道

  通过var定义一个通道变量ch,这个变量能够接收整型的数据。当然也可以指定其他任何数据类型。

  ch 代表变量名

  chan固定值。代表ch是通道类型

  int代表在通道ch中存储的是整型数据。

  ch变量的默认值是nil。对于nil通道在操作时会有特殊的场景,一会我们也会讲解。

  通过make初始化通道

  通过make可以初始化无缓冲区通道和缓冲区通道。区别就在于make中是否指定了缓冲区的大小。如下:

  无缓冲通道和有缓冲通道的区别可以从属性上和行为两方面来体现:

  从属性上区别:通道是否有一段缓冲区来暂存元素。

  从行为上区别:发送者和接收者是否同步的还是异步的。

  从底层数据结构上区别:是否有一块缓冲区来暂存数据。这个后面会详细讲解。

  通道的操作

  golang中对于通道有三种操作:往通道中发送元素、从通道中接收元素、关闭通道。如下:往通道中发送元素:

  总结一下:

  通道有三种操作:发送、接收和关闭。

  通道有三种类型:nil通道、无缓冲通道和有缓冲通道。

  通道有2种状态:关闭状态和未关闭状态。

  缓冲通道的未关闭状态又可以分为缓冲区满、缓冲区未满状态。

  那么,通道是基于怎样的数据结构来完成这些行为的呢?

  channel的数据结构

  我们先给出channel的底层数据结构,如下:

  根据上面的结构定义,依次解释下各个字段的含义:

  buf:指向一个数组,代表的是一个队列,结合sendx和recvx字段实现了环形队列。缓存对应的元素。缓冲区通道就是利用这个字段实现的。

  qcount:在buf队列中当前有多少个元素。

  dataqsiz:代表队列buf的容量。在使用make进行初始化时,指定的元素个数就存在该字段中。

  elemsize:一个元素的字节大小。根据该元素的大小,可以初始化buf的容量的大小。通过elemsize*容量就能知道该给buf分配多少字节的空间了。

  closed:代表该通道是否被关闭。其值只有0和1。1代表该通道已经关闭了。0代表未关闭。

  elemtype:代表元素的类型。

  sendx:代表的是发送下一个元素应该存储的位置

  recvx:代表的是下一个接收元素的位置。

  recvq:代表的是等待接收元素的协程队列

  sendq:代表的是发送元素的协程队列。

  根据以上结果,绘制成图会容易理解点,如下:

  缓冲通道和非缓冲通道的区别

  从定义上,缓冲通道和非缓冲通道都是通过make来初始化的。不同点在于是否在make函数上指定了通道的容量大小。如下:

  从通道的底层数据结构上来说,非缓冲渠道不会初始化结构体中的buf字段。而缓冲渠道则会初始化buf字段。该字段指向一块内存区域。如下图:

  通道的发送、接收流程

  通过源码我们梳理出来了给通道发送数据和从通道中接收数据的流程图。这张流程图将缓冲通道和无缓冲通道两种状态下的发送和接收流程都包含了,所以看起来会比较复杂。但是没关系,下面我们会分解这张图。

  通过上面的流程,大家需要注意的一点就是,无论是在发送还是接收操作时,都是优先从等待队列中获取对应的线程,如果有,则直接接收或发送;如果等待队列没有协程,然后再看是否有缓冲区。这一点需要大家额外注意。

  各状态通道的操作

  无缓冲通道

  根据上述无缓冲通道其实本质上就是没有缓冲区。在初始化时不指定make的容量即可。实际上这也叫做同步发送和接收。针对这种状态的通道,当发送数据时,如果接收队列中有等待的接收协程,那么就能发送成功;否则,进入阻塞状态。反之,亦然。其流程图就是图中的红色箭头部分,如下:

  再简化一下就是:

  往无缓冲区中发送数据时,如果有等待接收的协程,则发送成功;否则,发送协程进入阻塞状态。

  从无缓冲区接收数据时,如果有等待发送的协程,则接收成功;否则,接收协程进入阻塞状态。

  那么,上面的图可以简化成如下:

  另外需要额外注意一点,对于非缓冲区通道的发送和接收操作。如果是在main函数中进行发送和接收,那么会造成死锁。如下:

  所以,对于非缓冲区通道的发送和接收操作,最主要的问题就是可能会造成阻塞。除非,两个发送和接收协程都存在,而且要在不同的协程里。

  有缓冲通道

  有缓冲区通道就是在通道中有一块缓冲区,发送和接收都可以针对缓冲区进行操作。也称为异步发送和接收。在有缓冲通道的状态下,对于发送操作来说,有缓冲通道的状态分为缓冲区满和未满两种状态。根据上面的发送流程图来说,当缓冲区满了,自然就不能再发送了,就会进入等待发送队列。同时阻塞,等待被接收协程唤醒。

  对于接收操作来说,有缓冲通道的状态分为缓冲区空和未满两种状态。同样,如果当缓冲区空时,无数据可接收,自然就进入到接收等待队列。同时进入阻塞,等待被发送协程唤醒。

  已关闭状态的通道

  关闭通道是通过**close**函数进行的。本质上关闭一个通道,就是将通道结构中的closed字段置为 1。从源代码中可以获知:

  关闭nil通道:panic

  关闭已经关闭了的通道:panic。这一点可以这样理解,关闭一个已经关闭的通道是没有任何意义的。

  发送消息到已关闭的通道

  给已经关闭了的通道发送消息会引发panic。这个很好理解,因为通道已经关闭,就是为了不让发消息了。如下代码:

  从已关闭的通道接收消息

  从已关闭的通道中接收消息时,都能操作成功。但会根据通道中是否有元素有以下不同:

  如果通道中已经没有元素了,则会返回一个false的状态。

  如果通道中有元素,则会继续接收通道中的元素,直到接收完,并返回false。

  你看,其实代码也很简单。我们将代码拆解一下,就是右侧的流程图。

  nil通道

  通过以下方式定义的通道类型的变量,其默认值就是nil。

  nil通道相当于没有分配通道的底层结构

  如下是从源代码中截取的各个操作以及对应操作结果。通过源代码可获知:

  关闭nil通道会panic

  从nil通道接收、发送消都会阻塞

  总结

  golang中的通道就是用来在协程间进行通信的。我们从源码级别推导了针对通道的各个状态下的操作所产生的结果。最后总结一下:缓冲区通道:

  只要有缓冲空间就能发送成功。除非缓冲空间满了,则产生阻塞。

  只要缓冲空间中有元素就能接收成功。除非没有元素,则产生阻塞。

  nil通道:

  nil通道是没有初始化底层数据结构的通道。因为没有空间可存储任何元素,所以发送和接收都会产生阻塞。关闭nil通道,则会引发panic。

  已关闭的通道:

  往已关闭的通道中发送消息,会引发panic。

  从已关闭通道中接收消息,会成功。

  关闭已关闭的通道,也会引发panic。

相关资讯