go语言学习 chan黑科技

409 阅读8分钟

做为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 类型为我们解决了业务中使用锁和队列的的场景,不需要我们依赖第三方工具。

如果觉得文章对您有帮助,请下赞,您的认可是我更新的动力,谢谢大家