golang的两把利器,协程和管道

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会引发并发问题,现在我们看看怎么用管道解决他

协程和管道配合解决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多平台发布

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,783评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,360评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 142,942评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,507评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,324评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,299评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,685评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,358评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,652评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,704评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,465评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,318评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,711评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,991评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,265评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,661评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,864评论 2 335

推荐阅读更多精彩内容