声明
本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。
引入
有了上一篇文章的基础,这一节我们来看通道的底层实现,我们先看一个例子,相信你已经很熟悉了:
func main() {
ch := make(chan int)
go func() {
ch <- 1
}()
time.Sleep(5 * time.Second)
fmt.Println(len(ch)) // 0
<- ch
}
我们在主协程中创建一个通道,并且开了一个子协程往通道里写入一个数据,然后再在主协程读取这个数据,且在读取之前打印通道的有效长度。由于我们在上一节说过,这是一个非缓冲通道,如果我们要向里面写入数据,必须得有接收方同时来接收才可以,否则就会发生死锁。
代码执行流程
我们分析下这个程序的执行流程,由于我们在主协程中强行睡眠了5s,那么子协程中的 ch <- 1 写入操作一定大概率会先于主协程的 <- ch 读取操作执行。又因为主协程中一直没有对通道进行读取操作,所以子协程中的 ch <- 1操作一直会阻塞在这里得不到执行,直到主协程从睡眠中醒来,对通道进行读取的时候,子协程才会执行写入通道的操作。
为什么len返回0
经过上面的解释,我们也就能够理解为什么打印的 len(ch) 为0了。因为打印的时候,子协程还在阻塞而并没有往通道中写入,自然通道里没有任何有效的数据。事实上,对非缓冲通道求 len ,其值永远都为0。因为我们都是等待接收方准备好之后,才会真正往通道中“推送”数据(这里并不是推送,而是内存拷贝,下文会讲),而不是事先直接把数据推送到通道中就完事了,这样就无法实现非缓冲通道的阻塞效果了。
通道的底层实现
我们上文说过,非缓冲通道并不需要一个存储空间去暂存推送数据,而是等到接收方准备好了、真正写入数据那一刻才会去推送数据,这个时候我们直接使用内存拷贝就好了。反之,由于缓冲通道在容量足够的时候,写入操作并不会阻塞,强依赖于读取操作,而是写入完毕直接返回,这样就决定了缓冲通道需要一块存储空间来真正存储推送的数据。
数据结构
channel还有一个先进先出的特性。先写入的数据会先被读出来,那对于缓冲通道,自然我们会想到使用队列来存储数据了。我们知道, channel是引用类型,其零值为nil。那么进过前面的学习,我们知道,所有引用类型的底层实现都可能会是一个结构体,然后结构体中的某个字段真正指向某块存储空间,而我们在函数参数传递的时候传的都是这个结构体本身,而并不拷贝结构体某个字段指向的底层结构,这就会造成在调用函数内部也能够修改函数外部的值的假象(好像是引用传递,其实是传递了结构体这个值,比如切片与字典的底层实现)
我们扯得有点远了,我们在使用make函数创建一个channel、甚至切片与map的时候,实际上就是创建了一个结构体,channel的结构体叫做hchan,切片叫sliceHeader,字典叫hmap。我们看下hchan的结构:
type hchan struct {
// 通道里元素的数量
qcount uint
// 循环队列的长度
dataqsiz uint
// 指针,指向存储缓冲通道数据的循环队列
buf unsafe.Pointer
// 通道中元素的大小
elemsize uint16
// 通道是否关闭的标志
closed uint32
// 通道中元素的类型
elemtype *_type
// 已接收元素在循环队列的索引
sendx uint
// 已发送元素在循环队列的索引
recvx uint
// 等待接收的协程队列
recvq waitq
// 等待发送的协程队列
sendq waitq
// 互斥锁,保护hchan的并发读写,下文会讲
lock mutex
}
果然,我们发现了buf这个字段,它是一个指向底层循环队列的指针,也验证了我们之前对“为什么channel是引用类型”的猜想。在我们进行函数参数值传递的时候,我们也仅仅是传的这个结构体值罢了,也相当于传了这个buf指针的值,而底层的buf内存空间则是两个指针共享的。最后,waitq类型则是用来存放Channel缓冲空间不足而阻塞的Goroutine列表,它是一个双向链表:
type waitq struct {
first *sudog
last *sudog
}
我们根据这个结构体画出数据结构图:
并发安全
在多核CPU环境下的Go的多协程环境,必然会出现多个协程同时并行的对同一个channel进行读取操作的情况,这样就带来了资源并发访问的问题。对于非缓冲通道,发送方与接收方必须是一对一、点对点的操作。如果有多个协程同时对某一个非缓冲channel进行操作,那么就会发生有部分读/写操作不能匹配到相应的写/读操作,必然会导致死锁。所以接下来我们只针对缓冲通道进行讨论。
为了叙述简便,我们把写入操作简称为send操作,读取操作简称为recv操作。send操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。recv操作包含了“复制通道内的元素值”“放置副本到接收方”“删除原值”三个步骤。
我们拿recv操作来举例,对于多个协程的接收操作来说,这个存数据的循环队列就是共享资源,如果多个协程同时去复制通道内的元素值,然后拷贝副本到接收方,那么我们就会出现“多次读取相同的元素”的情况。所以,Go语言会在“复制”操作之前,使用lock字段给循环队列加锁,表示我要去进行接收了,然后直到最后删除原值这个操作结束之后才会释放锁。这样就保证了recv中的三个子操作是一个整体,是一个原子操作,协程之间的recv操作都是串行执行的。在某一个协程进行recv操作的时候,其他协程只能阻塞在那里,等待上一个协程释放锁,这样就保证了循环队列数据的并发安全性。
所以,基于以上锁的机制,在同一时刻,Go语言的运行时系统只会执行对同一个通道的任意个发送操作中的某一个,直到这个元素值被完全复制进该通道之后,其他针对该通道的send操作才可能被执行,recv操作也同理。接下来我们总结一下send和recv的流程:
send
- 给循环队列加锁
- 复制元素值
- 把数据从协程1中copy到循环队列中
- 释放锁
recv
- 给循环队列加锁
- 复制通道内的元素值
- 把数据从循环队列中copy到协程2中
- 删除原值
- 释放锁
send流程图解
我们以send操作为例,我们用图片展示一下整个过程。
首先我们初始化一个空的通道数据结构:
接下来我们往通道中写入一个数据,加锁:
然后我们复制元素值,并最终拷贝到循环队列中:
以上都是针对缓冲通道来说的,而非缓冲通道因为根本不需要这个循环队列,我们直接使用内存copy,从协程1拷贝到协程2即可。
协程等待队列
最后,我们讲一下hchan结构体的sendq与recvq字段,他们的作用是什么呢?我们知道,在通道满了之后,如果我们继续往通道中写入数据,那么当前协程会被阻塞。这两个字段就是使用两个双向链表,来存储所有等待的协程的。一旦通道不再为空或者不再为满,那么Go协程的调度器就会去这个双向链表中唤醒一个链表中的协程,允许这个协程往通道中写入/接收数据也同理。具体如何唤醒的流程属于Go的GMP协程调度模型,这里就不再展开讲了,有兴趣的读者可以阅读下面的参考资料。
参考资料
尾声与致谢
到这里,我们的【Go语言踩坑系列】就全部写作完成了。只写别人没有写过的;别人如果写过的,我们就要写的更好。这是我们NoSay一直秉承的写作理念。我们会继续努力,致力于发布更高水平、更高质量、更有比较优势的文章。很高兴大家能够和我们一起成长,感兴趣的读者可以继续关注我们后续的产出,谢谢!
关注我们
欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~
有疑问加站长微信联系(非本文作者)