Go Mutex 原理
Contents
Mutex
Mutex
结构:
type Mutex struct {
state int32 // 互斥锁的状态
sema uint32 // 信号量,阻塞的协程等待此信号量,执行解锁的协程释放信号量、唤醒其它等待此信号量的协程
}
const (
mutexLocked = 1 << iota // Mutex 是否已被锁定,0 表示未锁定,1 表示锁定
mutexWoken // 是否有协程已被唤醒,0 表示没有,1 表示有(正在加锁过程中)
mutexStarving // Mutex 是否处于饥饿状态,0 表示没有饥饿,1 表示饥饿状态(有协程阻塞时间超过 1ms)
mutexWaiterShift = iota // 阻塞中的等待锁的协程个数
)
Mutex.state
内存布局:
29 bit | 1bit | 1bit | 1bit |
---|---|---|---|
Waiter | Starving | Woken | Locked |
加解锁
协程抢锁是抢 Locked 位的赋值权,能将 Locked 置为 1 就说明抢锁成功。抢不到锁当前协程就会阻塞,Waiter 位加 1,等待 Mutex.sema
信号量,一旦持有锁的协程释放锁(Locked 变回 0),(若存在阻塞中的协程)就会释放信号量,阻塞协程会依次被唤醒,每唤醒一个协程 Waiter 位就减 1。
自旋
加锁时,如果当前 Locked 位为 1,说明当前该锁由其他协程持有,尝试加锁的协程并不是马上转入阻塞,而是会持续地探测 Locked 位是否变为 0,这个过程为自旋。
自旋对应于 CPU 的 PAUSE 指令,CPU 对该指令什么都不做,相当于 CPU 空转,对程序而言相当于 sleep 了一小段时间。PAUSE 不同于 sleep,不需要将协程转为睡眠状态。
自旋是一种多线程同步机制,当前的进程在进入自旋的过程中会一直保持 CPU 的占用,持续检查某个条件是否为真。在多核的 CPU 上,自旋可以避免 goroutine 的切换,使用恰当会对性能带来很大的增益,使用不当会拖慢整个程序。
加锁时程序会自动判断是否可以自旋,自旋必须满足以下所有条件:
- 自旋次数要足够少,通常为 4,即自旋最多 4 次;
- CPU 核数要大于 1,否则自旋没有意义,因为此时不可能有其他协程释放锁;
- 协程调度机制中的 Process 的数量要大于 1,比如使用
GOMAXPROCS()
将处理器设置为 1 就不能启用自旋; - 协程调度机制中的可运行队列必须为空,否则会延迟协程调度。
自旋目的是更充分地利用 CPU,尽量避免协程切换。因为当前申请加锁的协程占据了 CPU,如果经过短时间的自旋可以获得锁,则当前协程可以继续运行,不必进入阻塞状态。
自旋持续时间很短,自旋过程中发现锁被释放,协程可以立即获取锁,其它之前被阻塞的协程将无法获得锁。如果后来的执行加锁的协程特别多,而且常常都能通过自旋获得锁(插队),那么之前被阻塞的协程将很难获得锁,进入到“饥饿”状态。为了避免这一情况,1.8 版本增加了 Starving 位,有 Normal 和 Starving 两种状态。Starving 状态下不会自旋,一旦有协程释放锁,一定会唤醒一个协程并成功加锁。
释放锁时如果发现有阻塞协程,会释放一个信号量来唤醒一个阻塞协程,被唤醒的协程得到 CPU 后开始运行,此时若发现锁已被抢占(其它协程自旋),自己只好再次阻塞,阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,若超过 1ms,则会将 Mutex
标记为“饥饿”模式,然后阻塞。
其它
第一次使用后,Mutex.state
变为 mutexLocked
,此时复制得到的 Mutex
的 state
也是 mutexLocked
,复制这一动作造成 Mutex
初始状态不明确。
https://go.dev/play/p/UW82-TA-BYU
Woken 状态用于加锁和解锁过程中的通信:同一时刻,两个协程一个在加锁一个在解锁,在加锁的协程可能在自旋过程中,此时把 Woken 标记为 1,用于通知解锁协程不必释放信号量,因为自旋中的加锁协程马上可以拿到锁,避免了其它之前的阻塞协程被唤醒却又拿不到锁带来的损耗。
重复解锁触发 panic 的考虑:Unlock
分为将 Locked 置为 0 和 判断 Waiter 值两个过程,Waiter > 0 则释放信号量。如果允许多次执行 Unlock
,有可能每次都会释放一个信号量,导致多个协程的唤醒,多个协程唤醒后会继续在 Lock
逻辑里抢锁,这样会增加 Lock
实现的复杂度,也会引起不必要的协程切换。
RWMutex
结构:
type RWMutex struct {
w Mutex // held if there are pending writers
writerSem uint32 // semaphore for writers to wait for completing readers
readerSem uint32 // semaphore for readers to wait for completing writers
readerCount int32 // number of pending readers
readerWait int32 // number of departing readers
}
读写锁实现的功能:
- 写锁阻塞写锁;
- 写锁阻塞读锁;
const rwmutexMaxReaders = 1 << 30
- 进行写锁定时,会先将
readerCount
减去2^30
,readerCount
变成负值,此时再有读锁定到来时检测到readerCount
为负值,便知道有写操作在进行,于是阻塞等待。真实的读操作个数只需将readerCount
加上2^30
。
- 读锁阻塞写锁;
- 判断
readerCount
是否大于 0。
- 判断
- 读锁不阻塞读锁。
Lock
的写锁定逻辑:
- 获取互斥锁
w
,获取不到则排队等待; - 若
readerCount
大于 0,则阻塞等待所有读操作结束。
Unlock
的写解锁逻辑:
- 若
readerCount
大于 0,唤醒阻塞的读操作(readerSem
); - 释放互斥锁,若有其它阻塞的写操作则会进行唤醒(
w.sema
)。
RLock
读锁定逻辑:
readerCount++
;- 若有写操作,阻塞等待写操作结束,等待
readerSem
信号量。
RUnlock
读解锁逻辑:
readerCount--
;- 若有写锁定且当前是最后一个 reader,唤醒阻塞的写操作(
writerSem
)。
写操作要等待读操作结束后才可以获得锁,写操作等待期间可能还有新的读操作持续到来,如果写操作等待所有读操作结束,则很可能被“饿死”,于是引入了 readerWait
。获得写锁的协程(由于 read
大于 0,仍被阻塞),会把 readerCount
的值复制到 readerWait
中,用于标记排在写操作前面的读者个数。前面的读操作结束后,除了会递减 readerCount
的值,也会递减 readerWait
的值,当 readerWait
的值变为 0 时唤醒写操作。
一次写操作把连续的读操作划分成写操作之前和之后的两部分,写操作之前的读操作结束后唤醒写操作,写操作结束后唤醒后面的读操作。