Go语言进阶(十二)channel原理

Channel 的本质

Go 语言中最常见的、也是经常被人提及的设计模式就是:不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。

channel-and-goroutines

上图中的两个 Goroutine,一个会向 Channel 中发送数据,另一个会从 Channel 中接收数据,它们两者能够独立运行并不存在直接关联,但是能通过 Channel 间接完成通信。

数据结构(hchan)

make chan

unbuffered channel

buffered channel

channel创建后,会被分配到堆上heap

image-20211217142922743

发送数据

buffered channel为例:

数据会被存入hchan.bufhchan.buf其实时一个环形队列。

image-20211217143234016

  • hchan.sendx表示发送索引,0表示插入环形队列的第0位。插入第0位后,hchan.sendx会变成1,依次递增,当填满后,会从头开始。

image-20211217143530045

image-20211217143607507

简单总结一下,发送过程包含三个步骤:

  1. 持有互斥锁hchan.lock
  2. 入队,拷贝要发送的数据
    1. 找到是否有正在阻塞的接收方,是则直接发送
    2. 找到是否有空余的缓存,是则存入
    3. 阻塞直到被唤醒
  3. 释放锁

获取数据

与发送数据相似,channel使用hchan.recvx来进行控制获取哪个数据。当hchan.recvx0时,表示获取第0位中的数据,依次递增循环。

获取数据流程:

  1. 上锁
  2. 从缓存中出队,拷贝要接收的数据
    1. 如果 Channel 已被关闭,且 Channel 没有数据,立刻返回
    2. 如果存在正在阻塞的发送方,说明缓存已满,从缓存队头取一个数据,再复始一个阻塞的发送方
    3. 否则,检查缓存,如果缓存中仍有数据,则从缓存中读取,读取过程会将队列中的数据拷贝一份到接收方的执行栈中
    4. 没有能接受的数据,阻塞当前的接收方 Goroutine
  3. 解锁

FIFO(先进先出)

目前的 Channel 收发操作均遵循了先进先出的设计,具体规则如下:

  • 先从 Channel 读取数据的 Goroutine 会先接收到数据;
  • 先向 Channel 发送数据的 Goroutine 会得到先发送数据的权利;

以上面栗子为例,当channel被填满后,继续往channel发送数据,会如何处理?

发送方发现hchan.buf已满,会将自己存入hchan.sendq中。当接收方从缓冲区hchan.buf中读取数据后,会从hchan.sendq唤醒发送方,发送方会尝试向缓冲区写入数据,如果缓冲区已满会重新陷入休眠;

channel没有数据时,从channel获取数据时,会如何处理?

接收方hchan.buf中读取不到数据,会将自己存入hchan.recvq, 当有发送方向缓冲区hchan.buf中写入数据后,从hchan.recvq唤醒接收方,多个接收方会尝试从缓冲区中读取数据,如果没有读取到会重新陷入休眠

img

无锁管道

锁是一种常见的并发控制技术,我们一般会将锁分成乐观锁和悲观锁,即乐观并发控制和悲观并发控制,无锁(lock-free)队列更准确的描述是使用乐观并发控制的队列。乐观并发控制也叫乐观锁,很多人都会误以为乐观锁是与悲观锁差不多,然而它并不是真正的锁,只是一种并发控制的思想。

乐观并发控制本质上是基于验证的协议,我们使用原子指令 CAScompare-and-swap 或者 compare-and-set)在多线程中同步数据,无锁队列的实现也依赖这一原子指令。

Channel 在运行时的内部表示是 runtime.hchan,该结构体中包含了用于保护成员变量的互斥锁,从某种程度上说,Channel 是一个用于同步和通信的有锁队列,使用互斥锁解决程序中可能存在的线程竞争问题是很常见的,我们能很容易地实现有锁队列。

然而锁导致的休眠和唤醒会带来额外的上下文切换,如果临界区6过大,加锁解锁导致的额外开销就会成为性能瓶颈。1994 年的论文 Implementing lock-free queues 就研究了如何使用无锁的数据结构实现先进先出队列,而 Go 语言社区也在 2014 年提出了无锁 Channel 的实现方案,该方案将 Channel 分成了以下三种类型:

  • 同步 Channel — 不需要缓冲区,发送方会直接将数据交给(Handoff)接收方;
  • 异步 Channel — 基于环形缓存的传统生产者消费者模型;
  • chan struct{} 类型的异步 Channelstruct{} 类型不占用内存空间,不需要实现缓冲区和直接发送(Handoff)的语义;

这个提案的目的也不是实现完全无锁的队列,只是在一些关键路径上通过无锁提升 Channel 的性能。

channel的基本操作和注意事项

channel存在3种状态:

  • nil,未初始化的状态,只进行了声明,或者手动赋值为nil
  • active,正常的channel,可读或者可写
  • closed,已关闭,千万不要误认为关闭channel后,channel的值是nil

channel可进行3种操作:

  • 关闭

把这3种操作和3种channel状态可以组合出9种情况:

操作 nil的channel 正常channel 已关闭channel
阻塞 成功或阻塞 读到零值
阻塞 成功或阻塞 panic
关闭 panic 成功 panic

Select 语句的本质

Go 语言中的 select 能够让 Goroutine 同时等待多个 Channel 可读或者可写,在多个文件或者 Channel状态改变之前,select 会一直阻塞当前线程或者 Goroutine

Golang-Select-Channels

select 是与 switch 相似的控制结构,与 switch 不同的是,select 中虽然也有多个 case,但是这些 case 中的表达式必须都是 Channel 的收发操作。下面的代码就展示了一个包含 Channel 收发操作的 select 结构:

现象

当我们在 Go 语言中使用 select 控制结构时,会遇到两个有趣的现象:

  1. select 能在 Channel 上进行非阻塞的收发操作;
  2. select 在遇到多个 Channel 同时响应时,会随机执行一种情况;

这两个现象是学习 select 时经常会遇到的,我们来深入了解具体场景并分析这两个现象背后的设计原理。

非阻塞的收发

在通常情况下,select 语句会阻塞当前 Goroutine 并等待多个 Channel 中的一个达到可以收发的状态。但是如果 select 控制结构中包含 default 语句,那么这个 select 语句在执行时会遇到以下两种情况:

  • 当存在可以收发的 Channel 时,直接处理该 Channel 对应的 case
  • 当不存在可以收发的 Channel 时,执行 default 中的语句;

当我们运行下面的代码时就不会阻塞当前的 Goroutine,它会直接执行 default 中的代码:

非阻塞的 Channel 发送和接收操作还是很有必要的,在很多场景下我们不希望 Channel 操作阻塞当前 Goroutine,只是想看看 Channel 的可读或者可写状态,如下所示:

在上面这段代码中,我们不关心到底多少个任务执行失败了,只关心是否存在返回错误的任务,最后的 select 语句能很好地完成这个任务。

然而使用 select 实现非阻塞收发不是最初的设计,Go 语言在最初版本使用 x, ok := <-c 实现非阻塞的收发。

随机执行

另一个使用 select 遇到的情况是同时有多个 case 就绪时,select 会选择哪个 case 执行的问题,我们通过下面的代码可以简单了解一下:

从上述代码输出的结果中我们可以看到,select 在遇到多个 <-ch 同时满足可读或者可写条件时会随机选择一个 case 执行其中的代码。

这个设计是在十多年前被 select 提交5引入并一直保留到现在的,虽然中间经历过一些修改,但是语义一直都没有改变。在上面的代码中,两个 case 都是同时满足执行条件的,如果我们按照顺序依次判断,那么后面的条件永远都会得不到执行,而随机的引入就是为了避免饥饿问题的发生

数据结构

select 在 Go 语言的源代码中不存在对应的结构体,但是我们使用 runtime.scase 结构体表示 select 控制结构中的 case:

因为非默认的 case 中都与 Channel 的发送和接收有关,所以 runtime.scase 结构体中也包含一个 runtime.hchan 类型的字段存储 case 中使用的 Channel。

实现原理

select 语句在编译期间会被转换成 OSELECT 节点。每个 OSELECT 节点都会持有一组 OCASE 节点,如果 OCASE 的执行条件是空,那就意味着这是一个 default 节点。

golang-oselect-and-ocases

上图展示的就是 select 语句在编译期间的结构,每一个 OCASE 既包含执行条件也包含满足条件后执行的代码。

编译器在中间代码生成期间会根据 selectcase 的不同对控制语句进行优化,这一过程都发生在 cmd/compile/internal/gc.walkselectcases 函数中,我们在这里会分四种情况介绍处理的过程和结果:

  1. select 不存在任何的 case
  2. select 只存在一个 case
  3. select 存在两个 case,其中一个 casedefault
  4. select 存在多个 case

上述四种情况不仅会涉及编译器的重写和优化,还会涉及 Go 语言的运行时机制,我们会从编译期间和运行时两个角度分析上述情况。

直接阻塞

首先介绍的是最简单的情况,也就是当 select 结构中不包含任何 case。我们截取 cmd/compile/internal/gc.walkselectcases 函数的前几行代码:

这段代码很简单并且容易理解,它直接将类似 select {} 的语句转换成调用 runtime.block 函数:

runtime.block 的实现非常简单,它会调用 runtime.gopark 让出当前 Goroutine 对处理器的使用权并传入等待原因 waitReasonSelectNoCases

简单总结一下,空的 select 语句会直接阻塞当前 Goroutine,导致 Goroutine 进入无法被唤醒的永久休眠状态。

单一管道

如果当前的 select 条件只包含一个 case,那么编译器会将 select 改写成 if 条件语句。下面对比了改写前后的代码:

cmd/compile/internal/gc.walkselectcases 在处理单操作 select 语句时,会根据 Channel 的收发情况生成不同的语句。当 case 中的 Channel 是空指针时,会直接挂起当前 Goroutine 并陷入永久休眠。

非阻塞操作

select 中仅包含两个 case,并且其中一个是 default 时,Go 语言的编译器就会认为这是一次非阻塞的收发操作。cmd/compile/internal/gc.walkselectcases 会对这种情况单独处理。不过在正式优化之前,该函数会将 case 中的所有 Channel 都转换成指向 Channel 的地址,我们会分别介绍非阻塞发送和非阻塞接收时,编译器进行的不同优化。

发送

首先是 Channel 的发送过程,当 case 中表达式的类型是 OSEND 时,编译器会使用条件语句和 runtime.selectnbsend 函数改写代码:

这段代码中最重要的就是 runtime.selectnbsend,它为我们提供了向 Channel 非阻塞地发送数据的能力。我们在 Channel 一节介绍了向 Channel 发送数据的 runtime.chansend 函数包含一个 block 参数,该参数会决定这一次的发送是不是阻塞的:

由于我们向 runtime.chansend 函数传入了非阻塞,所以在不存在接收方或者缓冲区空间不足时,当前 Goroutine 都不会阻塞而是会直接返回。

接收

由于从 Channel 中接收数据可能会返回一个或者两个值,所以接收数据的情况会比发送稍显复杂,不过改写的套路是差不多的:

返回值数量不同会导致使用函数的不同,两个用于非阻塞接收消息的函数 runtime.selectnbrecvruntime.selectnbrecv2 只是对 runtime.chanrecv 返回值的处理稍有不同:

因为接收方不需要,所以 runtime.selectnbrecv 会直接忽略返回的布尔值,而 runtime.selectnbrecv2 会将布尔值回传给调用方。与 runtime.chansend 一样,runtime.chanrecv 也提供了一个 block 参数用于控制这次接收是否阻塞。

常见流程

在默认的情况下,编译器会使用如下的流程处理 select 语句:

  1. 将所有的 case 转换成包含 Channel 以及类型等信息的 runtime.scase 结构体;
  2. 调用运行时函数 runtime.selectgo 从多个准备就绪的 Channel 中选择一个可执行的 runtime.scase 结构体;
  3. 通过 for 循环生成一组 if 语句,在语句中判断自己是不是被选中的 case

一个包含三个 case 的正常 select 语句其实会被展开成如下所示的逻辑,我们可以看到其中处理的三个部分:

展开后的代码片段中最重要的就是用于选择待执行 case 的运行时函数 runtime.selectgo,这也是我们要关注的重点。因为这个函数的实现比较复杂, 所以这里分两部分分析它的执行过程:

  1. 执行一些必要的初始化操作并确定 case 的处理顺序;
  2. 在循环中根据 case 的类型做出不同的处理;

reference

  • https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-select/#52-select
  • https://golang.design/under-the-hood/zh-cn/part1basic/ch03lang/chan/#heading