Go 并发控制之 Mutex
与 RWMutex
一、Mutex
包的功能和用法
(一)功能概述
sync.Mutex
是 Go 提供的对共享资源进行互斥访问的原语。它的核心作用是确保同一时刻只有一个 goroutine
能够持有锁,从而避免多个 goroutine
同时访问共享资源导致的数据混乱。
(二)基本用法
Mutex
仅对外提供两个方法:Lock()
和Unlock()
。Lock()
用于加锁,当一个goroutine
调用Lock()
方法时,如果锁未被其他goroutine
持有,则该goroutine
成功获取锁;如果锁已经被其他goroutine
持有,则当前goroutine
会阻塞,直到获取到锁。Unlock()
则用于解锁,释放锁资源,以便其他等待的goroutine
可以获取锁。
(三)四种状态
- Locked :表示
Mutex
是否已被锁定。值为 0 表示未锁定,值为 1 表示已被锁定。这是判断锁是否可用的关键状态。 - Woken :用于指示是否有协程已被唤醒且正在尝试加锁。值为 0 表示没有协程唤醒,值为 1 表示有协程被唤醒。它有助于协调多个
goroutine
对锁的竞争。 - Starving :反映
Mutex
是否处于饥饿状态。当有协程阻塞超过 1ms 时,Mutex
会进入饥饿模式,此时该字段值为 1。饥饿模式下,锁的获取策略会有所调整,以避免某些goroutine
长时间无法获取锁。 - Waiter :记录阻塞等待锁的协程个数。在协程解锁时,根据这个值来判断是否需要释放信号量,从而唤醒等待的协程。
(四)自旋机制
当一个 goroutine
尝试获取一个已经被其他 goroutine
持有的锁时,并不会立即进入阻塞状态。而是会进入一个自旋过程,持续探测锁的 Locked
位是否变为 0。自旋的好处在于,如果锁很快就会被释放,自旋的 goroutine
就有机会在短时间内获取锁,避免了协程的频繁切换,提高了效率。
(五)正常模式和饥饿模式
- 正常模式 :在正常模式下,
goroutine
加锁失败时,会先判断是否满足自旋条件。如果满足,就会启动自旋过程,尝试抢锁。这种模式适用于锁竞争不激烈的情况,能够充分利用自旋的优势,减少协程切换开销。 - 饥饿模式 :如果一个
goroutine
被阻塞后,在上次阻塞到本次阻塞的时间超过 1ms,就会将Mutex
标记为饥饿模式。饥饿模式下,不再启动自旋过程,而是直接进入阻塞队列等待锁的释放。这种模式主要用于应对锁竞争非常激烈的情况,确保每个等待的goroutine
都能在一定时间内获取到锁,避免出现部分goroutine
长时间无法获取锁的 “饥饿” 现象。
二、RWMutex
是什么
(一)概念
sync.RWMutex
是 Go 标准库中的读写互斥锁。它允许多个 goroutine
同时读取共享资源,但在写操作时必须独占锁。这使得 RWMutex
在读多写少的高并发场景中表现出色,能够有效提高程序的并发性能。
(二)功能实现
RWMutex
内部通过一个读计数器和一个写计数器来实现其功能。读计数器用于记录当前正在进行的读操作数量,当有多个goroutine
同时读取资源时,读计数器会相应增加。只有当读计数器为 0 时,才允许写操作进行。写计数器则用于记录当前是否有写操作正在进行,一旦有写操作获取锁,写计数器会增加,此时其他所有读写操作都会被阻塞。- 它提供了两种锁机制:读锁(
RLock()
和RUnlock()
)以及写锁(Lock()
和Unlock()
)。读锁允许多个goroutine
同时持有,而写锁是独占的,同一时刻只能有一个goroutine
持有写锁。
(三)使用注意事项
- 成对调用锁与解锁 :无论是读锁还是写锁,都必须成对调用相应的锁和解锁方法。否则,可能会导致死锁或者程序出现 panic。例如,调用了
RLock()
后没有对应地调用RUnlock()
,或者调用了Lock()
后没有调用Unlock()
,都会导致锁无法正常释放,进而引发死锁。 - 不可锁升级 :在持有
RLock()
的情况下,不能尝试获取Lock()
。因为读锁允许多个goroutine
同时持有,而写锁需要独占,如果允许锁升级,会导致写操作与其他读操作冲突,从而引发死锁。 - 写锁不能重入 :
RWMutex
不支持写锁的重入。这意味着,如果一个goroutine
已经持有了写锁,再次调用Lock()
方法时,会导致死锁。因为写锁的设计初衷是为了保证写操作的独占性,重入写锁会破坏这一特性。 - 不记录 goroutine :
RWMutex
不会记录是哪个goroutine
加了锁。因此,在解锁时,必须确保是在正确的goroutine
中进行解锁操作,否则会导致解锁失败或者出现其他不可预见的问题,比如跨goroutine
解锁可能会引发 panic。 - 小心遗漏解锁 :忘记调用
Unlock()
或RUnlock()
方法来释放锁,会导致锁一直被持有,其他需要该锁的goroutine
无法获取锁,从而引发死锁或者资源泄漏。例如,在函数执行过程中,如果没有在所有可能的分支路径上都正确地调用解锁方法,就可能出现遗漏解锁的情况。 - 解锁后的唤醒机制 :对写锁解锁时,会唤醒所有因试图锁定读锁而被阻塞的
goroutine
,因为写锁释放后,读操作可以重新开始进行。而对读锁解锁时,只有在没有其他读锁锁定的前提下,才会唤醒因试图锁定写锁而被阻塞的goroutine
,这是为了确保写操作能够在没有其他读操作干扰的情况下进行。