Bootstrap

Go sync 同步原语

Go 中不仅有 channel 这种 CSP 同步机制,还有 sync.Mutex、sync.WaitGroup 等比较原始的同步原语。使用它们,可以更灵活的控制数据同步和多协程并发。

  • sync.Mutex
  • sync.RWMutex
  • sync.WaitGroup
  • sync.Once
  • sync.Cond
  • sync.Map

在一个 goroutine 中,如果分配的内存没有被其他 goroutine 访问,只在该 goroutine 中被使用,不存在资源竞争的问题。但如果同一块内存被多个 goroutine 同时访问,就会不知道谁先访问,也无法预料最后结果。这就产生了资源竞争,这块内存就是共享资源。channel 是并发安全的,内部自加了锁,但是很多变量或者资源没有加锁,就需要 sync 同步原语了。

eg. 启动100个协程,让 nSum 加10,期待的结果是1000。

package main

import (
        "fmt"
        "time"
)

var nSum = 0

func add(i int) {
        nSum += i
}

func main() {
        for i := 0; i < 100; i++ {
                go add(10)
        }

        time.Sleep(time.Second)
        fmt.Println("sum=", nSum)
}

运行完之后,输出的结果可能是1000,也可能是990,或是980。

$ while true; do go run gosrc.go; done;

类似 go build、go run、go test,这种 Go 工具链命令,添加 -race 标识,帮助检查 Go 语言代码是否存在资源竞争。

$ go run -race gosrc.go

导致这种现象的原因是,资源 nSum 并不是并发安全的,因为同时会有多个协程执行 nSum += i,产生不可预料的结果。所以需要确保同时只有一个协程执行 nSum += i 操作,互斥锁可以实现。

sync.Mutex

互斥锁,是指在同一时刻只有一个协程执行某段代码,其他协程都要等待该协程执行完毕后才能继续执行。

下面的实例中,声明一个互斥锁,然后修改 add 函数,对 nSum += i 执行加锁保护,这样这段代码在并发的时候就安全了,可以得到正确的结果。

上面这段加锁保护的代码,称为临界区。在同步程序设计中,临界区指的是一个访问共享资源的程序片段,而这些共享资源又无法同时被多个协程访问的特性。当一个协程获得了锁后,其他的协程只有等待锁释放,才能再去获得锁。锁的 Lock 和 Unlock 方法总是成对的出现。

package main

import (
        "fmt"
        "sync"
        "time"
)

var (
        nSum int
        mutex sync.Mutex
)

func add(i int) {
        mutex.Lock()
        defer mutex.Unlock()
        nSum += i
        
}

func main() {
        for i := 0; i < 100; i++ {
                go add(10)
        }

        time.Sleep(2*time.Second)
        fmt.Println("nSum=", nSum)
}

运行结果如下,

$ count=0;while (($count < 10)); do go run gomutex.go;((count=$count+1)); done

sync.RWMutex

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在 Go 语言中使用 sync.RWMutex 类型。

读写锁分为两种:读锁和写锁。当一个 goroutine 获取读锁之后,其他 goroutine 如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个 goroutine 获取写锁之后,其他 goroutine 无论是获取读锁还是写锁都会等待。

这里有一个性能问题,每次读写共享资源都要加锁,性能低下,怎么解决?现在分析这个特殊的场景,会有以下三种情况,写的时候不能同时读(读未提交读的时候不能同时写(读已提交读的时候可以同时读(可重复读

  1. 可能读到脏数据,脏读
  2. 会产生不可预料的结果,幻读
  3. 不管多少协程读,都是并发安全的,可重复读。

可以通过读写锁提升性能,对比互斥锁,读写锁改动有两个地方,

  • 把锁的声明换成读写锁 RWMutex
  • 把读取数据的代码(函数 readSum)换成读锁

这样性能有很大提升,多个协程可以同时读取数据,不用相互等待。

 sync.WaitGroup

用于最终完成的场景,关键点在于一定是等待所有协程都执行完毕。

在前面的程序里边,为了防止主函数返回,使用了 time.Sleep 语句强制程序睡眠,因为一旦 main goroutine 返回,函数就退出了。

但这里是有问题的。如果这100个协程在两秒内执行完毕,main 函数本该提前返回,但是还是要等够两秒才能返回,存在性能问题。如果执行超过2秒,函数返回,有些协程不会执行,产生不可预知的结果。

有没有办法监听所有 goroutine 的执行?一旦全部执行完毕,程序马上退出,既可以保证所有协程执行完毕,又可以及时退出节省时间,提升性能。

通道 channel 可以实现,但比较复杂。所以,Go 提供了 WaitGroup。对上面的例子代码进行改造,分三步执行,

  1. 声明一个 WaitGroup,通过 Add 方法设置一个计数器的值,需要跟踪多少协程就设置多少。
  2. 每个协程在执行完毕的时候,一定要调 Done 方法,让计数器减1,告诉 WaitGroup 该协程已经执行完毕。
  3. 最后调用 Wait 方法,一直等待,直到计数器的值变为0,也就是所有跟踪的协程执行完毕了。

通过 WaitGroup 可以很好地跟踪协程,在协程执行完毕后,整个 main 函数才能执行完毕。

package main

import (
        "fmt"
        "sync"
)

var (
        nSum int
        mutex sync.RWMutex
)

func add(i int) {
        mutex.Lock()
        defer mutex.Unlock()
        nSum += i
}

func main() {
        var wg sync.WaitGroup
        wg.Add(100)
        for i := 0; i < 100; i++ {
                go func() {
                        defer wg.Done()
                        add(10)
                }()
        }


        wg.Wait()
        fmt.Println("nSum=", nSum)
}

运行结果,会发现输出执行速度方面会清爽很多。

sync.WaitGroup适合协调多个goroutine共同做一件事情的场景。比如下载较大的文件时,为了加快下载速度,我们会使用多线程(协程)下载。假设使用10个协程,每个协程下载文件的1/10大小,只有10个协程都下载好了整个文件才算是下载好了。再比如流水线上,下个阶段需要上个阶段把所有数据准备好,10个协程准备数据,等所有协程处理完后,统一进入下个阶段继续执行.....

sync.Once

让代码只执行一次,哪怕是在高并发的情况下,比如创建一个单例。

先看个例子

package main

import (
        "fmt"
        "sync"
)

func main() {
        var once sync.Once

        onceBody := func() {
                fmt.Println("Only once")
        }

        done := make(chan bool)        // 用于等待协程执行完毕
        for i := 0; i < 10; i++ {        // 启动 10 个协程
                go func(n int) {
                        fmt.Println(n)
                        once.Do(onceBody)
                        done<-true
                }(i)
        }

        for i := 0; i < 10; i++ {
                <-done
        }
}

运行结果如下,

使用 WaitGroup 来保证子协程执行完毕,也可以这样写, 

package main

import (
        "fmt"
        "sync"
)

func main() {
        var once sync.Once

        onceBody := func() {
                fmt.Println("Only once")
        }

        var wg sync.WaitGroup
        wg.Add(10)
        for i := 0; i < 10; i++ {
                go func(n int) {
                        fmt.Println(n)
                        once.Do(onceBody)
                        wg.Done()
                }(i)
        }

        wg.Wait()
}

sync.Cond

可以用做发令枪,关键点在于 goroutine 开始的时候是等待的。Cond 一声令下,所有 goroutine 都开始执行。sync.Cond 从字面意思看是条件变量,除此之外,还具有阻塞和唤醒协程的功能,所以可以在满足一定条件的情况下唤醒协程。

sync.Cond有三个方法,

  • Wait,阻塞当前协程,直到其他协程调用signal或broadcast来唤醒,使用时需要加锁
  • Signal,唤醒一个等待时间最长的协程
  • Broadcast就是广播,唤醒所有等待的协程

注意,在调用 Signal 或者 Broadcast 之前,一定要确保目标协程要处于等待 Wait 阻塞状态,不然会出现死锁问题。和 java 里边的 wait、notify、notifyall 类似。

package main

import (
        "fmt"
        "sync"
        "time"
)

func main() {
        cond := sync.NewCond(&sync.Mutex{})
        var wg sync.WaitGroup
        wg.Add(11)
        for i := 0; i < 10; i++ {
                go func(n int) {
                        defer wg.Done()
                        fmt.Println("ready", n)
                        cond.L.Lock()
                        cond.Wait()
                        fmt.Println("go", n)
                        cond.L.Unlock()
                }(i)
        }

        time.Sleep(time.Second)
        go func() {
                defer wg.Done()
                fmt.Println("beng beng...")    // 发令枪响
                cond.Broadcast()
        }()

        wg.Wait()
}

运行结果如下,

sync.Map

Go 中的 map 类型是并发不安全的,在实际开发中,这种类型不能用在并发写的场景,并发读还是可以的。不过 slice 是并发安全的,有时候可以使用 slice 来代替 map,但需要迭代元素进行转换。这时 sync.Map 也是一个不错的选择。

  • Store,存储一对 kv;
  • Load,根据 key 获取对应的 value,并可以判断 key 是否存在;
  • LoadOrStore,如果 key 对应的 value 存在,则返回 value;否则存储相应的value;
  • Delete,删除一对 kv;
  • Range,循环迭代 sync.Map,效果与 for range 一样。

Have Fun

;