写点什么

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

  • 2022 年 10 月 01 日
    江苏
  • 本文字数:3300 字

    阅读完需:约 11 分钟

跟随一组图片,了解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 使用演示

任务分发:

func main() {   // Buffered channel.   ch := make(chan Task, 3)   // Run fixed number of workers.   for i := 0; i < numWorkers; i++ {   		go worker(ch)   }   // Send tasks to workers.   hellaTasks := getTasks()   for _, task := range hellaTasks {   		taskCh <- task   }   ...}
复制代码


任务处理:

func worker(ch) {  for {      // Receive task.      task := <-taskCh      process(task)  }}
复制代码


三、making channels(the hchan struct)

3.1-缓冲 channel 与无缓冲 channel

buffer channel:缓冲 channel,带有容量。

unbuffered channel:无缓冲 channel,不带有容量。

// buffer channelch := make(chan Task, 3)
// unbuffered channelch := make(chan int)
复制代码


3.2-hchan 结构

channel 在底层实现使用 hchan 结构体实现。其包含很多成员,在下面文章中会不断介绍。


例如,下面定义一个 buffer size 为 3 的 channel,其内部结构如下:

  • buf 成员:buf 指向环形队列,队列中存放着数据元素。

  • sendx 成员:sendex 表示下一次要写入数据的索引位置。

  • recvx 成员:recvx 表示下一次读取数据的索引位置。

  • lock 成员:lock 互斥锁。

ch := make(chanTask, 3) 
复制代码



注: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)来进行运行。

ch <- task4 // send on a full channel 
复制代码



从 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》获取。


发布于: 2022 年 10 月 01 日阅读数: 75
用户头像

写一些平时大家接触的到的文章 2021.07.29 加入

哔哩哔哩后端开发工程师。公粽号:董哥的黑板报

评论 (1 条评论)

发布
用户头像
欢迎添加微信,加入创作者社群!infoqwriter
8 小时前 · 北京
回复
没有更多了
跟随一组图片,了解Go Channel的底层实现_Go_董哥的黑板报_InfoQ写作社区