跟随一组图片,了解 Go Channel 的底层实现

一、概述
goroutines:独立执行任务,可能并行执行。
channels:用于通信,在 goroutine 之间进行同步。
Channel 特性
goroutine-safe:在多个 goroutine 之间可以安全的使用。
store and pass values between goroutines:在多个 goroutine 之间用来存储和传递数据。
provide FIFO semantics:FIFO 的结构。
can cause goroutines to block and unblock:可以阻塞 goroutine,唤醒 goroutine。
二、Channel 使用演示
任务分发:
任务处理:
三、making channels(the hchan struct)
3.1-缓冲 channel 与无缓冲 channel
buffer channel:缓冲 channel,带有容量。
unbuffered channel:无缓冲 channel,不带有容量。
3.2-hchan 结构
channel 在底层实现使用 hchan 结构体实现。其包含很多成员,在下面文章中会不断介绍。
例如,下面定义一个 buffer size 为 3 的 channel,其内部结构如下:
buf 成员:buf 指向环形队列,队列中存放着数据元素。
sendx 成员:sendex 表示下一次要写入数据的索引位置。
recvx 成员:recvx 表示下一次读取数据的索引位置。
lock 成员:lock 互斥锁。

注:hchan 不只包含这 4 个成员,其他成员会在下面继续介绍。
3.3-channel 数据更新演示
例如下面向 channel 写入一个数据,如下所示:
circular queue:队列中添加了一个元素。
send index:下次写入数据时,写入的索引位置为 1。
receive index:下次读取数据时,读取的索引为 0。

假设现在存放满 3 个元素,如下所示:其中因为元素存满了,并且 buf 是一个环形队列,因此 sendx 又变为 0,准备从头开始写入。
circular queue:队列中包含 3 个元素,已到达容量上限。
send index:下次写入数据时,写入的索引位置为 0。
receive index:下次读取数据时,读取的索引为 0。

假设现在读取一个元素,如下所示:
circular queue:由于是 FIFO 的结构,因此头部元素出队。
send index:下次写入数据时,写入的索引位置为 0。
receive index:下次读取数据时,读取的索引为 1。

3.4-channel 是分配在堆上的
程序的内存结构如下所示:channel 是分配在堆上的变量。

channel 是在堆上进行分配的,并且返回一个指针,因此 channel 是可以在多个 goroutine 之间进行安全访问的,并且不需要再传递其指针类型。

四、sends and receives(goroutine scheduling)
下面会涉及到一些关于 Go runtime 中关于 Goroutine 与 GMP 相关的知识点,可以参阅:https://xie.infoq.cn/article/2759c287c1ddc0a6d213e551d
4.1-演示案例
还是以文章最开始的例子:
G1:发送数据。
G2:接收数据。

4.1.1-写入数据
分为下面 3 个步骤:
1.acquire:请求操作 hchan,会对 lock 进行加锁。
2.enqueue:将元素入队,存入 buf。
3.release:操作完之后释放 lock 锁。

4.1.2-读取数据
分为下面 3 个步骤:
1.acquire:请求操作 hchan,会对 lock 进行加锁。
2.enqueue:将元素从 buf 中出队。
3.release:操作完之后释放 lock 锁。

4.2-communicate by channel
在并发编程中,线程/进程之间的通信都通过共享内存来进行,也就是常说的 communicate by sharing memory,这种模式的缺陷在于需要使用加锁来避免 race condition,这将会增加编程难度以及降低程序性能。
在 Go 中,提倡 communicate by channel,也就是说使用 channel 在多个 goroutine 中进行通信,而不是使用共享内存。
使用 channel 将使得对共享资源(memory)的访问串行化,避免了 race condition,自然也就不需要考虑加锁了。

4.3-场景分析:向容量已满的 channel 中写入数据
假设此时有一个容量为 3 的 channel,其 buf 中已经写入了 3 个数据,当再次写入数据时,写入操作将阻塞,写入操作所处的 goroutine 将阻塞。

4.3.1-小插曲:N:N 调度模型
goroutine 是用户态的线程。由 Go 运行时而不是操作系统创建和管理。与操作系统线程相比更加轻量级。
运行时调度器将 goroutine 调度到操作系统线程上。
Go 的 runtime 会将 M 个 goroutine 托管在 N 个 OS thread 上运行,称之为 M:N 调度模型。如下图所示:

4.3.2-goroutine 阻塞时 GMP 模型的变化
假设当前向 channel 中写入的 goroutine 开始运行,GMP 模型的结构如下所示:
M:系统线程,用来运行 G。
G:正在运行中的 goroutine。
P:逻辑处理器,在自己的 local queue 中存储着需要运行的 goroutine。

假设上述 G1(黄色)的操作是向 channel 中写入数据,由于此时 channel 已经满了,所以 GMP 模型的变化有:
G1 陷入 gopark 状态,与原先的 M 解绑。
P 从自己的 local queue 中挑选出一个 G(下图中黄色的 G)来进行运行。

从 GMP 模型中我们可以看到,在整个操作过程中,只有 G 的状态在发生变化(运行态/阻塞态/就绪态),而系统线程 M 的状态一直没有变化。
4.3.3-sudog 结构
对于阻塞发送(上面介绍的例子)或者阻塞接收(下面将要介绍)的操作,其将会在 hchan 中创建对应的 sudug 结构:
sendq:存储等待发送数据到 channel 中的 goroutine 和元素。
recvq:存储需要从 channel 中读取数据的 goroutine(下面会介绍)。

我们上面介绍的 G1 向 channel 中写入数据发送阻塞时,其就是在 sendq 中插入了一个 sudog 结构,其中有些字段的含义为:
G:写入操作对应的 goroutine。
elem:写入的元素。

4.3.4-resuming goroutines
上述的 G1 发生了阻塞,那么其何时被唤醒呢?答案为:当有其他 goroutine 从 channel 中读取数据之后,G1 会被唤醒。
接着上面的例子,假设现在有一个 G2 从 channel 中读取了一个元素。大概会发生 4 个事件:
首先,G2 从 channel 中读取数据。
buf dequeue 出队元素,供 G2 读取。
将原先存储在 sendq 字段中的 G1 解绑。
然后将 G1 中原先写入 channel 中的元素 tash4 入队存储到 buf 中。

上述 G2 读取元素,然后唤醒 G1 写入数据的这一系列操作对应的 GMP 模型如下。
首先,G2 读取元素之后,将 G1 的状态变为 goready。

然后 G1 从原先的 waiting 状态变为 runnable 状态,然后重新放入 P 的 local queue 中等待调度。

备注:goroutine 还提供了的亲缘性调度,上述 G1 被放到了 local queue 中等待调度,如果是使用亲缘性调度,G1 应该被放到 P 所指的 runext 字段中,当 G1 被唤醒后,将优先被调度。
4.4-场景分析::从空 channel 中读取数据
假设现在 channel 中的 buf 是空的,此时 G2 从 channel 中读取数据,那么此时其对应的场景与上述类似:
首先,G2 会 gopark 进入阻塞(waiting)状态,与 M 解绑。
然后在 recvq 上面创建一个 sudug 结构,G 为 G2,elem 为当有数据时,要接受数据的变量。

数据写入
假设现在 G1 向 channel 中写入数据,根据我们上面介绍的知识点,你可能认为会发生如下的几件事情:
首先,G1 获取到 channel 的 lock,然后向 buf 中写入数据。
接着 G1 唤醒 G2,G2 从 waiting 变为 runnable 状态,将 sudug 从 recvq 中解绑,接着从 buf 中读取到数据。
......

对于上述这一系列操作,涉及到多次加锁/解锁,以及数据拷贝的操作,有没有什么优化的方法呢?
优化
在 runtime 层面,对于唤醒读取 channel 的 goroutine 有自己优化。
就以上面的例子为例,当 G1 写入数据唤醒 G2 时
G1 不将数据写入到 buf 中,然后再让 G2 去读取。
G1 直接将数据写入到 sudog 的 elem 中,唤醒 G2 即可。


对于上述操作,减少了加锁/解锁,以及与 buf 缓冲区之间拷贝数据的开销。
备注:当然,这一系列操作是在 runtime 层面完成的。
五、总结
通过上面一系列的例子可以看出,goroutine 的运行与暂停可以通过 channel 来进行操作,也就实现了 goroutine 之间的同步。
channel 是 goroutine 是安全的
通过 hchan 自身的 mutex 来保护。
通过 FIFO 的形式写入和读取元素。
hchan 使用环形队列保存元素。
channel 可以控制 goroutine 的暂停与恢复。
hchan 维护了 sudog 结构队列。
calls into the runtime scheduler(gopark,goready)
六、参考链接
本文介绍的内容参考于【Undeerstanding channels.pdf】。
PDF 获取:公粽号《董哥的黑板报》回复《8000》获取。

版权声明: 本文为 InfoQ 作者【董哥的黑板报】的原创文章。
原文链接:【http://xie.infoq.cn/article/d633c88db407c5c400552c377】。文章转载请联系作者。
评论 (1 条评论)