废话不多说,直奔主题。

channel 的整体结构图

图解Go的channel底层原理 - 图1

简单说明:

  • buf是有缓冲的 channel 所特有的结构,用来存储缓存数据。是个循环链表
  • sendxrecvx用于记录 buf这个循环链表中的~ 发送或者接收的~ index
  • lock是个互斥锁。
  • recvqsendq分别是接收 (<-channel) 或者发送 (channel <- xxx) 的 goroutine 抽象出来的结构体 (sudog) 的队列。是个双向链表

源码位于 /runtime/chan.go中 (目前版本:1.11)。结构体为 hchan

  1. type hchan struct {

  2. qcount uint // total data in the queue

  3. dataqsiz uint // size of the circular queue

  4. buf unsafe.Pointer // points to an array of dataqsiz elements

  5. elemsize uint16

  6. closed uint32

  7. elemtype *_type // element type

  8. sendx uint // send index

  9. recvx uint // receive index

  10. recvq waitq // list of recv waiters

  11. sendq waitq // list of send waiters

  12. // lock protects all fields in hchan, as well as several

  13. // fields in sudogs blocked on this channel.

  14. //

  15. // Do not change another G's status while holding this lock

  16. // (in particular, do not ready a G), as this can deadlock

  17. // with stack shrinking.

  18. lock mutex

  19. }

下面我们来详细介绍 hchan中各部分是如何使用的。

先从创建开始

我们首先创建一个 channel。

  1. ch := make(chan int, 3)

图解Go的channel底层原理 - 图2

创建 channel 实际上就是在内存中实例化了一个 hchan的结构体,并返回一个 ch 指针,我们使用过程中 channel 在函数之间的传递都是用的这个指针,这就是为什么函数传递中无需使用 channel 的指针,而直接用 channel 就行了,因为 channel 本身就是一个指针。

channel 中发送 send(ch <- xxx) 和 recv(<- ch) 接收

先考虑一个问题,如果你想让 goroutine 以先进先出 (FIFO) 的方式进入一个结构体中,你会怎么操作?加锁!对的!channel 就是用了一个锁。hchan 本身包含一个互斥锁 mutex

channel 中队列是如何实现的

channel 中有个缓存 buf,是用来缓存数据的 (假如实例化了带缓存的 channel 的话) 队列。我们先来看看是如何实现 “队列” 的。还是刚才创建的那个 channel

  1. ch := make(chan int, 3)

图解Go的channel底层原理 - 图3

当使用 send(ch<-xx)或者 recv(<-ch)的时候,首先要锁住 hchan这个结构体。

图解Go的channel底层原理 - 图4

然后开始 send(ch<-xx)数据。一

  1. ch <- 1

  1. ch <- 1

  1. ch <- 1

这时候满了,队列塞不进去了 动态图表示为:图解Go的channel底层原理 - 图5

然后是取 recv(<-ch)的过程,是个逆向的操作,也是需要加锁。

图解Go的channel底层原理 - 图6

然后开始 recv(<-ch)数据。一

  1. <-ch

  1. <-ch

  1. <-ch

图为:图解Go的channel底层原理 - 图7

注意以上两幅图中 bufrecvx以及 sendx的变化, recvxsendx是根据循环链表 buf的变动而改变的。至于为什么 channel 会使用循环链表作为缓存结构,我个人认为是在缓存列表在动态的 sendrecv过程中,定位当前 send或者 recvx的位置、选择 send的和 recvx的位置比较方便吧,只要顺着链表顺序一直旋转操作就好。

缓存中按链表顺序存放,取数据的时候按链表顺序读取,符合 FIFO 的原则。

send/recv 的细化操作

注意:缓存链表中以上每一步的操作,都是需要加锁操作的!

每一步的操作的细节可以细化为:

  • 第一,加锁
  • 第二,把数据从 goroutine 中 copy 到 “队列” 中(或者从队列中 copy 到 goroutine 中)。
  • 第三,释放锁

每一步的操作总结为动态图为:(发送过程)图解Go的channel底层原理 - 图8

或者为:(接收过程)图解Go的channel底层原理 - 图9

所以不难看出,Go 中那句经典的话: Donotcommunicatebysharing memory;instead,share memorybycommunicating.的具体实现就是利用 channel 把数据从一端 copy 到了另一端!还真是符合 channel的英文含义:

图解Go的channel底层原理 - 图10

当 channel 缓存满了之后会发生什么?这其中的原理是怎样的?

使用的时候,我们都知道,当 channel 缓存满了,或者没有缓存的时候,我们继续 send(ch <- xxx) 或者 recv(<- ch) 会阻塞当前 goroutine,但是,是如何实现的呢?

我们知道,Go 的 goroutine 是用户态的线程 ( user-space threads),用户态的线程是需要自己去调度的,Go 有运行时的 scheduler 去帮我们完成调度这件事情。关于 Go 的调度模型 GMP 模型我在此不做赘述,如果不了解,可以看我另一篇文章 (Go 调度原理)

goroutine 的阻塞操作,实际上是调用 send(ch<-xx)或者 recv(<-ch)的时候主动触发的,具体请看以下内容:

  1. //goroutine1 中,记做 G1

  2. ch := make(chan int, 3)

  3. ch <- 1

  4. ch <- 1

  5. ch <- 1

图解Go的channel底层原理 - 图11

图解Go的channel底层原理 - 图12

这个时候 G1 正在正常运行, 当再次进行 send 操作 (ch<-1) 的时候,会主动调用 Go 的调度器, 让 G1 等待,并从让出 M,让其他 G 去使用

图解Go的channel底层原理 - 图13

同时 G1 也会被抽象成含有 G1 指针和 send 元素的 sudog结构体保存到 hchan 的 sendq中等待被唤醒。

图解Go的channel底层原理 - 图14

那么,G1 什么时候被唤醒呢?这个时候 G2 隆重登场。

图解Go的channel底层原理 - 图15

G2 执行了 recv 操作 p:=<-ch,于是会发生以下的操作:

图解Go的channel底层原理 - 图16

G2 从缓存队列中取出数据,channel 会将等待队列中的 G1 推出,将 G1 当时 send 的数据推到缓存中,然后调用 Go 的 scheduler,唤醒 G1,并把 G1 放到可运行的 Goroutine 队列中。

图解Go的channel底层原理 - 图17

假如是先进行执行 recv 操作的 G2 会怎么样?

你可能会顺着以上的思路反推。首先:

图解Go的channel底层原理 - 图18

这个时候 G2 会主动调用 Go 的调度器, 让 G2 等待,并从让出 M,让其他 G 去使用。G2 还会被抽象成含有 G2 指针和 recv 空元素的 sudog结构体保存到 hchan 的 recvq中等待被唤醒

图解Go的channel底层原理 - 图19

此时恰好有个 goroutine G1 开始向 channel 中推送数据 ch<-1。此时,非常有意思的事情发生了:

图解Go的channel底层原理 - 图20

G1 并没有锁住 channel,然后将数据放到缓存中,而是直接把数据从 G1 直接 copy 到了 G2 的栈中。这种方式非常的赞!在唤醒过程中,G2 无需再获得 channel 的锁,然后从缓存中取数据。减少了内存的 copy,提高了效率。

之后的事情显而易见:图解Go的channel底层原理 - 图21

更多精彩内容,请关注我的微信公众号 互联网技术窝 或者加微信共同探讨交流:

图解Go的channel底层原理 - 图22

参考文献: