Go 中的“通道” | Go 主题月

1,186 阅读5分钟

距离14篇文章还差4篇,我要加油!!茶缸!!我来了!!!

前言

之前我聊到了在Go中的并发问题,但是却比开了在并发问题里面最麻烦的对资源的读写的问题,接下来这篇文章就来聊聊在Go中,面对并发问题中的读写问题,在Go语言里面是怎么解决的。

68747470733a2f2f676f6c616e672e6f72672f646f632f676f706865722f6669766579656172732e6a7067.jpeg

通道channel

在Go语言里面,有一条准则:不要通过共享内存来通讯,而应该通过通讯来共享内存,这句话其实跟我们一直接触到的例如java,php等语言不太相同,大部分时候,两个线程之间想要通讯的方式,都是通过访问一个共享的内存,来完成线程间的通讯,但是Go却提出了,通过通讯来共享内存,这其中的重点,就是通道channel

当一个资源需要共享的时候,通道通过架起一个管道,并且提供了确保同步交换数据的一些机制。

在声明一个通道的时候,首先需要指明通道中流动的数据类型,通过内置函数make来进行创建。

	a := make(chan int) // 无缓冲通道
	b := make(chan string, 10) // 有缓冲通道

上面代码可以看出,通道类型有两种,一种是有缓冲的,一种是无缓冲的,具体有什么区别,我会在后面细说。

而想要往通道里面存/取数据,则是通过<-的方式

	a := make(chan int)
	a <- 10  // 存变量
	b := <- a //取出变量

而通道有无缓冲,关键的区别,就在于存取变量的这个过程。

无缓冲通道

952033-20190607162332383-1590832521.png

无缓冲通道意思就是接受到数据的时候,不会去保存数据的一个通道,这种通道必须是发送和接受的goroutine同时同步进行才能完成,否则就会发生阻塞。这其中任何一个操作都离不开对方。

上图就展示了两个goroutine在通过通道来共享一个值的一个过程。

第一步两个goroutine之间建立起一个通道,第二步,左边的goroutine把资源放入到通道中,同时锁住左边的goroutine,第三步,右边的goroutine把手伸入通道,同时锁住右边的goroutine,第四和第五步完成资源的共享交换,第六步完成解锁。

重点注意:在两个goroutine通过无缓冲通道共享资源的时候,接受数据的goroutine是先一步阻塞在通道前的!!

为了更好的说明这个道理,我们通过一个简单的代码来说明问题。

var w sync.WaitGroup

func main() {
	count := make(chan int) //创建一个无缓冲通道

	w.Add(2) //计数器加二,使得两个goroutine可以执行完成

	go play(ball, "min")//选手一号
	go play(ball, "hong")//选手二号
	ball <- 1
	w.Wait()
}

func play(count chan int, name string) {
	defer w.Done()
	for {
		it, ok := <-count
		if !ok {
			log.Printf("game over %s win",name)
			return
		}
		n := rand.Intn(100)
		if n%15 == 0 {
			log.Printf("game over %s lose",name)
			close(count)
			return
		}
		log.Printf("round is %d",it)
		it++

		count <- it
	}
}

上面的例子中,我通过goroutine来模拟两个选手对打,你一拳然后我一拳,直到有一方倒下为止,其中规则就是在play函数内部,生成一个随机数,这个随机数除以15余数为0的时候,则关闭通道,这时候这个选手也就输了,另一个选手在收到通道已经关闭的通知的时候,也就知道自己赢了。

在这个过程中,比赛的回合数不停的累加,知道有一方倒下为止。

那么这时候输出的结果是这样的

2021/04/22 02:51:17 round is 1
2021/04/22 02:51:17 round is 2
2021/04/22 02:51:17 round is 3
2021/04/22 02:51:17 round is 4
2021/04/22 02:51:17 round is 5
2021/04/22 02:51:17 round is 6
2021/04/22 02:51:17 round is 7
2021/04/22 02:51:17 round is 8
2021/04/22 02:51:17 round is 9
2021/04/22 02:51:17 game over min lose
2021/04/22 02:51:17 game over hong win

我们可以看到,在goroutine通过通道贡献资源的过程中,一定是同步的,任意一方慢了,那另一边也就组赛了,这种情况被称作是goroutine泄露,这将会是一个bug,需要注意的是跟变量的垃圾回收不同,泄露的goroutine是不会被回收的。

接下来,我们再来看一下有缓冲的通道是怎么样的

有缓冲通道

有缓冲通道,顾名思义就是可以存储一定量的值的一个通道,它没有要求接收方和发送方必须同时准备好才可以进行,而要造成发送和接受两个goroutine的阻塞,发送的goroutine必须满足通道内的数据容量已经满了,还往里面塞数据,这时候发送方就阻塞了,而接收方则是通道里面已经没有数据了,那么这时候接收方的goroutine就会阻塞。

其实可以这么理解,这有点类似于在一个面包店在做面包一样,一个师傅负责擀面皮,一个师傅负责包包子,一个师傅负责把包子分装进笼子里,三个师傅代表着三个goroutine,包包子的师傅在做累了的话,可以休息一下,因为他和擀面皮的师傅已经分装的师傅之间都有一个缓冲区,暂时的存储着一些个原材料,而擀面皮师傅也不会因为包包子师傅停了下来,整个工序就停了,他们还可以继续工作,这也就是无缓冲通道的概念。

952033-20190607162619347-2067317996.png

就好像上图所示的一样,左右两个goroutine并不需要同时把手放进通道里面,来完成信息传递,它们之间在缓存未满之前,是非阻塞的。

同样的,这里我们也来看一个具体的例子

var w sync.WaitGroup

func main() {
	a := make(chan int , 5) //生成两个大小为5的有缓冲通道
	b := make(chan int , 5)

	w.Add(3)

	go work1(a)
	go work2(a,b)
	go work3(b)

	w.Wait()
}

func work1(a chan<- int)  {
	defer w.Done()
	for  {
		if len(a) == 5 {
			log.Print("work1 over")
			close(a)
			return
		}
		a <- rand.Int()
	}
}

func work2(a <-chan int, b chan<- int)  {
	defer w.Done()
	for  {
		time.Sleep(time.Second)
		if len(a) == 0 {
			log.Print("work2 over")
			close(b)
			return
		}
		c := <-a
		log.Print(c)
		b <- c
	}
}

func work3(b <-chan int)  {
	defer w.Done()
	for {
		if len(b)>0 {
			<-b
		}
		if _ , ok := <-b; !ok {
			log.Print("work3 over")
			return
		}
	}
}



这里就是模拟之前所说的面包师傅的例子,在中间那个面包师傅做了一个延时的操作,意思就是work2的工作效率比较慢,可以看到,当work1完成了工作之后,它就关闭了a通道,而work2则是每一秒从a通道中拿出数据,并且存入b通道中,而work3则会把b通道里面的数据排空,当b通道也关闭时,则work3也退出。

由上面的例子就很好的说明了,有缓冲通道并不会存在阻塞的问题,但是同样的,它也做不到数据的同步,就好像work2一样,如果想要work3那边可以尽快退出,唯一的方法就是在work2中加多一个goroutine。

至于是选择有缓冲的通道,还是无缓冲的通道,这就根据每个人的业务需求来决定的了。

select的多路复用

现在我们来想一下这么一个场景,我们用两个goroutine来完成🚀的倒计时发射的功能,当程序启动的时候,倒数五个数,goroutine一号会给二号goroutine发一个信号,然后goroutine会完成发射的工作,很明显我们知道这里需要用到一个无缓冲通道,而无缓冲通道如果说发送方和接收方没有同步进行的话,有一方是会发生阻塞的,也就会造成goroutine的泄漏,这不是我们想看到的。

为了解决这种问题,就需要用到多路复用的功能,也就是select

来看一下下面的代码

func main() {
	var w sync.WaitGroup
	w.Add(2)
	char := make(chan int)

	go func() {
		defer w.Done()
		t := 0
		for  {
			time.Sleep(time.Second)
			if t == 5 {
				char <- 1
				return
			}else {
				t += 1
			}
		}
	}()

	go func() {
		defer w.Done()
		for  {
			time.Sleep(time.Second)
			select {
			case <- char:
				log.Print("emission!!!")
				return
			default:
				log.Print("wait")
			}
		}
	}()
	w.Wait()
}

我们可以看到第二个goroutine里面为了避免因为通道阻塞而出现bug,通过使用select来完成相应的操作,当select存在default值的时候,它就会变成一个非阻塞的多路复用,当这个程序跑起来的时候,运行结果是这样的

2021/04/22 15:13:54 wait
2021/04/22 15:13:55 wait
2021/04/22 15:13:56 wait
2021/04/22 15:13:57 wait
2021/04/22 15:13:58 wait
2021/04/22 15:13:59 wait
2021/04/22 15:14:00 emission!!!

可以看到select会等待当有满足条件的case可以执行的时候,它会去执行它,当没有的时候,它会永远的等待下去(如果没有default的情况下),这个其实有点类似于JavaScript里面的switch,我想这样说大家应该就基本明白了。

当存在多个case同时满足条件的时候,他会随机性的挑选其中一个来执行,这样才能确保每一个case都可以公平的被执行到。

一些总结

  1. 在Go语言中——不要通过共享内存来通讯,而应该通过通讯来共享内存
  2. 无缓冲通道,接收方和发送方必须同步进行,否则会有一方出现阻塞,造成goroutine泄露
  3. 有缓冲通道在一定情况上避免上面的情况,但是它数据没有办法做到同步
  4. 多路复用select是一个有点类似于switch的东西,但是当有多个case同时满足条件的时候,它会随机选择其中一个来执行

另外有一点需要注意的:如果给一个chan传递一个nil变量的时候,会直接造成该通道的阻塞

最后的最后

我就快升级了!!!,看官大老爷如果看到了这里,求一个赞呀!!!

fgmhskqfsy.jpeg