Go语言并发 | 青训营笔记

67 阅读6分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第二天

一、本堂课重点内容:

  • 高并发操作

二、详细知识点介绍:

高并发操作

协程(goroutine)

在Go语言的高并发优势上,我们不得不提到一个重要的概念——协程

大家都知道,一个程序执行的最小单位是线程

那么与线程拥有着一字之差的协程又是什么呢?

协程又可以称为用户态线程,平常的线程通常都是CPU内核级别的内核态线程

这两者主要的区别在于所需的资源不一样,一般的不同的进程或是线程之间切换的时候,操作系统的CPU就会进行上下文切换,具体细节在此不表,当一个任务有着许多进程或是线程数时,CPU的资源基本上都浪费在了上下文切换上了,这就很容易产生效率问题。

而Go语言中的协程,在我的理解里,它可能是一个“更小”线程,因为很多个协程可以放在同一个线程里,所以就不会有CPU上下文切换的问题。

OK!熟悉完协程这一概念我们来谈一谈具体实现:

func helloA(i int)  {
   println("goroutine:"+fmt.Sprint(i))
}

func main() {
   for i := 0; i < 5; i++ {
      go helloA(i+1)
   }
   time.Sleep(time.Second)//这里main主线程休息一秒
}
/*
运行结果:(每一次都可能不一样)
goroutine:2
goroutine:1
goroutine:4
goroutine:5
goroutine:3
*/

实现协程的操作十分方便,只需要在自己想要进行协程操作的函数前加上go就行了

当然,在代码中的time.Sleep(time.Second)是非常粗鲁的

这里给不懂的解释一下为啥主线程要停一下,因为你在进行协程操作的时候,是有许多的协程挂着的,这边主线如果不等一下那边协程全部处理完就结束的话,就不会有结果输出来的,因为程序已经结束了,调用那些操作更是没有进行。

当然我们不能遇事不决就睡大觉(Sleep操作),Go中有着一个更好的解决方法。

WaitGroup

WaitGroup操作便是可以解决刚刚那个问题的方法之一

我们可以先猜一下,它有可能是怎么解决这个问题的?

我们现在问题的痛点在于在那边挂载的协程尚未解决的时候主线程就已经结束了

但是等一个固定的秒数又略显粗鲁,因为这个时间是固定的,但是协程那边结束的时间大功率都是随机的,所以要么会造成时间浪费,要么会时间不够又一次提前结束。

要是我能监视每一个协程的状态,当所有协程都结束的时候我刚好也结束主线程不就好了!

这个就是WaitGroup的基本思路了。在WaitGroup中会有一个计数器来专门监视当前还有多少协程没有结束

我们经常用的函数有三个:

  • Add(delta int) //计数器+delta
  • Done() //计数器-1
  • Wait() //阻塞至计数器为0

所以,我们可以对上面的代码小改一下变得优雅一点

var wg sync.WaitGroup//创建WaitGroup全局变量
func helloA(i int)  {
   println("goroutine:"+fmt.Sprint(i))
   wg.Done()//每用完输出语句计数器减一
}

func main() {
   //wg.Add(5)
   for i := 0; i < 5; i++ {
      wg.Add(1)//每调用一次,计数器加一
      go helloA(i+1)
      wg.Wait()//直到计数器归零才结束
   }
   //当然wg.Wait()也可以写在这,运行结果不一样
}
/*运行结果:
goroutine:1
goroutine:2
goroutine:3
goroutine:4
goroutine:5
*/

当然,上面的示例代码这么写跟脱裤子放屁没区别

正确能体现协程优势的写法应该是像注释的那样写, 我单纯是为了运行结果好看 小问题

既然讲到了多协程操作自然就避不开各个协程之间如何通信的问题,Go中也有对应的解决方案

通道(channel)

在日常开发场景中,我们不可能一直像刚刚的代码一样

把所有业务或者是数据处理操作都放在一个协程里面全都处理完

不同协程之间是需要交流通信的

协程之间交流通信的方式有两种:

1、通过通信共享内存(图左) 2、通过共享内存实现通信(图右)

image.png

我们一般都提倡通过通信共享内存而不是通过共享内存来实现通信(Go社区经典语句)

至于为啥这么说呢?

因为如果要通过共享内存来实现通信的话呢,就得整一把互斥锁,还得进行许多为了线程同步的操作,而线程同步用起来费力又容易造成死锁,并且过多的锁会导致线程的阻塞以及CPU进行频繁的上下文切换操作,而且不同协程之间也会出现“数据竞赛”的问题,这样下来效率是会不行的。

所以,我们建议通过通信共享内存,具体代码如下:

package main

var src = make(chan int)
var dest = make(chan int ,3)

func A()  {
   defer close(src)//defer是Go中的延迟执行语句   close()则是channel中的关闭方法 
   for i := 0; i < 10; i++ {
      src <- i
   }
   //close(src)  这个是去掉defer的写法
}

func B()  {
   defer close(dest)
   for i := range src {
      dest <- i*i
   }
}

func main() {
   go A()
   go B()
   for i := range dest {
      println(i)
   }
}
/*
运行结果:
0
1
4
9
16
25
36
49
64
81
*/

通道(channel)创建有两个参数,

一个来规定channel里数据的类型,一个用来规定缓冲区的大小(选填)

如果没有规定缓冲区的话,channel默认是阻塞的,也就是说只有sender和receiver都准备好了后它们的通讯(communication)才会发生(Blocking)。如果设置了缓存,就有可能不发生阻塞, 只有buffer满了后 send才会阻塞, 而只有缓存空了后receive才会阻塞。一个nil channel不会通信。

OK!接下来演示一下通过共享内存实现通信:

package main

import "sync"

var wg sync.WaitGroup
var (
   x int64
   lock sync.Mutex    //加上一把锁
)
func A()  {           //没有上锁
   for i := 0; i < 2000; i++ {
      x+=1
   }
   wg.Done()
}

func B()  {           //上锁操作
   for i := 0; i < 2000; i++ {
      lock.Lock()
      x+=1
      lock.Unlock()
   }
   wg.Done()
}

func main() {
   wg.Add(5)
   x=0
   for i := 0; i < 5; i++ {
      go A()
   }
   wg.Wait()//对线程同步的操作
   println(x)
   wg.Add(5)
   x=0
   for i := 0; i < 5; i++ {
      go B()
   }
   wg.Wait()
   println(x)
}
/*
运行结果:(第一行结果不一定)
8234
10000
*/

这个就是通过共享内存实现通信啦,跟平时操作全局变量差不多,就是多了锁而已

从代码实现直观来看,我都觉得第一个用channel远远比第二个用共享内存的要舒服的多。

三、课后个人总结:

OK,今天主要是学习了go语言中高并发场景下的一系列操作

重点还是围绕着协程(goroutine)来讲的

在go中用于处理多线程问题的工具一般都是用协程来做,同时还介绍了线程如何同步的解决方案,即WaitGroup,通过计数器来监视协程的状态,从而达到同步的目的

在了解到不同协程通信的时候又介绍了两种处理办法,即通过通信共享内存、通过共享内存实现通信。 同样的也做了相应的代码展示,也解释了为何提倡通过通信共享内存而不是通过共享内存实现通信,并且演示了通道(channel)是如何操作的

以上就是今日所学啦!