go语言笔记之Mutex和RWMutex(五)

49 阅读7分钟

学习一下go语言基础所做笔记,主要参考《Go专家编程》和《Go程序设计语言》这两本书。

Mutex

互斥锁是并发程序中对共享资源进行访问控制的主要手段, 对此Go语言提供了非常简单易用的Mutex, Mutex为一结构体类型,对外暴露两个方法Lock()和Unlock()分别用于加锁和解锁。Mutex使用起来非常方便, 但其内部实现却复杂得多, 这包括Mutex的几种状态。另外,我们也想探究一下Mutex重复解锁引起panic的原因。

数据结构

我们看到Mutex.state是32位的整型变量,内部实现时把该变量分成四份,用于记录Mutex的四种状态。

下图展示Mutex的内存布局:

  • Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。
  • Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。
  • Starving:表示该Mutex是否处于饥饿状态,0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms。
  • Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。

协程之间抢锁实际上是抢给Locked赋值的权利,能给Locked域置1,就说明抢锁成功。抢不到的话就阻塞等待Mutex.sema信号量,一旦持有锁的协程解锁,等待的协程会依次被唤醒。

加解锁

Mutex对外提供两个方法,实际上也只有这两个方法:

  • Lock() : 加锁方法
  • Unlock(): 解锁方法

Lock()

假定当前只有一个协程在加锁,没有其他协程干扰,那么过程如下图所示:

假定加锁时,锁已被其他协程占用了,此时加锁过程如下图所示:

Unlock()

假定解锁时,没有其他协程阻塞,此时解锁过程如下图所示:

假定解锁时,有1个或多个协程阻塞,此时解锁过程如下图所示:

扩展

自旋(乐观锁)

我感觉书上说的有点多,简单介绍一下就可以了。

加锁时, 如果当前Locked位为1, 说明该锁当前由其他协程持有, 尝试加锁的协程并不是马上转入阻塞, 而是会持续的探测Locked位是否变为0, 这个过程即为自旋过程。

自旋时间很短, 但如果在自旋过程中发现锁已被释放, 那么协程可以立即获取锁。 此时即便有协程被唤醒也无法获取锁, 只能再次阻塞。

优点:充分的利用CPU, 尽量避免协程切换。

缺点: 如果加锁的协程特别多, 每次都通过自旋获得锁, 那么之前被阻塞的进程将很难获得锁, 从而进入饥饿状态。(下面的Mutex模式的Starving状态解决这个问题的)

Mutex模式

每个Mutex都有两个模式, 称为Normal和Starving。 下面分别说明这两个模式。

  1. Normal

默认,协程如果加锁不成功不会立即转入阻塞排队, 而是判断是否满足自旋的条件, 如果满足则会启动自旋过程, 尝试抢锁。

没有解决饥饿问题。

  1. Starving

自旋过程中能抢到锁, 一定意味着同一时刻有协程释放了锁, 我们知道释放锁时如果发现有阻塞等待的协程, 还会释放一个信号量来唤醒一个等待协程, 被唤醒的协程得到CPU后开始运行, 此时发现锁已被抢占了, 自己只好再次阻塞,不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间, 如果超过1ms的话, 会将Mutex标记为”饥饿”模式, 然后再阻塞。

处于饥饿模式下, 不会启动自旋过程, 也即一旦有协程释放了锁, 那么一定会唤醒协程, 被唤醒的协程将会成功获取锁, 同时也会把等待计数减1。

画个图理解下哈:

Woken状态

Woken状态是用来在加锁和解锁过程中进行沟通的。比如说,如果有两个协程同时工作,其中一个正在尝试加锁,另一个正在解锁。如果加锁的那个协程正处于自旋等待中,这时我们会把Woken设为1。这样做是为了告诉那个正在解锁的协程:“你继续解锁吧,不用释放信号量了,因为我很快就能拿到锁了。”

RWMutex

读写互斥锁(RWMutex)是Mutex的一种改进型,其核心优势在于支持多读单写模式。在这种机制下,多个读操作可以并发执行,而写操作则需要独占锁资源。这种设计特别适用于读操作远多于写操作的场景,例如数据库查询或配置文件读取等。通过允许多个读操作同时进行,RWMutex能够有效提升系统性能,同时确保写操作的原子性和数据一致性。

数据结构

源码包src/sync/rwmutex.go:RWMutex定义了读写锁数据结构:

加解锁

RWMutex提供4个简单的接口来提供服务:

  • RLock():读锁定
  • RUnlock():解除读锁定
  • Lock(): 写锁定,与Mutex完全一致
  • Unlock():解除写锁定,与Mutex完全一致

RLock()

读锁定需要做两件事:

  • 增加读操作计数,即readerCount++
  • 阻塞等待写操作结束(如果有的话)

读计数+1,并且等待写锁释放,请结合UnLock理解。

RUnlock()

解除读锁定需要做两件事:

  • 减少读操作计数,即readerCount--
  • 唤醒等待写操作的协程(如果有的话)

所以func (rw *RWMutex) RUnlock()接口实现流程如下图所示:

注意:即便有协程阻塞等待写操作,并不是所有的解除读锁定操作都会唤醒该协程,而是最后一个解除读锁定的协程才会释放信号量将该协程唤醒,因为只有当所有读操作的协程释放锁后才可以唤醒协程。也就是所有的读完了之后才唤醒写

Lock()

写锁定操作需要做两件事:

  • 获取互斥锁
  • 阻塞等待所有读操作结束(如果有的话)

所以func (rw *RWMutex) Lock()接口实现流程如下图所示:

其实就是拿锁,等读操作完成。

Unlock()

解除写锁定要做两件事:

  • 唤醒因读锁定而被阻塞的协程(如果有的话)
  • 解除互斥锁

所以func (rw *RWMutex) Unlock()接口实现流程如下图所示:

正好和上面反过来先唤醒所有读,在释放锁。

简单理解下就是一个读写交替,画了个图,帮助理解下:

扩展

为什么写锁定不会被饿死?

我们知道, 写操作要等待读操作结束后才可以获得锁, 写操作等待期间可能还有新的读操作持续到来, 如果写操作等待所有读操作结束, 很可能被饿死。 然而, 通过RWMutex.readerWait可完美解决这个问题。

写操作到来时, 会把RWMutex.readerCount值拷贝到RWMutex.readerWait中, 用于标记排在写操作前面的读者个数。

前面的读操作结束后, 除了会递减RWMutex.readerCount, 还会递减RWMutex.readerWait值, 当RWMutex.readerWait值变为0时唤醒写操作。

所以说, 写操作就相当于把一段连续的读操作划分成两部分, 前面的读操作结束后唤醒写操作, 写操作结束后唤醒后面的读操作。 如下图所示:

其实也就是说,当写请求到来后,后续的新读者就不会直接获得读锁,而是被阻塞,等待写操作完成。

参考

Go专家编程

Go程序设计语言