聊聊go语言channel为什么可以高效
# 写在文章开头
对于go语言,我们再更进一步,本文将对channel的源码的进行深入分析,我们将从数据结构和发送流程简单介绍一下channel的工作机制,希望对你有帮助。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

# 详解channel发送队列工作机制
# channel基本数据结构
go语言中channel的核心概念就是由环形缓冲区,协程的发送队列和接收队列构成,本质上这个环形缓冲区就是一个有指针进行管理的数组,之所以这样设计的原因也很简单,主要是数组地址空间连续,基于局部性原理一次可以读取尽可能多的地址空间存储数据。
而channel的数据都来自于各个协程,所以这其中就有了生产者和消费者的概念,一旦缓冲区数据满了生产者就会不能继续投递数据,于是这些协程就会存放到发送队列,与之同理一旦缓冲区数据为空则消费者协程就会阻塞等待就绪数据。

对应的我们给出这个数据结构的源码:
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
//......
//发送队列的索引和接收者当前索引位置
sendx uint // send index
recvx uint // receive index
//接收队列和发送队列的协程
recvq waitq // list of recv waiters
sendq waitq
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这里我们也对waitq 进行简单的拓展一下,可以看到其实现本质就是通过first和last指针维护的sudog链表,每一个sudog的关联关系都是由各自的prev和next指针维护:

对应我们也给出waitq和sudog的结构体,读者可参考上图查阅:
type waitq struct {
//链表首元素指针
first *sudog
//链表尾指针
last *sudog
}
type sudog struct {
//......
g *g
//后继节点指针
next *sudog
//前驱节点指针
prev *sudog
//......
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 直接送达消费协程
为了保证协程之间通信的高效,go语言针对不同的发送场景提出不同的交互逻辑,先来说说第一种情况,即发送队列有数据,缓冲区没有数据,这种情况channel的工作机制是直接将数据直接送完消费者协程,并将其唤醒处理,由此避免了一次投递数据的开销,提升协程间通信的效率:

对此我们直接查看往channel投递数据语法转化为的函数chansend1,可以看到其内部调用chansend进行数据投递,参数列表含义依次是channel指针、投递的元素elem 指针:
// entry point for c <- x from compiled code.
//
//go:nosplit
func chansend1(c *hchan, elem unsafe.Pointer) {
chansend(c, elem, true, getcallerpc())
}
2
3
4
5
6
7
步入chansend可以看到如果channel的recvq即接收队列不为空,则会直接调用send方法将数据直接发送给协程,而不是将数据先放到环形缓冲区再通知接收队列协程去取:
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
//......
//上锁
lock(&c.lock)
//接收队列不为空
if sg := c.recvq.dequeue(); sg != nil {
//绕过缓冲区,直接发送给等待协程,完成后解锁
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
//......
}
2
3
4
5
6
7
8
9
10
11
12
send逻辑比较简单,调用sendDirect将元素赋值到接收协程的elem指针上,然后将消费协程sg唤醒:
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
//......
//将元素赋值到sg的elem指针所指向的位置,然后将指针清空
if sg.elem != nil {
sendDirect(c.elemtype, sg, ep)
sg.elem = nil
}
//......
//唤醒消费协程工作
goready(gp, skip+1)
}
2
3
4
5
6
7
8
9
10
11
# 入队等待协程消费
接下来我们说说第二种情况,即接收队列没有消费协程,我们的元素就需要按照原有逻辑将数据存入环形缓冲区中等待被消费,需要注意的是,因为我们的环形缓冲区是用数组实现,为实现循环复用,当我们的元素存到数据尾部的时候,下一个元素的插入索引会直接指向0开始下一轮的循环复用:

对应的代码片段仍然是位于chan.go的chansend方法,逻辑如上文所说:
if c.qcount < c.dataqsiz {
//拿到环形缓冲区指针sendx位置的指针
qp := chanbuf(c, c.sendx)
//......
//元素入队
typedmemmove(c.elemtype, qp, ep)
//下一个位置的索引自增,如果到达上限则重置为0开始下一轮的复用
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
//队列元素累加
c.qcount++
//解锁
unlock(&c.lock)
return true
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 等待空闲发送
自此我们来到了最后一种情况,即环形缓冲区无法容纳当前数据,那么发送数据的协程就需要阻塞,或许消费协程看到有发送队列有协程阻塞则直接拿走它的数据进行消费:

查看chan.go的chansend后续逻辑,可以看到队列已满的情况下,chansend方法将当前协程的信息以及要发送的数据即ep指向的地址一并封装成sudog,然后存入发送队列sendq,再用gopark将其阻塞:
//获取当前协程
gp := getg()
mysg := acquireSudog()
//......
//将要发送的数据和协程封装到sudog中
//ep指向的数据的指针记录到elem指针
mysg.elem = ep
//......
//记录当前协程
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.waiting = mysg
gp.param = nil
//存入发送队列
c.sendq.enqueue(mysg)
//将协程阻塞
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
后续消费者协程发现发送队列中有阻塞的协程,则直接调用recv消费其数据:
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
//......
lock(&c.lock)
if c.closed != 0 {
//......
} else {
//发现发送队列中有数据
if sg := c.sendq.dequeue(); sg != nil {
//直接到发送队列的协程拿数据
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 小结
与之相同的接收也分为直接消费、队列获取、阻塞等待,和发送队列差不多,笔者这里就不多做赘述,读者可自行查阅源码。自此,本文结束,希望对你有帮助。
我是 sharkchili ,CSDN Java 领域博客专家,mini-redis的作者,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。 因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
