GOLANG并发编程(第二阶段) | 青训营笔记

64 阅读5分钟

解决并发方法

当通过多个协程去操作同一个内存空间时,此时如果不加以处理的话,会出现并发操作同一空间的错误信息,我们应实现这些协程之间进行同步执行,一个一个地去操作这些空间;

var resMap = make(map[int]int)

// 计算阶乘
func calc(a int) {
    var tem int = 1
    for i := 1; i <= a; i++ {
       tem *= i
    }
    resMap[a] = tem
}

func main() {
    for i := 1; i < 10; i++ {
       go calc(i)
    }
    fmt.Println(resMap)
}

不出意外的话,会报下面的错:

  • 互斥锁

  •   在操作系统中,在解决多个进程互斥问题中,通过加锁的方式实现,如下图:
  •    摘自王道考研笔记

golang中,通过使用Mutex互斥锁来控制同一时刻只需要一个协程被执行,当互斥锁被放开后,才允许后面的协程执行,使用如下:

var (
    resMap = make(map[int]int)
    lock   sync.Mutex // 定义全局变量:互斥锁
)
func calc(a int) {
    lock.Lock() // 开启互斥锁,其他协程被阻塞
    var tem int = 1
    for i := 1; i <= a; i++ {
       tem *= i
    }
    resMap[a] = tem
    lock.Unlock() // 关闭锁,其他协程执行
}

func main() {
    for i := 1; i < 10; i++ {
       go calc(i)
    }
    time.Sleep(time.Second * 2)
    fmt.Println(resMap) // 正常打印
}

弊端

首先这里要声明一个问题,就是所有这里启动的协程都是会基于主线程的执行,如果主线程执行完毕之后,不论开启的协程是否执行完毕,所以的执行都会结束。所以,大家都会注意到一个地方,就是在我们的主函数中一直都有一个sleep函数来阻塞主线程的执行,这就是为了防止这里提到的问题,但是这种方式我之前也讲过只是在写demo的时候使用,同时通过这个demo我们也可以看出,我们无法去获取到开启协程什么时候才结束,只能手动去添加休眠让主线程阻塞,说明通过加互斥锁的方式,虽然可以解决进程互斥的问题,但是没有解决如何获取协程结束的时间。

channel管道

本质上是一个数据结构-队列:FIFO。它是线程安全的,多个协程访问管道不需要加锁。

channel是有类型的,存放的类型必须与声明时候指定的类型一致。

声明方式

var intChan chan int

注意:

在使用管道时,需要先通过make开辟空间,才能正常使用;

使用管道

// TODO: write your codes
var intChan = make(chan int, 1)
intChan <- 10
fmt.Println(intChan, <-intChan)
// output: 0xc000150000 10

注意

向管道添加数据或者取数据的时候,如果管道已经装满或者管道里面没有数据时,会出现死锁的现象

// TODO: write your codes
var intChan = make(chan int, 1)
intChan <- 10
//intChan <- 12 // fatal error: all goroutines are asleep - deadlock! 出现死锁
fmt.Println(intChan, <-intChan)
<-intChan // fatal error: all goroutines are asleep - deadlock! 出现死锁

遍历管道

// 循环遍历channel
var mapChan = make(chan map[string]string, 5)

map1 := make(map[string]string)
map1["userName"] = "张三"
map1["age"] = "12"
mapChan <- map1

map2 := make(map[string]string)
map2["telNum"] = "123456789"
map2["sex"] = "男"
mapChan <- map2

close(mapChan) // 这里需要关闭管道,不然还是会出现死锁的问题
// 使用内置函数close可以关闭channel,当channel关闭后,就不能再向channel写数据了,但是仍然可以从该channel读取数据,
// 在for range管道时,当遍历到最后的时候,发现由管道没有关闭,程序会认为由可能由数据继续写入,因此就会等待,如果程序没有数据写入,就会出现死锁。

// 方式1: 使用range
for v := range mapChan {
    fmt.Println(v)
}

// 方式2:使用for循环
for {
    if v, ok := <- mapChan; ok {
        // 输出v
    }

}

注意:

在遍历管道的时候,我们这里先进行了关闭管道的操作,首先我们要明确的是,关闭管道不会影响我们读取管道数据,也就是我们可以继续读取数据,但是此时是不能再进行向管道里面添加数据;除此之外,我们关闭管道之后,当我们取出所有数据之后,程序就不会再继续去读取管道数据,如果没有关闭管道,程序就会认为还会向管道中插入数据,最终导致出现死锁。

管道和协程结合使用

这里通过一个案例来实现:

  1. 通过write方法实现对一块空间数据的添加;
  2. 通过read方法实现对这块空间数据的读取;
  3. 为了更快的完成这两个任务,我们希望在写入数据的时候,可以进行读取数据,所以就要引申出协程;

分析:

在处理这个案例的时候,我们首先会开启两个协程来处理同步进行的过程,这个都没有什么问题;但是我们之前一直都有一个问题还没有解决,就是主线程和开启的其他协程的执行时长不一致,那么这里我们就通过创建一个管道来解决这个问题。如下图所示,我们创建一个exitChan管道来判断协程是否执行完毕。

代码:

// 写入数据
func write(intChan chan int) {
    for i := 0; i < 20; i++ {
       intChan <- i
       fmt.Printf("写入数据:%v\n", i)
    }
    // 记得要关闭管道,不然在后面遍历的时候就会 寄~
    close(intChan)
}

// 读取数据
func read(intChan chan int, exitChan chan bool) {
    for {
       if v, ok := <-intChan; ok {
          fmt.Printf("读取到数据:%v\n", v)
       }
    }
    exitChan <- true
    close(exitChan) // 这里也是同理
}

func main() {
    fmt.Println("开始任务")
    var intChan = make(chan int, 20)
    var exitChan = make(chan bool, 1)

    go write(intChan)
    go read(intChan, exitChan)

    if _, ok := <-exitChan; ok {
       fmt.Println("结束任务")
       return
    }
}

案例

统计2-20000000之间的素数,下面使用了两种方案,对应的耗时如下

start := time.Now().Unix()
primarySlice := make([]int, 0)
// 传统方式
for i := 2; i <= 20000000; i++ {
    if isPrimary(i) {
       primarySlice = append(primarySlice, i)
    }
}
//for _, v := range primarySlice {
//  fmt.Printf("%d\v", v)
//}

end := time.Now().Unix()
fmt.Printf("start: %d, end: %d, 最终耗时: %d\n", start, end, end-start)
// 耗时: 42
import (
    "fmt"
    "math"
    "runtime"
    "time"
)

// 统计2~2000之间的素数

// 判断是否为素数
func isPrimary(n int) bool {
    for i := 2; i <= int(math.Sqrt(float64(n))); i++ {
       if n%i == 0 {
          return false
       }
    }
    return true
}

func add(intChan chan int, num int) {
    for i := 2; i <= num; i++ {
       intChan <- i
    }
    close(intChan)
}

func getRes(intChan, resChan chan int, exitChan chan bool) {
    for {
       v, ok := <-intChan
       if !ok {
          break
       }
       if isPrimary(v) {
            resChan <- v
        }
    }
    exitChan <- true
    // 这里就不用执行关闭管道操作了,因为我们会开启多个协程来处理这些素数的,
    //要是这里就关闭了,那么其他协程就不能放内容进exitChan管道了
}

func main() {
    // 获取CPU数量
    var cNum = runtime.NumCPU()
    fmt.Println("当前系统CPU数量: ", cNum)
    // 设置程序运行时使用CPU数量
    //runtime.GOMAXPROCS(2)

    start := time.Now().Unix()

    // 创建对应的管道
    var intChan = make(chan int, 1000)
    var primaryChan = make(chan int, 2000)

    // 开启四个协程
    var exitChan = make(chan bool, 4)

    go add(intChan, 2000)

    // 开启四个协程
    var count int
    for {
       if count == 4 {
          break
       }
       count++
       fmt.Println("开启协程:", count)
       go getRes(intChan, primaryChan, exitChan)
    }

    // 开启一个协程来
    go func() {
       for i := 0; i < 4; i++ {
          <-exitChan
          fmt.Print("读取管道完毕\n")
       }
       close(primaryChan)
       end := time.Now().Unix()
       fmt.Printf("start: %d, end: %d, 最终耗时: %d\n", start, end, end-start)
    }()

    for {
       v, ok := <-primaryChan
       if !ok {
          break
       }
       fmt.Printf("素数:%d\n", v)
    }

}
// 耗时: 18秒

最后

这里总共就介绍了两种解决并发的方案,一种是使用互斥锁,另一种是使用管道,这是go的一大特色,这里也列举了一个比较典型的例子来说明了go通过开启协程来提高执行速度,通过对比发现,这速度提高的非常出色。