做为Go语言的爱好者,今天我们来聊聊chan类型
chan英文叫channel,中文名叫渠道,通道,从名字看出来,它应该是联系两种事务的一个媒介,现实社会中的通道可以走人,让人们从一个地方到另一个地方,也可以让人从另一个地方到这个地方,起到一个连接的作用。我们里面联系到了,那在计算机系统里面就是消息的通道。
一、并发锁
首先我们以一个锁的问题开始,举一个场景 A 和 B用户在某购物平台购买某phone,平台库库存只有1个,A,B同时下单,设计下这个过程
func main(){
count := inventory.getCount()
if count <= 0 {
panic("商品数量不足")
}else {
user.subMoneny(100)
inventory.sub(1)
}
获取库存,接着判断库存数量,库存小于0,直接抛出异常,否则用户扣余额,减库存。这个是一个简单的交易过程,这段代码在用户量很小,或者并发不大的情况下,是没有太大问题,但是如果用户量和并发量上来,就不行了。
举个例子 A 用户 和 B 用户同时下单。 A 用户购买过程,程序执行到 user.subMoneny(100) 代码,B用户刚执行到 count := inventory.getCount(),这时B 读的库存是1,因为A购买过程还未执行到 inventory.sub(1) 减库存,这时 A 和 B 够能购买成功,但是库存却变成了 -1,这就是超卖现象。
解决超卖现象有多分方法,这里不展开来讲述,这里我们用锁解决,go 语言有自己的锁封装库,但是这里我们通过chan 类型来实现。
二、消息通信
这里我们提到了消息通信,我们来说说进程内的消息通信,实现消息通信有很多种办法,对于不通场景通信方式不通。其他语言进程内消息通信最常用的办法就是设置一变量,通过修改变量还通信。go语言通过消息共享内存,而其他语言通过内存传递消息。
1、不考虑并发情况下,通过共享内存通信
var flag int
func main(){
if doSomeThingA() == true {
flag := 1
}
if flag == 1 {
doSomeThingB()
}
}
创建一个变量 flag,然后调用函数 doSomeThingA() 如果解决返回true,那么flag =1,通过判断是否flag =1 ,来执行doSomeThingB(),这个程序打眼一看,没有毛病,但是一想有点多此一举了,直接判断 doSomeThingA() 结果是否为true,来执行 doSomeThingB() 就行了,其实只是想说明这个意思,但是实际中还是有小伙伴这么写的,我当时很费解。
2、并发情况下,通过共享内存通信
var int flag
func main(){
go func (flag *int){ //匿名函数1
if doSomeThingA() == true {
flag := 1
}
}(&flag)
go func (flag *int) { //匿名函数2
if flag == 1 {
doSomeThingB()
}
}(&flag)
}
解释下这段代码 定义变量 flag , 然后通过go调用两个匿名函数 ,匿名函数1里面判断 doSomeThingA(),设置flag = 1,匿名函数2里面判断 flag = 1,来执行doSomeThingB()。
里面用到了go 关键字,go关键字是开启一个协程来执行函数,协程的概念单独写一个文章来写,可以这么理解,协程之间是可以并发执行的,不能保证先后顺序,可以防止某个函数执行出现问题阻塞整个进程。
传参是时候用到&,定义函数参数用到*,这个属于引用传递和值传递概念。我们想在函数里面修改flag的值,就必须通过引用传值。关于这两种传值方式,后续可以单独研究。
如果函数1先执行,函数2后执行,那么doSomeThingA() 为true,doSomeThingB() 肯定能执行到,这就要求我们函数执行必须顺序执行的,但是现实中场景不能保证这样。
3、并发情况下,通过chan类型通信
func main(){
flag := make(chan int,1)
go func (flan chan int){ //匿名函数1
if doSomeThingA() == true {
flag <- 1
}
}(flag)
go func (flan chan int) { //匿名函数2
if <-flag == 1 {
doSomeThingB()
}
}(flag)
}
make(chan int,1) 创建一个chan 类型变量, chan int 指的是chan 里面存储的类型是整型,1指的长度为1,也就只能存储一个数,chan 长度后面在说。flag<-1 表示在chan 中写入1,<-表示读。
chan类型有个特点是通道中已经存了数据,写入会阻塞,通道中没有数据,读会阻塞,根据这个特性,那么可以知道,假设执行先执行到函数2, <-flag 在通道中企图读取数据是阻塞,那么函数2阻塞,直到函数1已执行完毕,flag <- 1,通道中写入数据成功,才会执行匿名函数2。
4、防止主进程提前退出,增加sleep函数
func doSomeThingA() bool{
fmt.Println("doSomeThingA")
return true
}
func doSomeThingB(){
fmt.Println("doSomeThingB")
}
func main(){
flag := make(chan int,1)
go func (flan chan int){ //匿名函数1
if doSomeThingA() == true {
flag <- 1
}
}(flag)
go func (flan chan int) { //匿名函数2
if <-flag == 1 {
doSomeThingB()
}
}(flag)
time.Sleep(1*time.Second) //主程序sleep 1秒 等待协程执行完成
}
执行结果 doSomeThingA doSomeThingB 主进程sleep 1秒是为了等待协程执行完成,go主进程是不会等待协程执行完成会退出的。
4、实现一个并发锁
回到文字开始的交易过过程,我们来实现一个锁。
var lock chan int
func getLock(){
lock <- 1
}
func releaseLock(){
<- lock
}
func main(){
getLock()// 获取锁
count := inventory.getCount()
if count <= 0 {
panic("商品数量不足")
}else {
user.subMoneny(100)
inventory.sub(1)
}
releaseLock() //释放锁
}
在main函数外定义了 lock,然后分别在 getLock() 在lock 中写入值,最后在releaseLock() 释放锁,我们main 函数里面 并没有判断lock 中存的值,因为这不重要,我们只是利用队列阻塞的特性。是不是很简单,如果在其他程序里面实现锁那是很困难的,我们在程序中通过设置变量值来标识,那么在读取变量值之后,这个变量很有可能被其他线程或者进程修改了,那么这样做的意义就不大了。在保证并发的情况下,通过通道chan的读写阻塞特性,很容易实现锁的功能,而不用借住第三方工具比如redis,zk,etcd 等等。
4、实现一个消息队列
接下来我们聊聊队列,大家都知道,队列是先进先出的结构,一般用来做消息通信,异步削分,等等,特点是保证数据顺序。做开发的同学都知道,我们用过kafaka,redis,etcd,rabbitmq 等等工具,这些都是第三方工具。下面我们用chan 来实现一个队列。
var queue chan interface{}
func producer(i int){
queue <- i
}
func consumer(){
time.Sleep(1*time.Second)
n := <- queue
fmt.Println("消费第",n,"次")
}
func init(){
queue = make(chan interface{},10)
}
func main(){
for i := 0;i < 10; i++ {
go producer(i)
}
for i := 0;i < 10; i++ {
go consumer()
}
time.Sleep(12*time.Second)
}
分析下这段代码 var queue chan interface{} 创建 queue 渠道,存储类型可以为任何类型 在 init 方法里面queue = make(chan interface{},10) 创建了一个带缓冲区的chan,怎么理解呢?这相当于可以连续写入10次,都不会被阻塞,如果超过10次就回阻塞,直到存queue取出一个数值为止。
producer() 方法在queue中放入int型数值,consumer()方法可以从queue取出int型数值。
在main 函数里面写循环通过协程调producer用写入 0-9 十个数值,然后在接下来通过循环调用consumer() 取出数值。
下面是输出结果
- 消费第 2 次
- 消费第 1 次
- 消费第 0 次
- 消费第 4 次
- 消费第 8 次
- 消费第 6 次
- 消费第 5 次
- 消费第 7 次
- 消费第 9 次
- 消费第 3 次
首先看到并不是按照入队列顺序打印出来数值,不是说队列是有序的吗?是这样的,在queue中写入数据的时候,我们用的是go 关键字,使用协程,取数值的时候也用的是协程,协程是不能保证调用顺序的。如果要保证读取顺序,那看调整后的代码。
4、实现一个顺序的消费队列
var queue chan interface{}
func producer(i int){
queue <- i
}
func consumer(){
n := <- queue
fmt.Println("消费第",n,"次")
}
func init(){
queue = make(chan interface{},10)
}
func main(){
for i := 0;i < 10; i++ {
producer(i)
}
for i := 0;i < 10; i++ {
go consumer()
}
输出结果
- 消费第 0 次
- 消费第 1 次
- 消费第 2 次
- 消费第 3 次
- 消费第 4 次
- 消费第 5 次
- 消费第 6 次
- 消费第 7 次
- 消费第 8 次
- 消费第 9 次 分析下,在调用producer不使用协程保证了写入队列queue数值是顺序,调用consumer使用了协程,但是不管consumer执行先后,然取数逻辑都是按照队列顺序取值的。
我们设计的这个队列有长度,固定长度保证内存不会被无限占用,即使在队列存满的情况下也不会丢失数据。
总结Go语言强大的chan 类型为我们解决了业务中使用锁和队列的的场景,不需要我们依赖第三方工具。
如果觉得文章对您有帮助,请下赞,您的认可是我更新的动力,谢谢大家