一、channel
1、实现
使用ch := make(chan int, 5)
创建一个有缓冲的channel之后,ch变成函数栈帧上的一个指针,指向堆上的实际hcann数据结构。
channel往往用于协程间的并发访问,所以要有一把锁锁住整个数据结构。
对于上述有缓冲channel需要知道的信息有:
1、已经存储的元素
2、最多存储的元素
3、每个元素占多大空间
所以channel缓冲区用一个数组实现。并且有缓冲区支持交替读写数据,因此需要两个值记录读写下标的位置。
当读和写不能立即完成。要让当前channel的协程等待,待到可以读写的时候,要能立即唤醒等待的协程。所以要有两个等待队列,分别对应读写操作,也就是发送和接受队列。
另外channel还有记录channel本身是否关闭的状态。因此channel底层的数据结构hchan结构体应该包括:类型信息,元素占用空间,元素大小,容量,发送和接受的下标值,两个等待队列,锁以及关闭状态等信息。
2、发送数据
创建ch := make(chan int, 5)
则会创建一个大小为5的缓冲区。协程g1持续向缓冲区写入数据比如:
ch <- 1
ch <- 2
ch <- 3
ch <- 4
ch <- 5
ch <- 6
过程如图:
最后发送到6次数据,但是缓冲区容量只有5,缓冲区的写下标sendx会回到缓冲区首部读下标recvx的位置0,第六个元素就无处可放。发送数的协程g1就会进入ch的发送等待队列sendq中。如下图
队列是基于链表实现,生成链表节点sudogo加入链表,节点信息包括:哪个协程在等待,等待的是哪个channel,等待发送的数据在哪等信息。
当有新的协程从缓冲区读取数据,读下标会前移,空出了新的缓冲区就会唤醒加入等待队列的协程g1,g1退出队列执行ch <- 6
操作,把数据写入缓冲区0号位置,所以其实channel的缓冲区是一个环形缓冲区。
所以对于写操作时候发送数据的协程不阻塞情况有:
1、缓冲区还有空闲位置
2、有协程在等待从缓冲区接收数据
对于写操作时候发送数据的协程阻塞情况有:
1、channel为nil
2、无缓冲区且没有协程等待接收数据
3、有缓冲区,但是已用尽
为了不阻塞可以使用select机制,代码如下:
select {
case ch <- 10;
...
default:
...
}
进入select,如果检测到channel可以发送数据,执行case分支,如果不能发送数据会产生阻塞,则会进入default分支。
3、接收数据
接收数据的写法更多
1、<- ch
:会将读出的结果丢弃
2、 v := <- ch
:结果赋值为变量v
3、v, ok := <- ch
: ok为false时候,表示ch关闭,v为channel元素类型的0值
只有在缓冲区有数据和有协程往缓冲区发送数据才不会阻塞。如果出现以下情况则会读协程发生阻塞:
1、ch为nil
2、无缓冲也无发送协程
3、有缓冲,缓冲区无数据
为了不阻塞也可以使用select机制。
二、多路select
1、select实现
以上读写操作中的select中,只有一个case分支,只针对一个channel。多路select指的就是存在两个及以上的case分支。每个分支可以使一个channel的发送或者接受操作。比如
var a, b int
b = 10
select {
case a = <- ch1;
case ch2 <- b;
default;
}
把执行上述多路select的协程称为g1,编译器会将多路select转换为对底层runtime.selectgo函数的调用,函数调用中有很多参数,比如数组cas0, 数组order0以及其他参数。
- 数组cas0:数组中存放select中的所有case分支,send在前,recv在后。
- order0数组:指向uint16类型的数组,数组容量为cas0的两倍,被用作两个数组。用来保存对case内channel轮训的顺以及加锁的顺序。轮训需要乱序保证公平性,确定加锁顺序避免死锁。
- 其他参数:包括发送分支数目nsends以及接受分支数目nrecvs,以及记录select是否需要阻塞的值block,如果有default则不会被阻塞,没有则会被阻塞。
- 函数返回值:另外函数调用也有返回值,第一个返回值为int类型对哪一个case分支被执行,如果执行default分支,则返回-1。第二个返回值为bool类型用于执行channel分支的操作时候,表示分支channel返回真实数据还是channel关闭。
2、多路select处理逻辑
按序加锁+乱序轮训:
多路select需要进行轮训确定哪个case分支可以操作,轮训前需要先按照顺序对case中的所有channel有序加锁。然后按照乱序的轮询顺序检查channel的等待队列和缓冲区。
挂起等待+ 按序解锁:
假如检查到ch1,如果ch1中的缓冲区有数据可读,则进入对应case分支,当前协程g1拷贝数据开始执行。
假如所有channel都无法执行,就把当前协程加入到select中所有case中channel的对应发送或者接受队列中,阻塞等待。然后g1会挂起,按序解锁所有channel对应的锁。
按序加锁+离开队列+上锁+返回:
假如接下来有数据可用,g1的就会被唤醒执行对应分支。执行之后,将自己再次按序加锁,接着从对应的队列中将自己移除,再次对channel上锁后返回。
三、应用及问题
1、生产者消费者模型
channel可以用来作为两个协程通信,常用的应用比如生产者消费者模型,上游的生产者生产数据写入缓冲区,下游的消费者从缓冲区中读取数据,这个缓冲区可以用channel实现。从而解决了并发,解耦和缓存等问题。
比如实际应用中,同时访问同一个公共区域,同时进行不同的操作。都可以划分为生产者消费者模型,比如订单系统。很多用户的订单下达之后,放入缓冲区或者队列中,然后系统从缓冲区中去读来真正处理。系统不必开辟多个线程来对应处理多个用户的订单,减少系统并发的负担。通过生产者消费者模式,将订单系统与仓库管理系统隔离开,且用户可以随时下单(生产数据)。如果订单系统直接调用仓库系统,那么用户单击下订单按钮后,要等到仓库系统的结果返回。这样速度会很慢。
也就是:用户变成了生产者,处理订单管理系统变成了消费者。
相关链接:生产者消费者模型及go实现
2、内存泄漏
在一.2,一.3中的对缓冲读写操作都需要协程执行,但是如果协程创建之后,因为缓冲区空或者死锁等情况,协程阻塞持续不释放,go很难判断这些阻塞是人为的还是出现故障,也难判断这些协程的阻塞是暂时的还是永久的,所以不会主动去释放阻塞的协程而造成永久阻塞,而程序还在不断创建新的协程,就会造成内存持续泄漏,严重的话最终程序崩溃。
四、总结
主要介绍了如下:
- channel的底层数据结构对应哪些信息
- channel中缓冲区其实是环状缓冲区以及等待队列
- 读写下的阻塞和非阻塞操作
- 多路select的用法和处理逻辑
注:以上仅作为幼麟实验室Golang学习笔记,总结有错误谅解。视频链接