关于后端:golang的两把利器协程和管道

3次阅读

共计 5948 个字符,预计需要花费 15 分钟才能阅读完成。

golang 的协程置信大家都不生疏,在 golang 中的应用也很简略,只有加上一个关键字 go 即可,尽管说大家都晓得,然而真的在理论应用中又遇到这样那样的问题,坑其实还是挺多的。而网上很多文章和教程,要么就是讲的太简略,给你简略介绍一下协程和管道的应用,点到为止 ,要么就上来给你撸 GPM 模型,看的人 一脸懵逼 ,所以我以 理论应用过程中遇到的问题 这个角度登程, 可能会分多篇总结一下 golang 的协程相干的知识点,心愿对你有用,如果感觉还不错,记得点个赞,点个关注。

ps:如果你素来没有理解过 golang 的协程,倡议先本人搜一些材料简略的理解一下,还有并发并行那些根底概念之类的,本文都不会提及。

协程非常容易引发并发问题

咱们先看下列程序

func main() {res := make(map[int]int)
    for i := 0; i < 100; i++ {go handleMap(res)
    }
    time.Sleep(time.Second * 1)

}
func handleMap(res map[int]int) {
    for i := 0; i < 200; i++ {res[i] = i * i
    }
}
  • 因为 map 类型作为参数是间接以援用的形式传递的,所以 handleMap 函数不须要返回值,间接操作参数 res 即可
  • handleMap 的作用就是一直的给 map 赋值
  • 因为执行 handleMap 的时候是开启协程的,所以是多个程序并发的去对 res(map 类型写入),所以上述程序是会报错的,输入后果如下
  • 程序下方加上 time.Sleep(time.Second * 1)的起因是因为主程序 (main) 执行结束退出,然而协程还没执行结束会被间接敞开。

    fatal error: concurrent map writes
    
    goroutine 48 [running]:
    runtime.throw(0x100f814d1, 0x15)
          /opt/homebrew/Cellar/go@1.16/1.16.13/libexec/src/runtime/panic.go:1117 +0x54 fp=0x14000145f50 sp=0x14000145f20 pc=0x100f16f34
    runtime.mapassign_fast64(0x100faeae0, 0x14000106180, 0x1f, 0x14000072200)
          /opt/homebrew/Cellar/go@1.16/1.16.13/libexec/src/runtime/map_fast64.go:176 +0x2f8 fp=0x14000145f90 sp=0x14000145f50 pc=0x100ef7188
    main.handleMap(0x14000106180)
          /Users/test/Sites/beikego/test/rountine.go:22 +0x44 fp=0x14000145fd0 sp=0x14000145f90 pc=0x100f7e644
    runtime.goexit()
    

    解决形式(1) 加锁

    如果有并发问题,咱们最容易想到的一个方法就是加锁

    func main() {res := make(map[int]int)
      for i := 0; i < 100000; i++ {go handleMap(res)
      }
    
      time.Sleep(time.Second * 1)
      lock.Lock()  // 因为对 map 的读取的时候有可能还在写入,所以这里也须要加锁
      for _, item := range res {fmt.Println(item)
      }
      lock.Unlock()}
    func handleMap(res map[int]int) {lock.Lock()  // 每一个协程过去申请都先加锁
      for i := 0; i < 2000; i++ {res[i] = i * i
      }
      lock.Unlock()  // 解决完 map 之后开释锁}
    

    下面过程我画了一张图,具体哪里为什么加锁都有阐明

  • 上述例子尽管开启了 100000 个协程,然而在每个协程解决 map 的时候加上了一个 lock,处理完毕才开释,所以 各个协程对 map 的操作是隔离开的
  • 在读取 map 的时候加锁的起因,是因为 sleep 1s 之后,有可能 map 还在写入,边读边写当然会有并发问题
    上述形式尽管解决了并发问题,然而也存在肯定的问题。次要是 须要 sleep,而且 sleep 多长时间没法确定
    所以这里引入咱们的解决形式 2,管道

    解决形式(2)管道 channel

channel 实质就是一个 数据结构,队列 。既然是队列,当然有着 先进先出 的准则, 而且是能保障 线程平安 的, 多个 gorountine 拜访不须要加锁。

当然如果你还没有接触过管道,能够提前找些材料理解一下,上面是一个管道的简略示意图

管道在应用的过程中须要留神的问题

管道 (channel) 在应用的过程中有很多须要留神的点,我在这里列一下

应用管道之前必须 make 一下,而且指定长度

  var intChan chan int
    intChan <- 1
    fmt.Println(<-intChan)
  // 返回信息
  fatal error: all goroutines are asleep - deadlock!
  goroutine 1 [chan send (nil chan)]:

为什么须要 make,后面文章曾经讲过,能够看看,
聊聊 golang 的 make 和 new 函数
指定长度也很好了解,管道的实质是队列, 队列当然是须要指定长度的

管道写入的数据数如果超过管道长度,会报错

  intChan := make(chan int, 1)  // 长度为 1
    intChan <- 1
    intChan <- 2  // 这里会报错
    fmt.Println(<-intChan)
  // 返回后果
  fatal error: all goroutines are asleep - deadlock!
  goroutine 1 [chan send]:

读取空管道,会报错

intChan := make(chan int, 1)
fmt.Println(<-intChan)  // 此时管道外面还没有任何内容
// 返回后果
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:

管道也反对 interface,然而拿到构造体具体的属性的时候,须要断言

type Person struct {Name string}
func main(){personChan := make(chan interface{}, 10)
      personChan <- Person{Name: "小饭"}  // 写入构造体类型
      personChan <- 1  // 写入 int 类型
      personChan <- "test_string"  // 写入 string 类型
      fmt.Println(<-personChan, <-personChan, <-personChan)
}
  // 返回后果
  {小饭} 1 test_string

下面例子咱们能够看到,如果管道定义为 interface 类型,任何类型的数据都是能够写入并且失常取出的,然而咱们写入 构造体类型 之后,如果想取出构造体的 具体属性,则须要断言

type Person struct {Name string}
func main() {personChan := make(chan interface{}, 10)
    personChan <- Person{Name: "小饭"}
    person := <-personChan  // 取出构造体之后,此时还不晓得是什么类型,所以没法间接取属性,因为定义的是 interface
    per := person.(Person)  // 对取出后果进行断言
    fmt.Println(per.Name)
}
// 返回后果
小饭

管道是能够循环的,然而循环之前必须敞开,敞开之后不可写入任何数据

  personChan := make(chan int, 10)
    personChan <- 1
    personChan <- 2
    personChan <- 3
    close(personChan) // 敞开之后管道不能写入任何数据,否则就会报 panic: send on closed channel
    for item := range personChan {  // 在 for range 循环管道之前必须敞开管道,否则会报  fatal error: all goroutines are asleep - deadlock!
        fmt.Println(item)
    }
  • 其实为什么循环之前须要敞开管道,很好了解,因为 for rang 循环能够简略了解为一个死循环,当管道数据读取完了之后会持续读取,相似于读取一个空管道,当然会报错
  • 管道敞开之后不能写入更好了解,一个对象销毁了还能去赋值么?一样的情理

    切忌不要尝试用 for(i:=0;i<len(chan):i++)的形式去循环

    这个很好了解,我就不必代码演示了,因为每次从管道中取一个数据,len(chan)是变动的,所以这么取数据必定是有问题的。换句话说也就是 不要轻易用 len(chan), 坑很多

    协程和管道的综合应用

    咱们后面抛出的问题是,开启协程操作 map 会引发并发问题,当初咱们看看怎么用管道解决他

  • 留神这里用到了 两个管道,管道chan map 是用于 map 的读写用的exitChan 是用于通知 main 函数能够退出用的
  • 首先开启一个 writeMap 的协程,把 map 数据都写入到管道 (chan map) 中,须要留神的是数据写完之后须要把协程敞开掉
  • 在开启一个 readMap 的协程,把管道中 (chan map) 数据一个一个的读出来.
  • 当 readMap 把数据全副读取实现中后,给 main 函数发送一个信号(也就是往 exitChan 中写一条数据)
  • main 函数监听 exitChan,收到数据间接退出即可。

    var chanMap chan map[int]int
    var exitChan chan int
    
    func main() {
      size := 50000
      chanMap := make(chan map[int]int, size)  
      exitChan := make(chan int, 1)
      go WriteMap(chanMap, size)  // 开启写 map 协程
      go ReadMap(chanMap, exitChan) // 开启读 map 协程
      for {
          exit := <-exitChan  // 监听 exitChan 收到信号间接 return 即可
          if exit != 0 {return}
      }
    }
    
    // 写 map 数据
    func WriteMap(chanMap chan map[int]int, size int) {
      for i := 1; i <= size; i++ {temp := make(map[int]int, 1)
          temp[i] = i
          chanMap <- temp
          fmt.Println("写入数据:", temp)
      }
      close(chanMap)  // 留神数据写完须要敞开管道
    }
    // 读 map 数据
    func ReadMap(chanMap chan map[int]int, exitChan chan int) {
      for {
          val, ok := <-chanMap
          if !ok {break}
          fmt.Println("读取到:", val)
      }
      exitChan <- 1  // 数据读取结束告诉 main 函数可退出
    }

    协程和管道到底能晋升多高的效率?

    咱们用协程的目标就是想进步程序的运行效率,管道能够简略了解为是帮助协程一起应用的,然而效率到底能晋升多少呢?咱们一起来看一看。

    判断素数

    大家都晓得,判断素数的复杂度是 N²,比较慢,咱们先看一看传统的一个一个的去判断须要多长时间

    判断 100000 以内的数字哪些是素数
    func CheckPrime(num int) bool {  // 判断一个数字是否是素数
      res := true
      for i := 2; i < num; i++ {
          if num%i == 0 {res = false}
      }
      return res
    }
    
    
    func main(){t := time.Now()
      size := 100000
    
      for i := 0; i < size; i++ {if CheckPrime(i) {fmt.Println(i, "是素数")
          }
      }
      elapsed := time.Since(t)
    
      fmt.Println("app elapsed:", elapsed)
      return
    }

    上述程序运行了 3.33 秒多,看来还是比较慢的

接下来咱们用协程和管道的形式看看,还是老规矩,咱们先看看流程图

  • 先把每个须要判断的数字写入 initChan
  • 开启 多个协程拉取 initChan 的数据一个一个的判断, 这一步是程序速度放慢的要害,如果不是素数,不解决即可,如果是素数,就写入 PrimeChan,判断完之后写入 exitChan,告诉主程序即可
  • 主程序监听 primeChan 并输入,同时监听 exitChan,收到信号退出即可
// 初始化,把须要被判断的数字写入 initChan
func initChan(intChan chan int, size int) {
    for i := 1; i <= size; i++ {intChan <- i}
    close(intChan)
}
// 读取 initChan 中的数据,一个一个的判断,如果是素数,就写入 PrimeChan,并且写入 exitChan

func CheckPrimeChan(intChan, primeChan chan int, exitChan chan bool) {
    for {
        num, ok := <-intChan
        if !ok {break}
        if CheckPrime(num) {primeChan <- num}
    }
    exitChan <- true

}

func main() {t := time.Now() 
    size := 100000
    intChan := make(chan int, size)
    primeChan := make(chan int, size)
    exitChan := make(chan bool, 1)
    go initChan(intChan, size) // 初始化 initChan
    checkChannelNum := 8
    for i := 0; i < checkChannelNum; i++ {  // 开启 8 个协程同时拉取 initChan 的数据并判断是否是素数
        go CheckPrimeChan(intChan, primeChan, exitChan)
    }
    go func() {
        for i := 0; i < checkChannelNum; i++ {<-exitChan}
        close(primeChan)

    }()

    for {
        value, ok := <-primeChan
        if !ok {break}
        fmt.Println(value, "是素数")
    }
    elapsed := time.Since(t)

    fmt.Println("app elapsed:", elapsed)
}
  // 程序执行耗费工夫
  848.455084m

上述程序执行工夫为 848.455084ms, 是传统的形式的工夫的 四分之一,可见协程在进步运行效率这块的作用还是不言而喻的

本文由 mdnice 多平台公布

正文完
 0