面试官:实现协程同步有哪些方式?

4,107 阅读6分钟

「第9期」 距离大叔的80期小目标还有71期,今天大叔要跟大家分享的内容是 —— golang中实现协程同步的几种方式,没错,依旧是基础,夯实基础永远都不会过时,另外是不是觉得这个题目在面试中比较熟悉呢?接下来一起来了解一下吧。

为什么要做同步

在进入正题前,我们先习惯性地摸着良心问问自(ji)己 (ji) 个:为什么要做同步处理?

假设现在有多个协程并发访问操作同一块内存中的数据,那么可能上一纳秒第一个协程刚把数据从寄存器拷贝到内存,第二个协程马上又把此数据用它修改的值给覆盖了,这样共享数据变量会乱套。

举个栗子:

package main

import(
    "fmt"
    "time"
)

var share_cnt uint64 = 0

func incrShareCnt() {
    for i:=0; i < 10000; i++ {
        share_cnt++
    }
}

func main()  {

    for i:=0; i < 2; i++ {
        go incrShareCnt()
    }

    time.Sleep(10*time.Second)
    
    fmt.Println(share_cnt)
}

上面代码用2个协程序并发各自增一个全局变量1000000 次,我们来看一下打印输出的结果:

dashu@dashu > /data1/htdocs/go_practice > go run test.go
1014184
dashu@dashu > /data1/htdocs/go_practice > go run test.go
1026029
dashu@dashu > /data1/htdocs/go_practice > go run test.go
19630
...

从打印结果我们可以看到,虽然代码中我们对一个全局变量自增了20000次,但是没有一次打印输出20000的结果,原因就是因为协程间共享数据时发生了数据覆盖。实际上面的代码无聊sleep多就久都不会打印输出20000。

协程同步方法

那么,如何才能让数据在goroutine之间达到同步呢?下面跟大家分享以下三种数据同步的方式:

  • time.Sleep
  • channel
  • sync.WaitGroup

time.Sleep

为什么sleep可以用来实现数据同步呢?我们看个栗子:

func main()  {
    go func() {
        fmt.Println("goroutine1")
    }()

    go func() {
        fmt.Println("goroutine2")
    }()
}

执行上面那段代码你会发现没有任何输出,原因是:主协程在两个协程还没执行完就已经结束了,而主协程结束时会结束所有其他协程, 所以导致代码运行的结果什么都没有。

我们在主协程结束前 sleep 一段时间就 可能出现 了结果:

func main()  {
    go func() {
        fmt.Println("goroutine1")
    }()

    go func() {
        fmt.Println("goroutine2")
    }()

    time.Sleep(time.Second)
}

打印输出:

goroutine1
goroutine2

为什么上面我要说 “可能会出现” 呢?上面代码中我们设置了睡眠时间为1s,由于协程的处理逻辑比较简单,所以能正常打印输出上面结果;如果我这两个协程里面执行了很复杂的逻辑操作(时间大于 1s),那么就会发现依旧也是无结果打印出来的。

所以又一个问题来了:我们无法确定需要睡眠多久

看来这sleep着实不靠谱,有没有什么办法来代替sleep呢?答案肯定是有的,我们来看第二种方法。

channel(信道)

channel是如何实现goroutine同步的呢?我们再看个典型的栗子:channel实现简单的生产者和消费者

package main

import (
    "fmt"
    "time"
)

func producer(ch chan int, count int) {
    for i := 1; i <= count; i++ {
        fmt.Println("大妈做第", i, "个面包")
        ch <- i
        
        // 睡眠一下,可以让整个生产消费看得更清晰点
        time.Sleep(time.Second * time.Duration(1))
    }
}

func consumer(ch chan int, count int) {
    for v := range ch {
        fmt.Println("大叔吃了第", v, "个面包")
        count--
        if count == 0 {
            fmt.Println("没面包了,大叔也饱了")
            close(ch)
        }
    }
}

func main() {
    ch := make(chan int)
    count := 5
    go producer(ch, count)
    consumer(ch, count)
}

上面代码中,我们另外起了个 goroutine 让大妈来生产5个面包(实际就是往channel中写数据),主 goroutine 让大叔不断吃面包(从channel中读数据)。我们来看一下输出结果:

大妈做第 1 个面包
大叔吃了第 1 个面包
大妈做第 2 个面包
大叔吃了第 2 个面包
大妈做第 3 个面包
大叔吃了第 3 个面包
大妈做第 4 个面包
大叔吃了第 4 个面包
大妈做第 5 个面包
大叔吃了第 5 个面包
没面包了,大叔也饱了

从输出结果我们可以看到,大妈一共做了5个面包,大叔一共吃了5个面包,同步上了!

Tip

上面代码,我们用 for-range 来读取 channel的数据,for-range 是一个很有特色的语句,有以下特点:

  • 如果 channel 已经被关闭,它还是会继续执行,直到所有值被取完,然后退出执行
  • 如果通道没有关闭,但是channel没有可读取的数据,它则会阻塞在 range 这句位置,直到被唤醒。
  • 如果 channel 是 nil,那么同样符合我们上面说的的原则,读取会被阻塞,也就是会一直阻塞在 range 位置。

我们来验证一下,我们把上面代码中的 close(ch) 移到主协程中试试:

package main

import (
    "fmt"
    "time"
)

func producer(ch chan int, count int) {
    for i := 1; i <= count; i++ {
        fmt.Println("大妈做第", i, "个面包")
        ch <- i
        
        // 睡眠一下,可以让整个生产消费看得更清晰点
        time.Sleep(time.Second * time.Duration(1))
    }
}

func consumer(ch chan int, count int) {
    for v := range ch {
        fmt.Println("大叔吃了第", v, "个面包")
        count--
        if count == 0 {
            fmt.Println("没面包了,大叔也饱了")
        }
    }
}

func main() {
    ch := make(chan int)
    count := 5
    go producer(ch, count)
    consumer(ch, count)
    close(ch)
}

打印输出:

大妈做第 1 个面包
大叔吃了第 1 个面包
大妈做第 2 个面包
大叔吃了第 2 个面包
大妈做第 3 个面包
大叔吃了第 3 个面包
大妈做第 4 个面包
大叔吃了第 4 个面包
大妈做第 5 个面包
大叔吃了第 5 个面包
没面包了,大叔也饱了
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.consumer(0xc00008c060, 0x0)
 /data1/htdocs/go_project/src/github.com/cnyygj/go_practice/test.go:19 +0x5f
main.main()
 /data1/htdocs/go_project/src/github.com/cnyygj/go_practice/test.go:32 +0x7c
exit status 2

果然阻塞掉了,最终形成了死锁,抛出异常了。

sync.WaitGroup

如果你觉的上面两种方法还不过瘾,接下来我们再看个方法:sync.WaitGroup

WaitGroup 内部实现了一个计数器,用来记录未完成的操作个数,它提供了三个方法:

  • Add() 用来添加计数
  • Done() 用来在操作结束时调用,使计数减一 【我不会告诉你 Done() 方法的实现其实就是调用 Add(-1)】
  • Wait() 用来等待所有的操作结束,即计数变为 0,该函数会在计数不为 0 时等待,在计数为 0 时立即返回

还是看栗子:

func main()  {
    var wg sync.WaitGroup
    wg.Add(2) // 因为有两个动作,所以增加2个计数

    go func() {
        fmt.Println("Goroutine 1")
        wg.Done() // 操作完成,减少一个计数
    }()

    go func() {
        fmt.Println("Goroutine 2")
        wg.Done() // 操作完成,减少一个计数
    }()

    wg.Wait() // 等待,直到计数为0

}

打印输出:

Goroutine 1
Goroutine 2

以上就是今天要跟大家分享的内容,欢迎留言交流~

关注公众号 「大叔说码」,获取更多干货,下期见~