在上一篇文章《深入理解 go chan》中,我们讲解了 chan
相关的一些概念、原理等东西,
今天让我们再深入一下,读一下它的源码,看看底层实际上是怎么实现的。
整体设计
我们可以从以下三个角度看 chan
的设计(源码位于 runtime/chan.go
,结构体 hchan
就是 chan
的底层数据结构):
- 存储:
chan
里面的数据是通过一个环形队列来存储的(实际上是一个数组,但是我们视作环形队列来操作。无缓冲chan
不用存储,会直接从sender
复制到receiver
) - 发送:数据发送到
chan
的时候,如果chan
满了,则会将发送数据的协程挂起,将其放入一个协程队列中,chan
空闲的时候会唤醒这个协程队列。如果chan
没满,则发送队列为空。 - 接收:从
chan
中接收数据的时候,如果chan
是空的,则会将接收数据的协程挂起,将其放入一个协程队列中,当chan
有数据的时候会唤醒这个协程队列。如果chan
有数据,则接收队列为空。
文中一些比较关键的名词解释:
sender
: 表示尝试写入chan
的goroutine
。receiver
: 表示尝试从chan
读取数据的goroutine
。sendq
是一个队列,存储那些尝试写入channel
但被阻塞的goroutine
。recvq
是一个队列,存储那些尝试读取channel
但被阻塞的goroutine
。g
表示一个协程。gopark
是将协程挂起的函数,协程状态:_Grunning
=>_Gwaiting
。goready
是将协程改为可运行状态的函数,协程状态:_Gwaiting
=>_Grunnable
。
现在,假设我们有下面这样的一段代码,通过这段代码,我们可以大概看一下 chan
的总体设计:
package main
func main() {
// 创建一个缓冲区大小为 9 的 chan
ch := make(chan int, 9)
// 往 chan 写入 [1,2,3,4,5,6,7]
for i := 0; i < 7; i++ {
ch <- i + 1
}
// 将 1 从缓冲区移出来
<-ch
}
现在,我们的 chan
大概长得像下面这个样子,后面会详细展开将这个图中的所有元素:
上图为了说明而在 recvq 和 sendq 都画了 3 个 G,但实际上 recvq 和 sendq 至少有一个为空。因为不可能有协程正在等待接收数据的时候,还有协程的数据因为发不出去数据而阻塞。
数据结构
在底层,go 是使用 hchan
这个结构体来表示 chan
的,下面是结构体的定义:
type hchan struct {
qcount uint // 缓冲区(环形队列)元素个数
dataqsiz uint // 缓冲区的大小(最多可容纳的元素个数)
buf unsafe.Pointer // 指向缓冲区入口的指针(从 buf 开始 qcount * elemsize 大小的内存就是缓冲区所用的内存)
elemsize uint16 // chan 对应类型元素的大小(主要用以计算第 i 个元素的内存地址)
closed uint32 // chan 是否已经关闭(0-未关闭,1-已关闭)
elemtype *_type // chan 的元素类型
sendx uint // chan 发送操作处理到的位置
recvx uint // chan 接收操作处理到的位置
recvq waitq // 等待接收数据的协程队列(双向链表)
sendq waitq // 等待发送数据的协程队列(双向链表)
// 锁
lock mutex
}
waitq
的数据结构如下:
type waitq struct {
first *sudog
last *sudog
}
waitq
用来保存阻塞在等待或接收数据的协程列表(是一个双向链表),在解除阻塞的时候,需要唤醒这两个队列中的数据。
对应上图各字段详细说明
hchan
,对于 hchan
这个结构体,我们知道,在 go 里面,结构体字段是存储在一段连续的内存上的(可以看看《深入理解 go unsafe》),所以图中用了连续的一段单元格表示。
下面是各字段说明:
qcount
: 写入chan
缓冲区元素个数。我们的代码往chan
中存入了7
个数,然后从中取出了一个数,最终还剩6
个,因此qcount
是6
。dataqsiz
:hchan
缓冲区的长度。它在内存中是连续的一段内存,是一个数组,是通过make
创建的时候传入的,是9
。buf
:hchan
缓冲区指针。指向了一个数组,这个数组就是用来保存发送到chan
的数据的。sendx
、recvx
:写、读操作的下标。指向了buf
指向的数组中的下标,sendx
是下一个发送操作保存的下标,recvx
是下一个接收操作的下标。recvq
、sendq
: 阻塞在chan
读写上的协程列表。底层是双向链表,链表的元素是sudog
(sudog
是一个对g
的封装),我们可以简单地理解为recvq
和sendq
的元素就是g
(协程)。
g 和 sudog 是什么?
上面提到了 g
和 sudog
,g
是底层用来表示协程的结构体,而 sudog
是对 g
的封装,记录了一些额外的信息,比如关联的 hchan
。
在 go 里面,协程调度的模型是 GMP
模型,G
代表协程、M
代表线程、P
表示协程调度器。我上图里面的 G
就是代表协程(当然,实际上是 sudog
)。
还有一个下面会提到的就是 g0
,g0
表示 P
上启动的第一个协程。
GMP
模型是另外一个庞大的话题了,大家可以自行去了解一下,对理解本文也很有好处。因为在 chan
阻塞的时候实际上也是一个协程调度的过程。
具体来说,就是从 g
的栈切换到 g0
的栈,然后重新进行协程调度。这个时候 g
因为从运行状态修改为了等待状态,所以在协程调度中不会将它调度来执行,
而是会去找其他可执行的协程来执行。
创建 chan
我们的 make(chan int, 9)
最终会调用 makechan
方法:
// chantype 是 chan 元素类型,size 是缓冲区大小
func makechan(t *chantype, size int) *hchan {
elem := t.elem
// compiler checks this but be safe.
// 检查元素个数是否合法(不能超过 1<<16 个)
if elem.size >= 1<<16 {
throw("makechan: invalid channel element type")
}
// 判断内存是否对齐
if hchanSize%maxAlign != 0 || elem.align > maxAlign {
throw("makechan: bad alignment")
}
// mem 是 chan 缓冲区(环形队列)所需要的内存大小
// mem = 元素大小 * 元素个数
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
if overflow || mem > maxAlloc-hchanSize || size < 0 {
panic(plainError("makechan: size out of range"))
}
// 定义 hchan
var c *hchan
switch {
case mem == 0:
// 队列或者元素大小是 0(比如 make(chan int, 0))
// 只需要分配 hchan 所需要的内存
c = (*hchan)(mallocgc(hchanSize, nil, true))
// ...
case elem.ptrdata == 0:
// elem 类型里面不包含指针
// 分配的内存 = hchan 所需内存 + 缓冲区内存
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
// 分配的是连续的一段内存,缓冲区内存在 hchan 后面
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// 元素类型里面包含指针
c = new(hchan)
// buf 需要另外分配内存
c.buf = mallocgc(mem, elem, true)
}
// 单个元素的大小
c.elemsize = uint16(elem.size)
// 元素类型
c.elemtype = elem
// 缓冲区大小
c.dataqsiz = uint(size)
// ...
}
创建 chan
的过程主要就是给 hchan
分配内存的过程:
- 非缓冲
chan
,只需要分配hchan
结构体所需要的内存,无需分配环形队列内存(数据会直接从sender
复制到receiver
) - 缓冲
chan
(不包含指针),分配hchan
所需要的内存和环形队列所需要的内存,其中buf
会紧挨着hchan
- 缓冲
chan
(含指针),hchan
和环形队列所需要的内存单独进行分配
对应到文章开头的图就是,底下的
hchan
和buf
那两段内存。
发送数据
<- 语法糖
在《深入理解 go chan》中,我们说也过,<-
这个操作符号是一种语法糖,
实际上,<-
会被编译成一个函数调用,对于发送操作而言,c <- x
会编译为对下面的函数的调用:
// elem 是被发送到 chan 的数据的指针。
// 对于 ch <- x,ch 对应参数中的 c,unsafe.Pointer(&x) 对应参数中的 elem。
func chansend1(c *hchan, elem unsafe.Pointer) {
chansend(c, elem, true, getcallerpc())
}
另外,对于 select
里面的调用,chansend
会返回一个布尔值给 select
用来判断是否是要选中当前 case
分支。
如果 chan
发送成功,则返回 true
,则 select
的那个分支得以执行。(select...case
本质上是 if...else
,返回 false
表示判断失败。)
chansend 第二个参数的含义
chansend
第二个参数 true
表示是一个阻塞