go 互斥锁实现原理

时间:2021-09-02 03:08:52

go 互斥锁的实现

go中通过mutex来实现对互斥资源的锁定

1. mutex的数据结构

1.1 mutex结构体,抢锁解锁原理

go type Mutex struce{ state int32 sema uint32 }
  • state表示互斥锁的状态,比如是否被锁定
  • sema表示信号量,协程阻塞等待该信号量来唤醒协程,解锁的协程释放该信号量来唤醒阻塞的协程

下图展示了mutex的内存布局

go 互斥锁实现原理

  • 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 简单加锁

假定当前只有一个协程在加锁,没有其他协程干扰,加锁过程如下

go 互斥锁实现原理

加锁过程会去判断Locked是否为1,如果是0则把Locked置为1,表示加锁成功,其他状态位不会发生变化

2.2 加锁被阻塞

假定加锁时,锁被其他协程占用,那么加锁过程如下:

go 互斥锁实现原理

如果协程对一个已经被占用的协程加锁时,Waiter计数器会增加1,此时B将会阻塞,直到Locked变为0后才会唤醒

2.3 简单解锁

假定解锁时,没有其他协程阻塞,那么解锁过程如下

go 互斥锁实现原理

由于此时watier值为0,表示没有其他协程在等待,所以无须释放信号量,只要把Locked置为0即可

2.4 解锁并释放协程

假定解锁过程,有1个或多个协程阻塞,那么此时的解锁过程

go 互斥锁实现原理

协程A解锁分为两个步骤

  1. 将Locked置为0
  2. 释放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()实现的复杂度,也会引起不必要的协程切换。