通道描述
通道(Channel/Chan)是在golang并发编程中用的最多的一个数据结构(也可以说是基础类型),在go中,作者在并发编程的环境下对于数据之间的共享提出了一个建议,也是go的经典开发准则:
Do not communicate by sharing memory; instead, share memory by communicating.
不要通过共享内存来通信,而应该通过通信来共享内存
在通信方面,通道给我们提供了一个很好的在并发编程环境中进行多Goroutine之间的数据共享。那么本篇文章就来解析一下通道在go中的源代码是如何实现的。
通道相关结构体解析
本篇文章所有的源代码数据均采用go1.15.5版本,如果和您的源代码不相同,建议更换1.15.5版本。
接下来我将根据通道的结构与其相关常量来进行解析,通道的源代码文件在 runtime/chan.go 路径下。
(前方高能,代码巨长,请各位准备好瓜子饮料矿泉水耐心观看)
type hchan struct {
qcount uint // 队列中有多少数据,即len时获得的值
dataqsiz uint // chan 是一个循环队列,这个字段代表这个循环队列的总大小,即cap时获取的值
buf unsafe.Pointer // 指向数组的指针,这个循环队列时靠数组实现的
elemsize uint16 // 每个元素的size,做为偏移使用
closed uint32 // 这个chan 是否关闭的标记
elemtype *_type // 构建chan时候的元素类型
// 这个索引是用于做地址偏移的而不是像数组一样的下标
sendx uint // 发送索引 即当前发送到哪了
recvx uint // 接收索引
recvq waitq // 被阻塞的接收g的列表
sendq waitq // 被阻塞的发送g的列表
// 一个锁 防止修改结构体中内容以及 waitq中的内容
lock mutex
}
type waitq struct {
first *sudog // 队列头 sudog是一个存有等待g相关信息的结构体
last *sudog // 队列尾 sudog是一个存有等待g相关信息的结构体
}
构建通道操作
func makechan64(t *chantype, size int64) *hchan {
// 应该是判断当前系统是不是64位系统,因为int的长度会随着系统长度改变而改变
// 32位系统中 int 相当于int32 64位中 int相当于int64
if int64(int(size)) != size {
panic(plainError("makechan: size out of range"))
}
// 调用构建chan的函数,实际上就是make(chan type)时
// 由runtime调用的makechan64
return makechan(t, int(size))
}
func makechan(t *chantype, size int) *hchan {
// chantype 代表着make(chan xxx)时的xxx的类型
elem := t.elem
// 检查一下类型的一些信息
if elem.size >= 1<<16 {
throw("makechan: invalid channel element type")
}
if hchanSize%maxAlign != 0 || elem.align > maxAlign {
throw("makechan: bad alignment")
}
// 检查元素的长度和要构建的chan的长度是否会造成内存溢出
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
if overflow || mem > maxAlloc-hchanSize || size < 0 {
panic(plainError("makechan: size out of range"))
}
// 初始化一个chan结构体指针
var c *hchan
switch {
// 如果mem等于0 则代表make长度为0即 make(chan type)
// 或者元素的大小为0 即 make(chan struct{},10)
case mem == 0:
// 当队列的长度为0或者元素的大小为0时,分配一个固定长度的内存地址
c = (*hchan)(mallocgc(hchanSize, nil, true))
// 做一下地址同步,防止未来访问地址是会与qcount。dataqsiz和closed字段产生冲突
// 此处存疑,如果说错了求大佬指正
c.buf = c.raceaddr()
// 代表make的元素类型不是个指针
case elem.ptrdata == 0:
// 当元素类型不是指针 就一次性分配了整个make时选择类型的内存
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// 如果元素要是个指针。那就分配一个men的长度的elem类型的内存,
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
// 一些基本赋值
c.elemsize = uint16(elem.size)
c.elemtype = elem
// chan的容量
c.dataqsiz = uint(size)
lockInit(&c.lock, lockRankHchan)
if debugChan {
print("makechan: chan=", c, "; elemsize=", elem.size, "; dataqsiz=", size, "\n")
}
return c
}
构建一个通道的源代码部分已经写了一些注释,接下来做一个总结:
- 程序在编译期间侦测到make(chan type,len )时,会将其替换成为
func makechan(t *chantype, size int64) *hchan
,我使用下列代码为大家呈现此调用在汇编中的样子。
func main() {
a := make(chan string, 998)
fmt.Println(a)
}
其汇编代码输出为:
0x0021 00033 LEAQ type.chan string(SB), AX
0x0028 00040 MOVQ AX, (SP)
0x002c 00044 MOVQ $998, 8(SP)
0x0035 00053 PCDATA $1, $0
0x0035 00053 CALL runtime.makechan(SB)
0x003a 00058 MOVQ 16(SP), AX
0x003f 00063 XORPS X0, X0
0x0042 00066 MOVUPS X0, ""..autotmp_13+64(SP)
0x0047 00071 LEAQ type.chan string(SB), CX
因为go的汇编我不怎么熟,所以只提取部分汇编代码,上面汇编代码可以清楚的看出,第00044处,将998,放到了一个内存地址处此处998即make时chan的长度,第00053处,调用了runtime.makechan(SB),代表将make(chan string, 998)转换为runtime.makechan(SB)调用
-
然后获取其要创建的chan的类型,首先判断该类型占用字节数是否大于2^16字节,即通道的类型size不能超过64kB,如果大于则代表此类型不符合创建的条件,会抛出panic。
(可以测试一下,构建一个下列代码,上面的就可以通过编译 下面就不可以)
a := make(chan [1<<16 - 1]bool) a := make(chan [1<<16 ]bool)
-
然后判断该类型该类型的对齐是否大于规定的
maxAlign
以及整体的hchan占用内存大小是否能整除maxAlign
-
随后会判断该类型占用字节数与设定的make长度的乘积是否会溢出内存,如果溢出则会panic
-
然后会根据几个条件去做相应的内存分配:
- 如果mem为0的话则代表类型的占用字节数为0,最典型的就是struct{} 空结构体类型,其不占用任何内存空间,或者make的长度为0,则要生成一个无缓冲的chan。此时则分配一个长度为
hchanSize
的内存空间。然后给c.buf做一下地址同步,因为其是不分配空间的,防止未来访问地址是会与qcount。dataqsiz和closed字段产生冲突(此处存疑,如果说错了求大佬指正) 。 - 如果类型不是一个指针类型的话,则直接一次性分配一个
hchanSize+mem
长度的顺序内存。然后把c.buf的起始位置设置到c的起始位置+hchanSize偏移的位置,也就是说分配的是一块内存但是分成两部分使用,一部分归hchan使用一部分归c.buf使用。 - 如果类型是一个指针类型,则直接分配一个符合类型指针长度的内存给c.buf。
- 如果mem为0的话则代表类型的占用字节数为0,最典型的就是struct{} 空结构体类型,其不占用任何内存空间,或者make的长度为0,则要生成一个无缓冲的chan。此时则分配一个长度为
-
分配完内存之后就可以把相关数据信息设置到hchan结构体中了,包括设置类型占用字节数(为了做c.buf的偏移量方便依次访问),还有类型,和hchan总的长度,即cap时的返回的长度。然后将锁初始化。
-
为什么要区分是否包含指针呢,官方给出了一个答案
Hchan does not contain pointers interesting for GC when elements stored in buf do not contain pointers. 也就是说,当类型不包含指针的时候,gc是不会去扫描并且回收的
通道的发送
发送代码解析
首先我们来讲一下通道的发送操作会在底层源代码处执行什么样的业务逻辑,废话不多说 show me code!
首先还是从会汇编阶段看一看对下面代码的编译会产生什么汇编代码:
func main() {
a := make(chan int,1)
a <- 1
}
我创建了一个有缓冲的通道并且向其发送了一个 1,运行下面的命令
go build --gcflags="-N -l -S" main.go
我获得了一个未经过编译期优化的汇编代码
0x0035 00053 CALL runtime.makechan(SB)
.....
0x0054 00084 CALL runtime.chansend1(SB)
多余的我们不看,只看00053处调用runtime.makechan(SB)构建了一个通道,这和我们上面讲的通道的构建是相同的,随后去除中间我们不需要看到的代码看到00084处调用了runtime.chansend1(SB),代表着向通道发送了一个数据。接下来我们看一下这个runtime.chansend1()的代码解析。
(再次提醒,前方高能,代码巨长,请各位准备好瓜子饮料矿泉水耐心观看)
太长不爱看直接跳转 :通道的发送总结
注:下面代码去掉了一些跟业务逻辑关系不大的代码
// 通道发送的入口函数,第一个参数是通道,第二个是发送数据的地址
func chansend1(c *hchan, elem unsafe.Pointer) {
// 调用发送函数,第三个参数true代表是否阻塞,true则为阻塞发送
// 最后一个参数是返回调用者的程序计数器和栈指针
// 不用关心第四个参数
chansend(c, elem, true, getcallerpc())
}
// 真正的发送业务逻辑
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
// 如果要是当前通道为nil 并且是阻塞模式则直接永久阻塞
if c == nil {
// 如果为不阻塞发送则直接返回
// 当
// select {
// case channel<-1:
// todo
// default:
// }
// 时会出现block == false的情况
if !block {
return false
}
// 挂起当前协程
// 此时会永久阻塞,系统监控会直接抛出死锁
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
throw("unreachable")
}
// 如果非阻塞并且还没关闭,并且通道还满了 直接返回
// 这个full需要说一下
// 这个函数会判断下面的条件返回
// 1。如果是无缓冲通道且没有接收端则返回true
// 2。如果是有缓冲通道 并且无可用缓冲区了 返回true
if !block && c.closed == 0 && full(c) {
return false
}
// 加锁 证明通道是一个有锁队列
lock(&c.lock)
// 对一个关闭了的通道发送会 panic
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
// 如果当前存在一个接收端
if sg := c.recvq.dequeue(); sg != nil {
// 调用发送函数 然后解锁返回
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
/// 如果当前缓冲区还有空余
if c.qcount < c.dataqsiz {
// 找到当前发送索引所处位置的指针
qp := chanbuf(c, c.sendx)
// 把发送的元素复制过去
typedmemmove(c.elemtype, qp, ep)
// 发送索引+1
c.sendx++
// 因为底层是个环形队列,所以当索引==长度的时候,索引归0
if c.sendx == c.dataqsiz {
c.sendx = 0
}
// 当前元素数量+1
c.qcount++
// 解锁
unlock(&c.lock)
return true
}
// 此时就相当于缓冲区没有空余了
// 且非阻塞发送 那么 返回
if !block {
unlock(&c.lock)
return false
}
// 获取当前g 就是发送数据那个g
// 因为这个函数注释写的不大清楚,所以这个函数一直有疑问到底获取的是什么的g
// 根据官方某个文档给出的结果是
// `getg()` alone returns the current `g`, but when executing on the
// system or signal stacks, this will return the current M's "g0" or
// "gsignal", respectively.
// 翻译一下就是:
// `getg()`单独返回当前的`g`,但在系统或信号栈上执行时,
// 会分别返回当前M的 "g0 "或 "gsignal"。
gp := getg()
// 开始
// 从开始到结束这一系列操作就是获取一个*sudog结构
// 这个结构放的是等待列表里的g的结构
// 然后设置一大堆的相关信息 比如发送元素的地址在哪,是不是在select里面
// 向哪个通道发送等等
// 在此不做赘述了
mysg := acquireSudog()
mysg.releasetime = 0
if t0 != 0 {
mysg.releasetime = -1
}
mysg.elem = ep
mysg.waitlink = nil
mysg.g = gp
mysg.isSelect = false
mysg.c = c
// 把当前的g的等待设为mysg
// 代表当前g正在等待这个mysg
gp.waiting = mysg
gp.param = nil
// 结束
// 把生成这个sudog放到发送等待队列里
c.sendq.enqueue(mysg)
// 栈内存收缩相关信息,告诉他别收缩我这个栈
atomic.Store8(&gp.parkingOnChan, 1)
// 阻塞等待唤醒
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
// 防止gc对这个发送的地址做点什么不好的事情
KeepAlive(ep)
// 把当前g的一些信息和sugog的一些信息清理一下 然后返回
// 因为已经放到了等待发送列表,当可发送的时候这个g就会被唤醒
// 所以到此处的时候代表该g已经被唤醒数据也已经发送成功了
gp.waiting = nil
gp.activeStackChans = false
if gp.param == nil {
if c.closed == 0 {
throw("chansend: spurious wakeup")
}
panic(plainError("send on closed channel"))
}
gp.param = nil
if mysg.releasetime > 0 {
blockevent(mysg.releasetime-t0, 2)
}
mysg.c = nil
releaseSudog(mysg)
return true
}
// 发送逻辑
// 参数:
// 1。通道
// 2。正在等待接收的那个sudog
// 3。发送数据的地址
// 4。发送结束后的回调
// 5。这个参数是goready用的但是不知道实际用途是啥。。好像是运行时的跟踪相关
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
// 如果等待接受的目标地址不是nil
if sg.elem != nil {
// 直接把要发送的变量的内存地址里面的数据拷贝到等待接受数据的内存地址上
sendDirect(c.elemtype, sg, ep)
// 拷贝完了 要他没用了
sg.elem = nil
}
// 获取等待接收数据的g
gp := sg.g
// 调用回调
unlockf()
// 唤醒的时候要传递的参数,如果不传就出问题了
gp.param = unsafe.Pointer(sg)
// 唤醒他!标记为_Grunnable
// 但是注意,不是让这个g所处的p立刻执行
// 而是放p.runnext上 让他在下一个执行
goready(gp, skip+1)
}
通道的发送总结
好了上面一大段代码都是关于通道发送的一些代码解析,代码很长,但是去掉一些没啥用的代码之后还算很好理解,我来总结一下发送的具体流程吧:
- 如果当前通道为nil,即未make的通道,则永久阻塞。
- 如果当前通道已关闭,则panic。
- 如果等待接收队列有正在等待的接收者则取出等待接收队列最前面的一个goroutine,直接把发送的变量内存地址里面的东西复制到其对应接收变量的内存地址中,然后唤醒等待方。例子:x:=<-c,则直接发送方的发送变量的数据复制到x变量地址中。在复制完相关数据后,会唤醒接收方的goroutine,然后将其加入到其p的runnext字段中,代表该g为下一个运行的g,而不是立刻运行
- 如果当前缓冲区有空余的位置,则把发送变量内存地址里面的东西拷贝到对应的空余位置中。然后当前通道元素个数+1。
- 如果上面都不满足, 则把当前的goroutine绑定一个sudog并在sudog中存入的相关信息后将sudog放入发送等待列表,等待接收方接收数据后唤醒。
通道的接收
接收代码解析
针对通道的发送,我们仍然从汇编代码看起,当前我有一个代码段如下:
func main() {
a := make(chan int)
go func() {
a <- 1
}()
b := <-a
fmt.Println(b)
}
代码中,创建了一个无缓冲通道,然后往其中发送数据和接受数据,打印一下汇编代码如下:
0x0043 00067 CALL runtime.makechan(SB)
...
0x008b 00139 CALL runtime.chanrecv1(SB)
去除了一些无关代码,只看000139处的代码,runtime.chanrecv1(SB) 这个代码是针对b := <-a的一个编译期间的改写,编译期间会将b := <-a改为runtime.chanrecv1(SB)调用,而当代码为b, ok:= <-a时,则会调用 runtime.chanrecv2(SB)。其区别就是返回了一个参数还是两个。其最终都会调用runtime.chanrecv()函数,下面我开始解析这个函数的相关源代码。
(再再次提醒,前方高能,代码巨长,请各位准备好瓜子饮料矿泉水耐心观看)
太长不爱看直接跳转 :通道的接收总结
注:下面代码去掉了一些跟业务逻辑关系不大的代码
// 实际的接收函数参数分别为
// 1。通道
// 2。接收变量的地址
// 3。是否阻塞接收,跟接收一样,非阻塞接收在select有效
// 返回值为:
// 1.是否接收到了
// 2.是否接收成功
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
// 老规矩 对未make的通道进行收发操作均会阻塞
if c == nil {
// 如果为非阻塞接收则直接返回
// 当
// select {
// case <-channel:
// todo
// default:
// }
// 时会出现block == false的情况
if !block {
return
}
// 否则挂起直接阻塞
gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
throw("unreachable")
}
// 如果为非阻塞操作且为空
// empty返回条件:
// 1.非缓冲通道且无发送方时,返回true
// 2.如果通道里暂时没有数据时 返回true
if !block && empty(c) {
// 通道关了 返回
// 也就是说对关闭通道的接收操作不会panic
if atomic.Load(&c.closed) == 0 {
return
}
// 如果为空
if empty(c) {
// 且接收变量的地址不为nil 即不能未初始化
// 这个地方 ep== nil的情况可能是
// _,ok:=<-chan的时候
if ep != nil {
// 这个是清空内存
// 将接收地址变量置为对应类型零值
typedmemclr(c.elemtype, ep)
}
return true, false
}
}
// 加锁
lock(&c.lock)
// 如果关了并且通道里也没有可接收数据的时候
// 解锁,并将接收地址变量置为对应类型零值
if c.closed != 0 && c.qcount == 0 {
unlock(&c.lock)
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
// 如果目前等待发送队列里面有正在等待发送的goroutine
if sg := c.sendq.dequeue(); sg != nil {
// 直接发
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
// 如果当前缓冲区里面有数据
if c.qcount > 0 {
// 找到当前接收索引所处位置的指针
qp := chanbuf(c, c.recvx)
// 把找到那个指针里面的数据,拷贝到接收者的地址
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
// 把索引位置那个指针的值归零
typedmemclr(c.elemtype, qp)
// 索引+1
c.recvx++
// 因为底层是个环形队列,所以当索引==长度的时候,索引归0
if c.recvx == c.dataqsiz {
c.recvx = 0
}
// 取出来一个了 那就当前元素个数-1
c.qcount--
// 解锁 返回
unlock(&c.lock)
return true, true
}
// 如果缓冲区没数据的时候且
// 非阻塞直接解锁返回
if !block {
unlock(&c.lock)
return false, false
}
gp := getg()
// 开始
// 从开始到结束这一系列操作就是获取一个*sudog结构
// 这个结构放的是等待列表里的g的结构
// 然后设置一大堆的相关信息 比如接受数据的地址在哪,是不是在select里面
// 向接收的通道等等
// 在此不做赘述了
mysg := acquireSudog()
mysg.releasetime = 0
mysg.elem = ep
mysg.waitlink = nil
gp.waiting = mysg
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.param = nil
// 结束
// 做完上面的赋值
// 把这个mysg塞到等待接收队列
c.recvq.enqueue(mysg)
// 栈内存收缩相关信息,告诉他别收缩我这个栈
atomic.Store8(&gp.parkingOnChan, 1)
// 阻塞等待唤醒
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
// 嘿!被唤醒了
if mysg != gp.waiting {
throw("G waiting list is corrupted")
}
// 下面一系列都是唤醒后做了一些清空操作
gp.waiting = nil
gp.activeStackChans = false
if mysg.releasetime > 0 {
blockevent(mysg.releasetime-t0, 2)
}
closed := gp.param == nil
gp.param = nil
mysg.c = nil
releaseSudog(mysg)
return true, !closed
}
// 真正的接收操作
func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
// 如果是无缓冲通道
if c.dataqsiz == 0 {
// 直接把发送方的发送地址的值拷贝给接收变量的地址
if ep != nil {
recvDirect(c.elemtype, sg, ep)
}
} else {
// 有缓冲通道
// 算一下当前接收到哪个索引了,找出他的地址
qp := chanbuf(c, c.recvx)
// 把索引哪个地址的值拷贝到接收变量的地址
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
// 把等待发送的g的要发送的数据拷贝到哪个空出来的索引处
typedmemmove(c.elemtype, qp, sg.elem)
// 接收索引+1
c.recvx++
// 环形队列不做解释了
if c.recvx == c.dataqsiz {
c.recvx = 0
}
// 如果不设置 可能会在下次发送的时候
// 发送数据覆盖到已存在的数据上
c.sendx = c.recvx
}
sg.elem = nil
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
// 唤醒他!标记为_Grunnable
// 但是注意,不是让这个g所处的p立刻执行
// 而是放p.runnext上 让他在下一个执行
goready(gp, skip+1)
}
通道的接收总结
好了上面一大段代码都是关于通道接收的一些代码解析,代码很长,但是去掉一些没啥用的代码之后还算很好理解,我来总结一下发送的具体流程吧:
- 老规矩,对未make的通道发送(即通道为nil)都会永久阻塞。
- 如果当前通道关闭了,则返回通道存储类型的零值,跟发送不同,接收关闭的通道不会panic。
- 如果当前等待发送队列存在正在等待发送的g,则取出第一个g,并且:
- 当前通道为无缓冲时直接把发送方存储数据的地址里面的数据拷贝到接收变量的地址上,然后唤醒发送方。
- 如果当前通道为有缓冲,则将当前接收的索引处的数据拷贝到接收变量的地址上,然后把等待发送的g所持有的发送东西的地址中的值拷贝到这个接收的索引处覆盖原值。
- 如果不存在等待发送的g且为有缓冲通道,且缓冲区中有数据,那么则将当前接收的索引处的数据拷贝到接收变量的地址上,然后将索引处的数据置零。
- 如果上述都不符合,那么将会将当前g放入recvq即接收等待列表,等待发送方的唤醒和数据发送。
通道的关闭操作
关于通道的关闭,我就不上源代码解析了,只总结以下几点:
- 把closed字段由0设置为1(我不知道为什么官方没有把这个字段设置为bool类型而是 uint32 )。
- 然后把当前处于发送等待列表sendq和接收等待列表recvq中的所有g取出放入一个gList中。
- 然后把gList里面所有的g都唤醒,此时发送的数据都会被抛弃。
总结
至此我们来对通道做一个总体的总结:
- 当前通道为无缓冲通道时,如果发送和接收方有一方不存在,则会阻塞当前对通道的接收和发送操作。
- 对关闭的通道进行发送会panic,而接收不会,但是会返回一个对应类型的零值和一个false。
- 当接收方的接收变量为 ‘_’ 时,不会拷贝数据到其中。
- 通道是一个有锁的环形的队列 。
- 创建通道时,make(chan int) 与 make(chan int ,0 )等同,皆为创建一个无缓冲通道。
- 创建通道时,通道的元素类型占用内存不能大于2^16 即,64KB。
- 无缓冲通道不会分配循环队列。
- 创建通道时元素类型为非指针的话, hchan和循环队列的内存地址是连续的,反之,循环队列的地址和hchan的内存地址不一定是连续的。
我已开通自己的公众号【Echo的技术笔记】
日后的文章发布会主要在公众号上发布
希望各位关注一下
谢谢大家啦