大家好,我是渔夫子。
channel是golang中独有的特性,也是面试中经常被问到的。相信大家都看到过下面这张图,对于不同状态下通道,在操作时会有什么结果。
这张图总结的非常好。但我们不能死记硬背这些结果。要了解其底层的基本原理,就能理解这些结果是怎么来的。
我们分三部分来讲。先是channel的基础使用,基础使用提现了channel有哪些特性。再引出channel的底层数据结构。底层数据结构就是围绕这些特性而建立的。最后再看go是如何基于底层数据结构来实现这些特性的。
通过var定义一个通道变量ch,这个变量能够接收整型的数据。当然也可以指定其他任何数据类型。
var ch chan int
nil
。对于nil通道在操作时会有特殊的场景,一会我们也会讲解。通过make可以初始化无缓冲区通道和缓冲区通道。区别就在于make中是否指定了缓冲区的大小。如下:
var ch = make(chan int) //初始化无缓冲通道
var ch = make(chan int, 10) //缓冲区通道,缓冲区可以存10个元素
无缓冲通道和有缓冲通道的区别可以从属性上和行为两方面来体现:
golang中对于通道有三种操作:往通道中发送元素、从通道中接收元素、关闭通道。如下:往通道中发送元素:
var ch chan int = make(chan int, 10)
2 ->ch //发送元素
var item int
item <-ch //接收元素
close(ch) //关闭元素
总结一下:
那么,通道是基于怎样的数据结构来完成这些行为的呢?
我们先给出channel的底层数据结构,如下:
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
type waitq struct {
first *sudog
last *sudog
}
根据上面的结构定义,依次解释下各个字段的含义:
根据以上结果,绘制成图会容易理解点,如下:
从定义上,缓冲通道和非缓冲通道都是通过make来初始化的。不同点在于是否在make函数上指定了通道的容量大小。如下:
unbufferCh := make(chan int) //初始化非缓冲区通道
bufferCh := make(chan int, 10) //初始化一个能缓冲10个元素的通道
从通道的底层数据结构上来说,非缓冲渠道不会初始化结构体中的buf字段。而缓冲渠道则会初始化buf字段。该字段指向一块内存区域。如下图:
通过源码我们梳理出来了给通道发送数据和从通道中接收数据的流程图。这张流程图将缓冲通道和无缓冲通道两种状态下的发送和接收流程都包含了,所以看起来会比较复杂。但是没关系,下面我们会分解这张图。
通过上面的流程,大家需要注意的一点就是,无论是在发送还是接收操作时,都是优先从等待队列中获取对应的线程,如果有,则直接接收或发送;如果等待队列没有协程,然后再看是否有缓冲区。这一点需要大家额外注意。
根据上述无缓冲通道其实本质上就是没有缓冲区。在初始化时不指定make的容量即可。实际上这也叫做同步发送和接收。针对这种状态的通道,当发送数据时,如果接收队列中有等待的接收协程,那么就能发送成功;否则,进入阻塞状态。反之,亦然。其流程图就是图中的红色箭头部分,如下:
再简化一下就是:
那么,上面的图可以简化成如下:
另外需要额外注意一点,对于非缓冲区通道的发送和接收操作。如果是在main函数中进行发送和接收,那么会造成死锁。如下:
func main() {
var ch = make(chan int)
<-ch
fmt.Println("the End")
}
//或
func main() {
var ch = make(chan int)
ch <- 2
fmt.Println("the End")
}
image.png
所以,对于非缓冲区通道的发送和接收操作,最主要的问题就是可能会造成阻塞。除非,两个发送和接收协程都存在,而且要在不同的协程里。
有缓冲区通道就是在通道中有一块缓冲区,发送和接收都可以针对缓冲区进行操作。也称为异步发送和接收。在有缓冲通道的状态下,j对于发送操作来说,有缓冲通道的状态分为缓冲区满和未满两种状态。根据上面的发送流程图来说,当缓冲区满了,自然就不能再发送了,就会进入等待发送队列。同时阻塞,等待被接收协程唤醒。
对于接收操作来说,有缓冲通道的状态分为缓冲区空和未满两种状态。同样,如果当缓冲区空时,无数据可接收,自然就进入到接收等待队列。同时进入阻塞,等待被发送协程唤醒。
image.png
关闭通道是通过**close**函数
进行的。本质上关闭一个通道,就是将通道结构中的closed字段置为 1。从源代码中可以获知:
image.png
给已经关闭了的通道发送消息会引发panic。这个很好理解,因为通道已经关闭,就是为了不让发消息了。如下代码:
从已关闭的通道中接收消息时,都能操作成功。但会根据通道中是否有元素有以下不同:
你看,其实代码也很简单。我们将代码拆解一下,就是右侧的流程图。
通过以下方式定义的通道类型的变量,其默认值就是nil。
var ch chan int
nil通道相当于没有分配通道的底层结构
如下是从源代码中截取的各个操作以及对应操作结果。通过源代码可获知:
golang中的通道就是用来在协程间进行通信的。我们从源码级别推导了针对通道的各个状态下的操作所产生的结果。最后总结一下:缓冲区通道:
nil通道:
已关闭的通道:
特别说明:你的关注,是我写下去的最大动力。点击下方公众号卡片,直接关注。关注送《100个go常见的错误》pdf文档、经典go学习资料。
参考链接:
https://halfrost.com/go_channel/
https://codeburst.io/diving-deep-into-the-golang-channels-549fd4ed21a8
https://shubhagr.medium.com/internals-of-go-channels-cf5eb15858fc
https://levelup.gitconnected.com/how-does-golang-channel-works-6d66acd54753