上一章中对于golang的常用关键字说明如下:

接下来我们来对golang的并发编程进行说明,主要内容有:

  • 4 Channel
  • 5 调度器
  • 6 网络轮询器
  • 7 系统监控

— — — — — — — — — — — — — — — — — — — — — — — — — — — —

rangeselect

作为 Go 核心的数据结构和 Goroutine 之间的通信方式,Channel 是支撑 Go 语言高性能并发编程模型的重要结构,我们首先需要了解 Channel 背后的设计原理以及它的底层数据结构。

4.1 设计原理

Go 语言中最常见的、也是经常被人提及的设计模式就是 — 不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。在很多主流的编程语言中,多个线程传递数据的方式一般都是共享内存,为了解决线程冲突的问题,我们需要限制同一时间能够读写这些变量的线程数量,这与 Go 语言鼓励的方式并不相同。


图 - 多线程使用共享内存传递数据

虽然我们在 Go 语言中也能使用共享内存加互斥锁进行通信,但是 Go 语言提供了一种不同的并发模型,也就是通信顺序进程(Communicating sequential processes,CSP)1。Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Go 语言中的 Goroutine 会通过 Channel 传递数据。


图 - Goroutine 使用 Channel 传递数据

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

先入先出

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

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

这种 FIFO 的设计是相对好理解的,但是 Go 语言稍早版本的实现却不是严格遵循这一语义的,runtime: make sure blocked channels run operations in FIFO order 中提出了有缓冲区的 Channel 在执行收发操作时没有遵循 FIFO 的规则2。

  • 发送方会向缓冲区中写入数据,然后唤醒接收方,多个接收方会尝试从缓冲区中读取数据,如果没有读取到就会重新陷入休眠;
  • 接收方会从缓冲区中读取数据,然后唤醒发送方,发送方会尝试向缓冲区写入数据,如果缓冲区已满就会重新陷入休眠;

这种基于重试的机制会导致 Channel 的处理不会遵循 FIFO 的原则。经过 runtime: simplify buffered channels 和 runtime: simplify chan ops, take 2 两个提交的修改,带缓冲区和不带缓冲区的 Channel 都会遵循先入先出对数据进行接收和发送3 4。

无锁管道

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


图 - 悲观并发控制与乐观并发控制

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

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

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

chan struct{}struct{}

这个提案的目的也不是实现完全无锁的队列,只是在一些关键路径上通过无锁提升 Channel 的性能。社区中已经有无锁 Channel 的实现9,但是在实际的基准测试中,无锁队列在多核测试中的表现还需要进一步的改进10。

因为目前通过 CAS 实现11的无锁 Channel 没有提供 FIFO 的特性,所以该提案暂时也被搁浅了12。

4.2 数据结构

Go 语言的 Channel 在运行时使用 结构体表示。我们在 Go 语言中创建新的 Channel 时,实际上创建的都是如下所示的结构体:

qcountdataqsizbufsendxrecv
qcountdataqsizbufsendxrecvx
elemsizeelemtypesendqrecvq

表示一个在等待列表中的 Goroutine,该结构体中存储了阻塞的相关信息以及两个分别指向前后 的指针。

4.3 创建管道

makemake(chan int, 10)OMAKEOMAKEOMAKECHAN
makemake
OMAKECHAN

和 会根据传入的参数类型和缓冲区大小创建一个新的 Channel 结构,其中后者用于处理缓冲区大小大于 2 的 32 次方的情况,我们重点关注 函数:

上述代码根据 Channel 中收发元素的类型和缓冲区的大小初始化 结构体和缓冲区:

  • 如果当前 Channel 中不存在缓冲区,那么就只会为 分配一段内存空间;
  • 如果当前 Channel 中存储的类型不是指针类型,就会为当前的 Channel 和底层的数组分配一块连续的内存空间;
  • 在默认情况下会单独为 和缓冲区分配内存;
elemsizeelemtypedataqsiz

4.4 发送数据

ch <- iOSEND
blocktrue
"send on closed channel"

因为 函数的实现比较复杂,所以我们这里将该函数的执行过程分成以下的三个部分:

  • 当存在等待的接收者时,通过 直接将数据发送给阻塞的接收者;
  • 当缓冲区存在空余空间时,将发送的数据写入 Channel 的缓冲区;
  • 当不存在缓冲区或者缓冲区已满时,等待其他 Goroutine 从 Channel 接收数据;

直接发送

recvq

下图展示了 Channel 中存在等待数据的 Goroutine 时,向 Channel 发送数据的过程:


图 - 直接发送数据的过程

发送数据时会调用 ,该函数的执行可以分成两个部分:

runnext

缓冲区

如果创建的 Channel 包含缓冲区并且 Channel 中的数据没有装满,就会执行下面这段代码:

chanbufsendxqcount


图 - 向缓冲区写入数据
sendxsendxbufsendxdataqsiz

阻塞发送

select
true

小结

ch <- i
recvqsendxsendq

发送数据的过程中包含几个会触发 Goroutine 调度的时机:

runnextsendq

4.5 接收数据

我们接下来继续介绍 Channel 操作的另一方 — 数据的接收。Go 语言中可以使用两种不同的方式去接收 Channel 中的数据:

ORECVOAS2RECV


图 - Channel 接收操作的路线图

虽然不同的接收方式会被转换成 和 两种不同函数的调用,但是这两个函数最终还是会调用 。

当我们从一个空 Channel 接收数据时会直接调用 直接让出处理器的使用权。

ep

除了上述两种特殊情况,使用 从 Channel 接收数据时还包含以下三种不同情况:

  • 当存在等待的发送者时,通过 直接从阻塞的发送者或者缓冲区中获取数据;
  • 当缓冲区存在数据时,从 Channel 的缓冲区中接收数据;
  • 当缓冲区中不存在数据时,等待其他 Goroutine 向 Channel 发送数据;

直接接收

sendq

函数的实现比较复杂:

该函数会根据缓冲区的大小分别处理不同的情况:

  • 如果 Channel 不存在缓冲区;


  • 如果 Channel 存在缓冲区;
  1. 将队列中的数据拷贝到接收方的内存地址;
  2. 将发送队列头的数据拷贝到缓冲区中,释放一个阻塞的发送方;


runnext


图 - 从发送队列中获取数据
<-chrecvx

缓冲区

recvx

如果接收数据的内存地址不为空,那么就会直接使用 将缓冲区中的数据拷贝到内存中、清除队列中的数据并完成收尾工作。


图 - 从缓冲区中接接收数据
recvxqcount

阻塞接收

select

在正常的接收场景中,我们会使用 结构体将当前 Goroutine 包装成一个处于等待状态的 Goroutine 并将其加入到接收队列中。

完成入队之后,上述代码还会调用 函数立刻触发 Goroutine 的调度,让出处理器的使用权并等待调度器的调度。

小结

我们梳理一下从 Channel 中接收数据时可能会发生的五种情况:

sendqrecvxsendqrecvxrecvq

我们总结一下从 Channel 接收数据时,会触发 Goroutine 调度的两个时机:

  1. 当 Channel 为空时;
  2. 当缓冲区中不存在数据并且也不存在数据的发送者时;

4.6 关闭管道

closeOCLOSE
panic
recvqsendqgListsudog

该函数在最后会为所有被阻塞的 Goroutine 调用 触发调度。

4.7 小结

Channel 是 Go 语言能够提供强大并发能力的原因之一,我们在这一节中分析了 Channel 的设计原理、数据结构以及发送数据、接收数据和关闭 Channel 这些基本操作,相信能够帮助大家更好地理解 Channel 的工作原理。

全套教程点击下方链接直达: