【Go同步原语】

在Go语言中,不仅有channel这类比较易用且高级的同步机制,还有sync.Mutex 、sync.WaitGroup等比较原始的同步机制。通过它们,我们可以更加灵活的控制数据的同步和多协程的并发。

资源竞争

​ 在一个goroutine中,如果分配的内存没有被其他goroutine访问,之后在该goroutine中是哟和哪个,那么不存在资源竞争问题。但如果同一块内容被多个goroutine同时访问,就会产生不知道谁先访问也就无法预料结果。这就是资源竞争,这块内存也可以称为共享资源

// 共享资源
sum := 0

func main () {
    // 开启100个协程 sum+10
    for i:=0; i <100; i++ {
        go add(10)
    }
    
    // 防止提前退出
    tiem.Sleep(2 * time.Second)
    
    fmt.Println("和为:", sum)
}

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

// 示例中,结果可能是990或者980,并不是预期的1000。导致这种情况发送的核心原因是sum并不是并发安全的,因为同时有多个协程交叉执行 sum += i,产生不可预料的结果。

小技巧:使用go build、go run、go test这些Go语言工具链提供的命令式,添加-race标记可以帮我们检查Go语言代码是否存在资源竞争。

同步原语

sync.Mutex

​ 互斥锁,顾名思义,指的是同一时刻只有一个协程执行某段代码,其它协程都要等待该协程执行完才能继续执行。 改造上述示例,声明一个互斥锁mutex,修改add函数,访问共享资源的代码片段就并发安全了。

sum:= 0
mutex := sync.Mutex


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

// 被解锁保护的sum += i 代码片段又称为临界区。在同步原语设计中,临界区指的是一个访问共享资源的代码片段,而这些共享资源又有无法同时被多个协程访问的特征。当有协程进入临界区段时,其它协程必须等待,这样就保证了临界区的并发安全。

​ 互斥锁的使用非常简单,它只有两个方法Lock()和Unlock(),代表加锁和解锁。当一个协程获得Mutex锁后,其它协程只能等到Mutex锁释放后才能再次获得锁。

​ Muetx 的 Lock 和 Unlock 方法总是成对出现,而且要确保Lock 获得锁后,一定执行Unlock释放锁,所以在函数或方法中会 采用defer 语句释放锁。 这样可以确保所以丁被释放,不会被遗忘。

sync.RWMutex

​ sync.Muetx对共享资源进行了加锁,保证并发安全。如果读取操作也是多个协程呢?

func main() {
    for i := 0; i < 100; i ++ {
        go add(10)
    }
    
    for i := 1; i <10 ; i++ {
        go fmt.Println("和为:", getSum())
    }
    
    time.Sleep(2 * time.Second)
}

func getSum() int {
    b := sum
    return b
}

// 开启了10个协程,同时读取sum的值。因为getSum函数没有任何加锁控制,所以它不是并发安全的,即一个goroutine在执行sum+=i 操作时,另一个goroutine可能在执行 b:=sum 操作,这就会导致读取的sum值是一个过期的值,结果不可预期, 要解决资源竞争问题,可以使用互斥锁sync.Mutex

func getSum() int {
    mutex.Lock()
    defer mutex.Unlock()
    b := sum 
    return b
}

​ 因为add 和 getSum函数使用的是同一个sync.Muetx,所以它们的操作是互斥的,也就是一个goroutine进行修改操作时,另一个goroutine读取操作会等待,直到修改操作完毕。

​ 我们解决了goroutine同时读写的资源竞争问题,但是又遇到了另一个问题——性能。因为每次读写共享资源都要加锁,所以性能底下,如何解决呢?

​ 读写这个特殊的场景,有以下几种情况:

1. 写的时候不能同时读,因为这个时候读取的话可能读到脏数据
2. 读的时候不能同时写,因为也可能产生不可预料的结果
3. 读的时候可以同时读,因为数据不会改变,所以不管多少个goroutine读都是并发安全的

​ 所以可以通过sync.RWMutex 来优化此段代码,提升性能。改用读写锁,来实现我们想要的结果

var mutex snyx.RWMutex

func getSum() int {
    // 获取读写锁
    mutex.RLock()
    defer mutex.RUnlock()
    b := sum
    return b
}

// 这样性能会有很大提升,因为多个goroutine可以同时读取数据,不再相互等待。

sync.WaitGroup

​ 我们上述的示例中使用了time.Sleep函数, 是为了防止主函数main() 返回,一旦main函数范湖了, 程序也就退出了。 提示:一个函数或方法的返回(return) 也就意味着当前函数执行完毕。

​ 如我们我们执行100个add协程和10个getSum协程,不知道什么时候执行完毕,所以设置了比较长的等待时间,但是存在一个问题,如果这110个协程很快就执行完毕,main函数应该提前返回,但是还要等待一会才能返回,会产生性能问题。但是如果等待时间截止时,协程没有执行完毕,程序会提前退出,导致有协程没有执行完,产生不可预知的结果。

​ 如何解决这个问题呢? 有没有办法监听所有协程的执行,一旦全部执行完毕,程序马上退出,这样既可保证所有协程执行完毕,又可以及时退出节省时间,提升性能。Channel可以解决这个问题,不过很复杂。Go语言提供了更简洁的方法,它就是 sync.WaitGroup

func run() {
    var wg sync.WaitGroup
    wg.Add(110)
    
    for i := 0; i< 100; i ++ {
        go func() {
          // 计数器减1
            defer wg.Done()
            add(10)
        }()
    }
    
    for i := 1; i < 10 ; i++ {
        go func() {
            defer wg.Done()  
            fmt.Println("sum is", getSum())
        }()
    }
    
    // 一直等待,直到计数器值为0
    wg.Wait()
}

sync.WaitGroup使用比较简单,一共分为三步:

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

​ 通过sync.WaitGroup可以很好地跟踪协程。在协程执行完毕后,整个函数才会执行完毕,时间不多不少,正好是协程执行的时间。

sync.WaitGroup适合协调多个协程共同做一件事情的场景,比如下载一个文件,假设使用10个协程,每个协程写在1/10,只有10个协程都下载好了整个文件才下载好了。这就是我们经常听说的多线程下载,通过多个线程共同做一件事情,显著提高效率。小提示我们可以把Go语言的协程理解为平常说的线程,从用户体验上并无不可,但是从技术实现上,它们是不一样的。

sync.Once

​ 在实际工作中,可能会有这样的需求:让代码只执行一次,哪怕是高并发情况下,比如创建一个单例。针对这种情形,Go语言为我们提供了sync.Once来保证代码只执行一次。

func main() {
    doOnce()
}

func doOnce() {
    var once sync.Once 
    onceBody := func() {
        fmt.Println("Only once")
    }
    
    // 用于等待协程执行完毕
    done := make(chan bool)
    
    // 启动10个协程执行once.Do(onceBody)
    for i := 1; i < 10; i++ {
        go func(){
            go func() {
                // 把要执行的函数(方法)作为参数传给once.Do方法即可
                once.Do(onceBody)
                done <- true
            }
        }()
    }
    
    for i := 10; i < 10 ; i++{
        <- done
    }
}

​ 这是Go语言自带的一个示例,虽然启动了10个协程来执行onceBody函数,但是因为用了once.Do方法,所以函数onceBody只会执行一次。也就是在高并发的情况下,sync.Once也会保证onceBody函数只执行一次。

​ sync.Once 适用于创建某个对象的单例、只加载一次的资源等只执行一次的场景。、

sync.Cond

​ 在Go语言中,sync.WaitGroup用于最终完成的场景,关键点在于一定要等待所有协程都执行完毕。而sync.Cond 可以用于发号施令,一声令下所有协程都可以开始执行,关键点在于协程开始的时候是等待的,要等待sync.Cond唤醒才能执行。

​ sync.Cond从字面意思看是条件变量,它具有阻塞协程和唤醒协程的功能,所有可以在满足一定条件的情况下唤醒协程,但条件变量只是它的一种使用场景。

// 10个人赛跑,1人裁判发号施令
func race() {
    cond := sync.NewCond(&sync.Mutex{})
    var wg sync.WaitGroup
    wg.Add(11)
    for i := 1; i < 10; i++ {
        go func(num int) {
            defer wg.Done() 
            fmt.Println(num, "号已就位")
            cond.L.Lock()
            cond.Wait() // 等待发令枪响
            fmt.Println(num, "号开始跑")
            cond.L.Unlock()
        }(i)
    }
    
    time.Sleep(2 * time.Second)
    go func(){
        defer wg.Done()
        fmt.Println("裁判已就位,准备发令枪")
        fmt.Println("比赛开始,大家开始跑")
        cond.Broadcast() // 发令枪响
    }()
    
    wg.wait()
}

大概步骤:

  1. 通过sync.NewCond函数生成一个*sync.Cond,用于阻塞和唤醒协程
  2. 然后启动10个协程模拟10个人,准备就位后调用cond.Wait()方法阻塞当前协程等待发令枪响,这里需要注意的是调用cond.Wait()方法是要加锁
  3. time.Sleep 用于等待所有人都进入wait状态,这样裁判才能调用cond.Broadcast()发号施令
  4. 裁判准备完毕后,可以调用cond.Broadcast通知所有人开始跑了

sync.Cond 有三个方法,分别是:

  1. Wait,阻塞当前协程,知道被其他协程调用Broadcast 或者 Signal 方法唤醒,使用的时候需要加锁,使用sync.Cond中的锁即可,也就是L字段
  2. **Signal **,唤醒一个等待时间最长的协程
  3. Broadcast,唤醒所有等待的协程

注意:在调用signal 或者Broadcast之前,要确保目标协程处于Wait阻塞状态,不然会出现死锁问题。

小结:我们了解了Go语言的同步原语使用,通过它们可以更灵活的控制多协程的并发。从使用上讲,Go语言还是更推荐channel这种更高级别的并发控制方式,因为它更简洁,也更容易理解和使用。同步原语通常用于更复杂的并发控制,如果追求更灵活的控制方式和性能,可以使用它们。


上一篇:Goroutine之sync.pool


下一篇:Redis分布式基石——主从复制技术详述,Java开发者必须收藏的8个开源库