购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.5 Channel类型

Channel是Go语言的特色结构,其读写操作都是并发安全的,广泛用于Goroutine间的消息传递和状态同步。Channel能够传输任意类型的数据,接下来将详细探讨Channel的使用方式及其背后的实现原理。

2.5.1 创建与初始化

我们可以创建两种类型的Channel:一种是带缓冲区的,另一种是无缓冲区的。无论哪种类型,创建Channel都需要使用make函数。创建Channel的示例如下:

当编译器识别到make函数创建Channel的语句时,它会转换成makechan函数调用,从而构建并初始化一个hchan结构体,并将该结构体的地址赋值给相应的指针变量。hchan是Channel的核心数据结构体,其定义如代码清单2-22所示。

代码清单2-22 hchan结构体的定义

从底层数据结构的角度来看,Channel的内部实现是一个环形数组(仅针对带缓冲区的Channel)。hchan结构体使用buf、sendx和recvx这三个字段来管理环形数组。[recvx,sendx)区域内的元素是Channel内当前有效的元素。

循环数组的元素遵循FIFO(先进先出)的原则。如果有需要等待的Goroutine,它们将会插入recvq和sendq这两个队列中。这两个队列也遵循FIFO的原则。这样设计的目的是保证数据的一致性和操作的公平性。图2-10直观地展示了Channel的内部结构。

图2-10 Channel的内部结构示意

接下来看一下makechan函数的实现,如代码清单2-23所示。

代码清单2-23 makechan函数的实现

makechan的核心逻辑在于内存的分配,如果是带缓冲区的Channel,那么会分配对应的环形数组的内存。如果是无缓冲区的Channel,则不会产生这部分内存消耗。

在使用Channel时,我们经常见到直接定义Channel变量的方式,如下所示:

这种方式实际上只是分配了一个8字节的指针变量,而hchan结构体本身并未分配和初始化。因此如果直接使用上述方式定义的Channel变量,将会触发空指针导致的panic错误。

2.5.2 入队和出队

Channel的操作是配合“<-”符号来使用的,编译器会根据不同的语法转换成不同的函数调用。Channel的元素的出队和入队遵循的是FIFO的设计,先入队的元素将会先出队。这个原则的另外一层含义是,先从Channel读取的Goroutine将会先接收到数据,先向Channel发送数据的Goroutine将会先发送成功。接下来将详细解析元素入队和出队的过程。

1.元素入队

当“<-”符号在Channel变量右侧时,代表元素的入队操作。编译器识别到这一操作后,会将其转换成对chansend1函数的调用。入队操作如下所示:

chansend1函数实际上是对chansend函数的封装,chansend函数在多个场景中都有应用,例如select和Channel结合的场景(对应调用selectnbsend函数)。这样设计的目的是复用代码逻辑。chansend1函数的实现如代码清单2-24所示。

代码清单2-24 chansend1函数的实现

接下来,我们深入了解chansend函数的核心实现,如代码清单2-25所示。

代码清单2-25 chansend函数的核心实现

chansend函数的核心逻辑是对环形数组的处理,当带缓冲区的Channel的环形数组满了或者是不带缓冲区的Channel时,可能会走到阻塞的逻辑。当前Goroutine把自身添加到hchan.sendq的链表上,然后主动挂起,并等待receiver的唤醒。Channel入队示意如图2-11所示。

图2-11 Channel入队示意

在chansend函数中,调用了send函数来处理处于阻塞状态的receiver。send函数的实现如代码清单2-26所示。

代码清单2-26 send函数的实现

send函数会先把入队的元素复制给等待的receiver,然后把相应的receiver唤醒。这里阻塞的recevier出自两种情况:一种是因当前是一个无缓冲区的Channel而阻塞;另一种是receiver来Channel读数据时,环形数组为空而阻塞。阻塞的receiver会被入队操作时的send函数调用而唤醒。

而元素入队操作一般有两种情况导致阻塞:一种是因当前是一个无缓冲区的Channel而阻塞;另一种是环形数组已满而阻塞。这两种情况导致阻塞的sender,将被某个出队操作(触发chanrecv函数调用,进一步调用recv函数)而唤醒。

2.元素出队

当“<-”符号在Channel变量左侧时,代表出队操作。编译器识别到这一操作后,会将其转换成对相应的chanrecv函数的调用。通常,出队操作有三种方式:

这三种方式各有其适用的场景。例如,如果只是把Channel用作同步工具,可能就不需要关心其返回值。如果不担心Channel被关闭带来的影响,那么就可以直接出队并赋值。但如果需要考虑Channel关闭的情况,并且需要明确区分Channel出队时是否已经关闭,那么就必须用带“ok”返回值的形式。否则无法区分收到的“零值”究竟是发送者发过来的实际值,还是因为Channel关闭后返回的默认值。

带“ok”和不带“ok”返回值的形式会被编译器转换成不同的函数调用,分别对应chanrecv1和chanrecv2函数。这两个函数的实现如代码清单2-27所示。

代码清单2-27 chanrecv1和chanrecv2函数的实现

这几种出队方式,默认都是阻塞操作。也就是说,当Channel内无数据时(且未关闭),操作会阻塞导致Goroutine挂起,直到有数据可接收。chanrecv1和chanrecv2的核心过程的实现位于chanrecv函数中,如代码清单2-28所示。

代码清单2-28 chanrecv函数的实现

chanrecv的核心逻辑和chansend是相呼应的。如果Channel内没有数据,那么可能会触发阻塞逻辑。在这种情况下,当前Goroutine会将自己添加到hchan.recvq的等待链表中,然后主动放弃执行权限,进入等待状态,直至有sender将其唤醒。

图2-12直观展示了Channel出队的过程。

图2-12 Channel出队示意

chanrecv通过recv函数来唤醒hchan.sendq链表上的Goroutine。recv函数的逻辑很简单,主要处理带缓冲区和不带缓冲区的两种场景,并且维护FIFO的语义。recv函数的实现如代码清单2-29所示。

代码清单2-29 recv函数的实现

在这段代码中,recv函数的核心逻辑是接收数据,并且唤醒处于等待状态的sender。这一逻辑与send函数是相互对应的。recv函数负责接收数据并唤醒因入队操作而阻塞的Goroutine,而send函数则负责发送数据并唤醒因出队操作而阻塞的Goroutine。

2.5.3 select和Channel结合

在上述讨论中,我们谈到了Channel结合“<-”的操作默认执行的是阻塞流程。例如,当Channel中无可用元素时,执行出队操作的Goroutine会被阻塞,直到有新的元素入队才会被唤醒。

然而,在某些场合,我们可能并不希望发生阻塞。如果Channel中没有元素,我们可能更希望继续执行其他代码。为了实现Channel的非阻塞操作,我们可以将Channel与select关键字结合使用。下面分别针对Channel入队和出队场景进行讨论。

1.元素入队

我们可以将Channel的入队操作与select结合起来,把它放在select的case语句中,并指定一个default分支。这样,Channel的入队操作就是非阻塞的。即使Channel的队列已满(或者是无缓冲区的Channel),也不会阻塞Goroutine的执行。一旦元素入队成功,就会进入对应case的分支。select与Channel的入队操作结合如代码清单2-30所示。

代码清单2-30 select与Channel入队操作结合

当编译器遇到select和Channel结合的入队操作时,会将其转换成selectnbsend函数调用,因此上述代码示例实质上等同于代码清单2-31所示的伪代码。

代码清单2-31 select与Channel入队操作结合的伪代码

selectnbsend函数实际上是对chansend函数的封装,复用了与chansend1相同的底层代码实现,只是传入的block参数为false而已。selectnbsend函数的实现如代码清单2-32所示。

代码清单2-32 selectnbsend函数的实现

由于selectnbsend函数调用chansend,且传入的block参数是false,这样chansend函数内部就不会执行gopark挂起的流程。chansend函数关于非阻塞模式的实现如代码清单2-33所示。

代码清单2-33 chansend函数关于非阻塞模式的实现

非阻塞模式下,如果Channel空间已满且没有等待的receiver,那么就会直接返回false,这样就不会阻塞当前Goroutine。同时,通过返回值false,调用者能够立即得知入队操作是否成功。

2.元素出队

我们也可以将Channel的出队操作放在select的case分支中,使出队操作变为非阻塞的。这意味着即使Channel为空(或者是无缓冲区的Channel),也不会阻塞Goroutine的执行。如果出队成功,就会进入到对应的分支。select与Channel出队操作结合的代码如代码清单2-34所示。

代码清单2-34 select与Channel出队操作结合

当编译器识别到Channel出队操作和select结合使用时,会将其转换成对selectnbrecv的函数调用。上述代码相当于代码清单2-35所示的伪代码。

代码清单2-35 select与Channel出队操作结合的伪代码

selectnbrecv函数实质上是对chanrecv函数的封装,它和chanrecv1、chanrecv2复用相同的底层实现。selectnbrecv函数的区别在于传入的block参数为false。chanrecv函数的实现如代码清单2-36所示。

代码清单2-36 chanrecv函数的实现

在chanrecv函数中,如果是非阻塞模式,如果当前Channel为空时,函数会直接返回。chanrecv函数返回两个值:selected和received。selected用于判断select的分支,如果为true,则会进入select-case的相关分支下。当received为true时,则表示成功接收到了一个值。

因此,select本身并没有用到什么“黑魔法”,它只是在不同的场景中调用了不同的函数。Channel默认的入队和出队操作对应的是chansend1和chanrecv1等阻塞式的函数调用。当select与Channel结合使用时,编译器会对selectnbsend、selectnbrecv函数进行调用,从而实现Channel的非阻塞操作。

2.5.4 for-range和Channel结合

Channel可以被视作支持FIFO规则的元素集合,当然我们也经常会有遍历Channel中元素的需求。在Go语言中,我们可以通过for-range和Channel结合来实现这一点,其基本语法结构如下所示:

当解析到这种结构时,编译器会转换成对chanrecv2函数的调用。这与我们在2.4节中所学习的map的range遍历机制相似。接下来分析for-range和Channel结合之后的三个要素:初始化、条件递进和终止条件判断。

(1)初始化和条件递进

在Channel的遍历场景,初始化和递进部分如果没有特殊处理,可以认为是空的。关键在于判断终止条件,其伪代码如下所示:

(2)终止条件判断

我们使用chanrecv2的返回值作为判断循环终止的条件。在返回值为false的时候,表明Channel被关闭,此时会终止循环。

chanrecv2是对chanrecv函数的封装,它将chanrecv的返回值received作为自己的返回值。当返回值为false时,表示循环应终止。在阻塞模式下,chanrecv只有在一种情况下才会返回false,即Channel被关闭。chanrecv2函数的实现如代码清单2-37所示。

代码清单2-37 chanrecv2函数的实现

通过上述的代码分析,我们可以总结出for-range和Channel结合的场景有以下几个关键点:

❑ 循环结束的条件之一是Channel被关闭,如果Channel没有被关闭,循环将持续进行。

❑ 当Channel被关闭后,循环并不会立即终止,还必须等到hchan.qcount==0,即Channel中的所有元素都被取出,才会满足for循环的终止条件。

❑ chanrecv2调用chanrecv函数时,block参数是true。这意味着在循环执行过程中,Goroutine可能会被阻塞并让出执行权,进入等待状态。

这三个关键点为我们深入理解并有效使用for-range和Channel的结合提供了重要参考。 fr/dA5MUwWqD2QbDzRkIVJ7auHIVxMH39+S5+b1ts+VQmzLYodP1YISb8Hig1lbW

点击中间区域
呼出菜单
上一章
目录
下一章
×