废话不多说,直奔主题。
channel 的整体结构图
简单说明:
buf
是有缓冲的 channel 所特有的结构,用来存储缓存数据。是个循环链表sendx
和recvx
用于记录buf
这个循环链表中的~ 发送或者接收的~ indexlock
是个互斥锁。recvq
和sendq
分别是接收 (<-channel) 或者发送 (channel <- xxx) 的 goroutine 抽象出来的结构体 (sudog) 的队列。是个双向链表
源码位于 /runtime/chan.go
中 (目前版本:1.11)。结构体为 hchan
。
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
下面我们来详细介绍 hchan
中各部分是如何使用的。
先从创建开始
我们首先创建一个 channel。
ch := make(chan int, 3)
创建 channel 实际上就是在内存中实例化了一个 hchan
的结构体,并返回一个 ch 指针,我们使用过程中 channel 在函数之间的传递都是用的这个指针,这就是为什么函数传递中无需使用 channel 的指针,而直接用 channel 就行了,因为 channel 本身就是一个指针。
channel 中发送 send(ch <- xxx) 和 recv(<- ch) 接收
先考虑一个问题,如果你想让 goroutine 以先进先出 (FIFO) 的方式进入一个结构体中,你会怎么操作?加锁!对的!channel 就是用了一个锁。hchan 本身包含一个互斥锁 mutex
channel 中队列是如何实现的
channel 中有个缓存 buf,是用来缓存数据的 (假如实例化了带缓存的 channel 的话) 队列。我们先来看看是如何实现 “队列” 的。还是刚才创建的那个 channel
ch := make(chan int, 3)
当使用 send(ch<-xx)
或者 recv(<-ch)
的时候,首先要锁住 hchan
这个结构体。
然后开始 send(ch<-xx)
数据。一
ch <- 1
二
ch <- 1
三
ch <- 1
这时候满了,队列塞不进去了 动态图表示为:
然后是取 recv(<-ch)
的过程,是个逆向的操作,也是需要加锁。
然后开始 recv(<-ch)
数据。一
<-ch
二
<-ch
三
<-ch
图为:
注意以上两幅图中 buf
和 recvx
以及 sendx
的变化, recvx
和 sendx
是根据循环链表 buf
的变动而改变的。至于为什么 channel 会使用循环链表作为缓存结构,我个人认为是在缓存列表在动态的 send
和 recv
过程中,定位当前 send
或者 recvx
的位置、选择 send
的和 recvx
的位置比较方便吧,只要顺着链表顺序一直旋转操作就好。
缓存中按链表顺序存放,取数据的时候按链表顺序读取,符合 FIFO 的原则。
send/recv 的细化操作
注意:缓存链表中以上每一步的操作,都是需要加锁操作的!
每一步的操作的细节可以细化为:
- 第一,加锁
- 第二,把数据从 goroutine 中 copy 到 “队列” 中(或者从队列中 copy 到 goroutine 中)。
- 第三,释放锁
每一步的操作总结为动态图为:(发送过程)
或者为:(接收过程)
所以不难看出,Go 中那句经典的话: Donotcommunicatebysharing memory;instead,share memorybycommunicating.
的具体实现就是利用 channel 把数据从一端 copy 到了另一端!还真是符合 channel
的英文含义:
当 channel 缓存满了之后会发生什么?这其中的原理是怎样的?
使用的时候,我们都知道,当 channel 缓存满了,或者没有缓存的时候,我们继续 send(ch <- xxx) 或者 recv(<- ch) 会阻塞当前 goroutine,但是,是如何实现的呢?
我们知道,Go 的 goroutine 是用户态的线程 ( user-space threads
),用户态的线程是需要自己去调度的,Go 有运行时的 scheduler 去帮我们完成调度这件事情。关于 Go 的调度模型 GMP 模型我在此不做赘述,如果不了解,可以看我另一篇文章 (Go 调度原理)
goroutine 的阻塞操作,实际上是调用 send(ch<-xx)
或者 recv(<-ch)
的时候主动触发的,具体请看以下内容:
//goroutine1 中,记做 G1
ch := make(chan int, 3)
ch <- 1
ch <- 1
ch <- 1
这个时候 G1 正在正常运行, 当再次进行 send 操作 (ch<-1) 的时候,会主动调用 Go 的调度器, 让 G1 等待,并从让出 M,让其他 G 去使用
同时 G1 也会被抽象成含有 G1 指针和 send 元素的 sudog
结构体保存到 hchan 的 sendq
中等待被唤醒。
那么,G1 什么时候被唤醒呢?这个时候 G2 隆重登场。
G2 执行了 recv 操作 p:=<-ch
,于是会发生以下的操作:
G2 从缓存队列中取出数据,channel 会将等待队列中的 G1 推出,将 G1 当时 send 的数据推到缓存中,然后调用 Go 的 scheduler,唤醒 G1,并把 G1 放到可运行的 Goroutine 队列中。
假如是先进行执行 recv 操作的 G2 会怎么样?
你可能会顺着以上的思路反推。首先:
这个时候 G2 会主动调用 Go 的调度器, 让 G2 等待,并从让出 M,让其他 G 去使用。G2 还会被抽象成含有 G2 指针和 recv 空元素的 sudog
结构体保存到 hchan 的 recvq
中等待被唤醒
此时恰好有个 goroutine G1 开始向 channel 中推送数据 ch<-1
。此时,非常有意思的事情发生了:
G1 并没有锁住 channel,然后将数据放到缓存中,而是直接把数据从 G1 直接 copy 到了 G2 的栈中。这种方式非常的赞!在唤醒过程中,G2 无需再获得 channel 的锁,然后从缓存中取数据。减少了内存的 copy,提高了效率。
之后的事情显而易见:
更多精彩内容,请关注我的微信公众号 互联网技术窝
或者加微信共同探讨交流:
参考文献:
- https://www.youtube.com/watch?v=KBZlN0izeiY
- https://zhuanlan.zhihu.com/p/27917262
https://mp.weixin.qq.com/s?__biz=MzUzMjk0ODI0OA==&mid=2247483766&idx=1&sn=eb605a64bed0b2066a12083f26fb04b6&chksm=faaa3501cdddbc177121ba14a6604743d5ea881ca8299d5609ac8eb9b6eca4f2a142ad5aabfd&token=1213124593&lang=zh_CN#rd