[ 后端与sync同步原语| 青训营笔记]

78 阅读4分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 12 天

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

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

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

sync.Mutex

互斥锁,是指在同一时刻只有一个协程执行某段代码,其他协程都要等待该协程执行完毕后才能继续执行。 下面的实例中,声明一个互斥锁,然后修改 add 函数,对 nSum += i 执行加锁保护,这样这段代码在并发的时候就安全了,可以得到正确的结果。

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

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

sync.RWMutex

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在 Go 语言中使用 sync.RWMutex 类型。 读写锁分为两种:读锁和写锁。当一个 goroutine 获取读锁之后,其他 goroutine 如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个 goroutine 获取写锁之后,其他 goroutine 无论是获取读锁还是写锁都会等待。 这里有一个性能问题,每次读写共享资源都要加锁,性能低下,怎么解决?现在分析这个特殊的场景,会有以下三种情况,

  • 写的时候不能同时读(读未提交)
  • 读的时候不能同时写(读已提交)
  • 读的时候可以同时读(可重复读) 可能读到脏数据,脏读 会产生不可预料的结果,幻读 不管多少协程读,都是并发安全的,可重复读。 可以通过读写锁提升性能,对比互斥锁,读写锁改动有两个地方
  • 把锁的声明换成读写锁 RWMutex
  • 把读取数据的代码换成读锁

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

var (
    nSum int
    mutex sync.RWMutex
)
func add(i int){
    mutex.Lock()
    defer mutex.Unlock()
    nSum +=i
}
func readsum() int {
    mutex.Rlock()
    mutex.RUnlock()
    b:=nSum
    return b
}

sync.WaitGroup

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

package main

import (
    "sync"
    "fmt"
)
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.Once

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

package main

import (
    "sync"
    "fmt"
)
func main(){
    var once sync.Once
    onceBody :=func(){
        fmt.Println("once!")
    }
    done:=make(chan bool)
    for i:=0;i<10;i++{
        go func(n int){
            fmt.Println(n)
            once.Do(onceBody)
            done<-true
        }(i)
    }
    for i:=0;i<10;i++{
        done<-true
    }
}

sync.Cond

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

sync.Cond有三个方法,

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

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 一样。