go 互斥锁的实现
go中通过mutex来实现对互斥资源的锁定
1. mutex的数据结构
1.1 mutex结构体,抢锁解锁原理
go type Mutex struce{ state int32 sema uint32 }
- state表示互斥锁的状态,比如是否被锁定
- sema表示信号量,协程阻塞等待该信号量来唤醒协程,解锁的协程释放该信号量来唤醒阻塞的协程
下图展示了mutex的内存布局
- Locked:表示mutex是否已经锁定。1:锁定。 0:没有锁定
- Waiter:表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量
- Starving:表示该mutex是否处于饥饿状态。0:没有饥饿。1:饥饿,表示有协程已经阻塞超过1ms
- Woken:表示是否有协程已被唤醒,0:没有协程唤醒。1:有协程唤醒,正在加锁过程中
协程之间抢锁的过程实际上是给Locked赋值1的过程,能给Locked赋值为1,表示抢锁成功,抢不到锁就阻塞等待sema信号量来唤醒加锁
1.2 mutex方法
Mutex对外提供两个方法
- Lock():加锁方法
- Unlock():解锁方法
2. 加解锁过程
2.1 简单加锁
假定当前只有一个协程在加锁,没有其他协程干扰,加锁过程如下
加锁过程会去判断Locked是否为1,如果是0则把Locked置为1,表示加锁成功,其他状态位不会发生变化
2.2 加锁被阻塞
假定加锁时,锁被其他协程占用,那么加锁过程如下:
如果协程对一个已经被占用的协程加锁时,Waiter计数器会增加1,此时B将会阻塞,直到Locked变为0后才会唤醒
2.3 简单解锁
假定解锁时,没有其他协程阻塞,那么解锁过程如下
由于此时watier值为0,表示没有其他协程在等待,所以无须释放信号量,只要把Locked置为0即可
2.4 解锁并释放协程
假定解锁过程,有1个或多个协程阻塞,那么此时的解锁过程
协程A解锁分为两个步骤
- 将Locked置为0
- 释放sema信号量,唤醒协程B,并将waiter减1
此时Locked为0,协程B收到信号量,将Locked置为1,B获得锁
3. 自旋过程
加锁时,如果当前Locked位为1, 说明该锁被其他协程占用,但尝试加锁的协程并不会马上转为阻塞状态,而是会持续的检测Locked位是否为0,这个过程称为自旋。
自旋的过程很短,如果在自旋过程中发现锁被释放,那么该协程会立即获得锁,被唤醒的协程会继续阻塞
自旋的好处是,当加锁失败时,不必立即转入阻塞,有一定机会获得锁,避免了协程之间的切换
3.1 什么是自旋
自旋对应CPU指令"PAUSE"(暂停,停顿),CPU对该指令什么都不做,相当于CPU空转,对程序而要相当于sleep了一段时间,该时间非常短,当前为30个时钟周期
自旋过程会持续检测Locked是否为0,它不同于sleep,不需要协程转为睡眠状态
3.2 自旋条件
- 自旋次数要足够小,通常为4,即自旋最多4次
- CPU核数要大于1,否则自旋没有意义,因为此时不可能有其他协程释放锁
- 协程调度机制中的Process数量要大于1,比如使用GOMAXPROCS()将处理器设置为1就不能启用自旋
- 协程调度机制中的可运行队列必须为空,否则会延迟协程调度
3.3 自旋的优势
自旋的优势是更充分的利用了CPU,避免了协程切换。因为当前申请加锁的协程获得了CPU,如果通过短时间自旋就可以获得锁,那么就可以直接运行,而不用阻塞并切换协程
3.4 自旋的问题
如果自旋过程中获得了锁,那么之前阻塞的协程就无法获得锁。如果等待加锁的协程特别多,而都在自旋过程中获得了锁,那么之前阻塞的协程就将一直阻塞。
为了解决这个问题,在1.8的版本之后增加了一个状态Starving。在这个状态下不会自旋,一定会有一个协程被唤醒并加锁
4. Mutex模式
现在我们看下Starving位的作用
每个Mutex都有两个模式,称为normal和Starving。
4.1 Normal模式
默认情况下都是Normal模式
当一个协程加锁失败时,不会立即转入等待状态,而是判断是否满足自旋条件,如果满足,则自旋来等待锁
4.2 Starving模式
自旋模式抢到锁,表示有协程释放了锁,我们知道释放锁时,如果waiter>0,即有阻塞等待的协程,会释放信号量来唤醒协程,当协程被唤醒后,发现Locked=1,锁又被抢占,则又会阻塞,但在阻塞前会判断自上次阻塞到本次阻塞经历了多长时间,如果超过1ms的话,会将Mutex标记为"饥饿"模式,然后再阻塞
在饥饿模式下,不会启动自旋,如果有协程释放锁,那么一定会唤醒一个协程,被唤醒的协程会获得锁,同时会把waiter减1.
5. Woken状态
Woken状态作用于加锁和解锁的过程中,如果一个协程正在解锁,另一个协程在自旋等待加锁,那么会把Woken状态置为1,通知解锁的协程不用释放信号量。
6. 为什么重复解锁要panic
unlock()过程分为将locked置为0,然后判断waiter是否大于0,如果大于0就释放信号量
如果多次unlock(),则可能会唤醒多个协程,多个协程唤醒后会继续在Lock()的逻辑里抢锁,势必会增加Lock()实现的复杂度,也会引起不必要的协程切换。