Loading...
墨滴

盘胧

2021/09/23  阅读:28  主题:橙心

golang-sync.[RW]Mutex

golang sync.Mutex sync.RWMutex

并发最紧密的代码包

并发相关概念

  • 竞态条件
  • 临界区
  • 同步工具

go宣扬“用通讯的方式共享数据”

通过共享数据的方式来传递信息和协调线程运行的做法其实更主流,大多数编程语言都是这样做的

数据被多个线程共享,就会有竞争和冲突——竞态条件(race condition), 产生竞争——破坏数据的一致性

一致性:多个线程对共享数据的操作总是可以达到它们各自预期的效果

如果一致性得不到保证,那么一些线程的代码和流程就没办法正确执行,甚至会造成某种不可预知的错误——排查成本高很难定位

同步

  • 避免多个线程在同一时刻操作同一块数据
  • 协调多个线程,避免同一时刻执行同一代码块

本质同步就是控制多个线程对共享资源的访问

访问权限

可以看作是一块令牌,如果令牌只有一块,那么同一时刻就只允许一个线程,进入到这个区域,访问共享资源

这个时候,多个并发运行的线程对这个共享资源的访问完全是串行的

只要一个代码片段需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical setion)

临界区总是要受到保护的,否则就会产生竞态条件,施加保护的重要手段之一,就是使用实现了某种同步机制的工具,称为同步工具

竞态条件、临界区与同步工具
竞态条件、临界区与同步工具

go中的同步工具不少,最常用的是互斥量(mutual exclusion,简称mutex)——sync包中的Mutex就是与之对应的类型,该类型的值可以被称为互斥量或者互斥锁

每当有 goroutine 想进入临界区时,都需要先对它进行锁定,并且,每个 goroutine 离开临界区时,都要及时地对它进行解锁。

锁定操作可以通过调用互斥锁的Lock方法实现,而解锁操作可以调用互斥锁的Unlock方法

mu.Lock()
_, err := writer.Write([]byte(data))
if err != nil {
 log.Printf("error: %s [%d]", err, id)
}
mu.Unlock()

这里的互斥锁就相当于我们前面说的那块访问令牌

互斥锁以及最佳实践

  • 不要重复锁定
  • 不要忘记解锁,必要时请使用defer语句
  • 不要对未上锁和已经解锁的互斥锁解锁
  • 不要在多个函数之间直接传递互斥锁

虽然没有任何强制规定来限制,你用同一个互斥锁保护多个无关的临界区,但是这样做,一定会让你的程序变得很复杂,并且也会明显地增加你的心智负担。

对一个已经被锁定的互斥锁进行锁定,是会立即阻塞当前的 goroutine 的。这个 goroutine 所执行的流程,会一直停滞在调用该互斥锁的Lock方法的那行代码上。直到该互斥锁的Unlock方法被调用,并且这里的锁定操作成功完成,后续的代码(也就是临界区中的代码)才会开始执行。这也正是互斥锁能够保护临界区的原因所在

一旦,你把一个互斥锁同时用在了多个地方,就必然会有更多的 goroutine 争用这把锁。这不但会让你的程序变慢,还会大大增加死锁(deadlock)的可能性。

死锁 当前程序中的主 goroutine,以及我们启用的那些 goroutine 都已经被阻塞。这些 goroutine 可以被统称为用户级的 goroutine。这就相当于整个程序都已经停滞不前了。

Go 语言运行时系统是不允许这种情况出现的,只要它发现所有的用户级 goroutine 都处于等待状态,就会自行抛出一个带有如下信息的 panic:

fatal error: all goroutines are asleep - deadlock!

注意,这种由 Go 语言运行时系统自行抛出的 panic 都属于致命错误,都是无法被恢复的,调用recover函数对它们起不到任何作用。也就是说,一旦产生死锁,程序必然崩溃。一定要尽量避免这种情况的发生。而最简单、有效的方式就是让每一个互斥锁都只保护一个临界区或一组相关临界区。

对于同一个 goroutine 而言,既不要重复锁定一个互斥锁,也不要忘记对它进行解锁

一个 goroutine 对某一个互斥锁的重复锁定,就意味着它自己锁死了自己。先不说这种做法本身就是错误的,在这种情况下,想让其他的 goroutine 来帮它解锁是非常难以保证其正确性的。

“不要忘记解锁互斥锁”的一个很重要的原因就是:避免重复锁定

因为在一个 goroutine 执行的流程中,可能会出现诸如“锁定、解锁、再锁定、再解锁”的操作,所以如果我们忘记了中间的解锁操作,那就一定会造成重复锁定。

除此之外,忘记解锁还会使其他的 goroutine 无法进入到该互斥锁保护的临界区,这轻则会导致一些程序功能的失效,重则会造成死锁和程序崩溃。

在很多时候,一个函数执行的流程并不是单一的,流程中间可能会有分叉,也可能会被中断。

如果一个流程在锁定了某个互斥锁之后分叉了,或者有被中断的可能,那么就应该使用defer语 句来对它进行解锁,而且这样的defer语句应该紧跟在锁定操作之后。这是最保险的一种做法。

忘记解锁导致的问题有时候是比较隐秘的,并不会那么快就暴露出来。相比之下,解锁未锁定的互斥锁会立即引发 panic。

并且,与死锁导致的 panic 一样,它们是无法被恢复的。因此,我们总是应该保证,对于每一个锁定操作,都要有且只有一个对应的解锁操作

我们应该让它们成对出现。这也算是互斥锁的一个很重要的使用原则了。在很多时候,利用defer语句进行解锁可以更容易做到这一点。

互斥锁的重复锁定和重复解锁
互斥锁的重复锁定和重复解锁

Go 语言中的互斥锁是开箱即用的。换句话说,一旦我们声明了一个sync.Mutex类型的变量,就可以直接使用它了。

该类型是一个结构体类型,属于值类型中的一种。把它传给一个函数、将它从函数中返回、把它赋给其他变量、让它进入某个通道都会导致它的副本的产生。并且,原值和它的副本,以及多个副本之间都是完全独立的,它们都是不同的互斥锁。

如果你把一个互斥锁作为参数值传给了一个函数,那么在这个函数中对传入的锁的所有操作,都不会对存在于该函数之外的那个原锁产生任何的影响。

mutex.lock()
defer mutex.unlock()

最好不要这样做——为了避免歧义,规范很重要

读写锁RWLock

读写锁是读 / 写互斥锁的简称。在 Go 语言中,读写锁由sync.RWMutex类型的值代表。与sync.Mutex类型一样,这个类型也是开箱即用的。

顾名思义,读写锁是把对共享资源的“读操作”和“写操作”区别对待了。它可以对这两种操作施加不同程度的保护。换句话说,相比于互斥锁,读写锁可以实现更加细腻的访问控制。

sync.RWMutex类型中的Lock方法和Unlock方法分别用于对写锁进行锁定和解锁

而它的RLock方法和RUnlock方法则分别用于对读锁进行锁定和解锁。

对于同一个读写锁来说有如下规则。

  • 在写锁已被锁定的情况下再试图锁定写锁,会阻塞当前的 goroutine。
  • 在写锁已被锁定的情况下试图锁定读锁,也会阻塞当前的 goroutine。
  • 在读锁已被锁定的情况下试图锁定写锁,同样会阻塞当前的 goroutine。
  • 在读锁已被锁定的情况下再试图锁定读锁,并不会阻塞当前的 goroutine。

对于某个受到读写锁保护的共享资源,多个写操作不能同时进行,写操作和读操作也不能同时进行,但多个读操作却可以同时进行。

对写锁进行解锁,会唤醒“所有因试图锁定读锁,而被阻塞的 goroutine”,并且,这通常会使它们都成功完成对读锁的锁定。——这个很重要

然而,对读锁进行解锁,只会在没有其他读锁锁定的前提下,唤醒“因试图锁定写锁,而被阻塞的 goroutine”;并且,最终只会有一个被唤醒的 goroutine 能够成功完成对写锁的锁定,其他的 goroutine 还要在原处继续等待。至于是哪一个 goroutine,那就要看谁的等待时间最长了。

除此之外,读写锁对写操作之间的互斥,其实是通过它内含的一个互斥锁实现的。因此,也可以说,Go 语言的读写锁是互斥锁的一种扩展。

盘胧

2021/09/23  阅读:28  主题:橙心

作者介绍

盘胧