距离14篇文章还差4篇,我要加油!!茶缸!!我来了!!!
前言
之前我聊到了在Go中的并发问题,但是却比开了在并发问题里面最麻烦的对资源的读写的问题,接下来这篇文章就来聊聊在Go中,面对并发问题中的读写问题,在Go语言里面是怎么解决的。
通道channel
在Go语言里面,有一条准则:不要通过共享内存来通讯,而应该通过通讯来共享内存,这句话其实跟我们一直接触到的例如java,php等语言不太相同,大部分时候,两个线程之间想要通讯的方式,都是通过访问一个共享的内存,来完成线程间的通讯,但是Go却提出了,通过通讯来共享内存,这其中的重点,就是通道channel
当一个资源需要共享的时候,通道通过架起一个管道,并且提供了确保同步交换数据的一些机制。
在声明一个通道的时候,首先需要指明通道中流动的数据类型,通过内置函数make来进行创建。
a := make(chan int) // 无缓冲通道
b := make(chan string, 10) // 有缓冲通道
上面代码可以看出,通道类型有两种,一种是有缓冲的,一种是无缓冲的,具体有什么区别,我会在后面细说。
而想要往通道里面存/取数据,则是通过<-的方式
a := make(chan int)
a <- 10 // 存变量
b := <- a //取出变量
而通道有无缓冲,关键的区别,就在于存取变量的这个过程。
无缓冲通道
无缓冲通道意思就是接受到数据的时候,不会去保存数据的一个通道,这种通道必须是发送和接受的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,包包子的师傅在做累了的话,可以休息一下,因为他和擀面皮的师傅已经分装的师傅之间都有一个缓冲区,暂时的存储着一些个原材料,而擀面皮师傅也不会因为包包子师傅停了下来,整个工序就停了,他们还可以继续工作,这也就是无缓冲通道的概念。
就好像上图所示的一样,左右两个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都可以公平的被执行到。
一些总结
- 在Go语言中——不要通过共享内存来通讯,而应该通过通讯来共享内存
- 无缓冲通道,接收方和发送方必须同步进行,否则会有一方出现阻塞,造成goroutine泄露
- 有缓冲通道在一定情况上避免上面的情况,但是它数据没有办法做到同步
- 多路复用select是一个有点类似于switch的东西,但是当有多个case同时满足条件的时候,它会随机选择其中一个来执行
另外有一点需要注意的:如果给一个chan传递一个nil变量的时候,会直接造成该通道的阻塞。
最后的最后
我就快升级了!!!,看官大老爷如果看到了这里,求一个赞呀!!!