Golang Mutex 的原理

sync.Mutex 是 Golang 提供的互斥锁,使用起来非常简单,只需要 Lock() 加锁,Unlock() 解锁就可以了。但其内部实现却不算简单。

Mutex 结构体

1
2
3
4
type Mutex struct {
state int32
sema uint32
}
  • state:表示该互斥锁的状态;
  • sema:信号量,协程就在这个信号量上排队等待。

state 是一个 32 位整数,但它会被拆成四份来使用,从低到高位:

  • 最低位 Locked:表示该互斥锁是否被锁定;
  • 第二位 Woken:表示是否有协程已被唤醒,用于决定释放锁的协程是否需要释放信号量;
  • 第三位 Starving:该互斥锁是否处于饥饿状态,饥饿状态意为有协程阻塞超过了 1ms;
  • 剩下的 29 位 Waiter:表示阻塞等待该互斥锁的协程数量;

所以我们可以知道所谓加锁就是在争抢给最低位 Locked 赋值的权利,抢到了就获取锁成功,否则只能去信号量上排队等待了,一旦持有锁的协程释放锁,等待锁的协程会被依次唤醒。

协程如何在信号量上排队等待?

要通过 runtime.semaphore 来实现,这是可供协程使用的信号量,runtime 内部会通过一个大小为 251 的 semaTable 来管理所有的 semaphore。semaTable 中存储了 251 棵平衡树的根。

要使用信号量时,就比如 Mutex 中的 sema,会先用这个 sema 映射到 sematable 中的某棵平衡树上,找到对应的节点,就找到了该信号量的等待队列。

加锁过程

加锁过程可以分为简单加锁和加锁时被阻塞的情况。

简单加锁

当前只有一个协程想要加锁,那么直接判断 Locked 是否为 0,如果是 0 则把 Locked 置为 1,加锁成功,其它位不会有变化。

加锁被阻塞

当加锁时,发现 Locked 位为 1,那么 Waiter 就会加 1,该协程被阻塞,直到 Locked 值变为 0 后才被唤醒。

解锁过程

解锁过程可以分为简单解锁和解锁并唤醒正在排队等待的协程。

简单解锁

解锁时,没有其他协程阻塞等待,只要将 Locked 位置为 0 即可,不需要释放信号量。

解锁并唤醒协程

解锁时,由于有其他协程在阻塞等待,所以除了将 Locked 位置为 0 之外,还要释放信号量,唤醒正在排队等待的协程。

Mutex 的模式

Mutex 有两种模式,由 Starving 位决定。

正常模式

在正常模式下,协程加锁不成功不会立刻进入阻塞排队,而会先判断是否符合自旋的条件,如果条件满足会先尝试自旋抢锁。

饥饿模式

如果一个协程自上一次阻塞到这一次阻塞超过 1ms 的时间,那么该互斥锁会进入饥饿状态。处于饥饿状态下的互斥锁,没有自旋过程,加锁失败的协程会直接进入阻塞排队。在持有锁的协程释放锁时,被唤醒的协程一定会获取锁成功。

自旋的条件

无限制的自旋将使得 CPU 利用率降低,所以需要有一定的条件来限制:

  1. 自旋次数不能太多,最多为 4 次;
  2. CPU 核数要大于 1,否则自旋没有意义;
  3. runtime 设置的 Process 数量也要大于 1;
  4. P 的可运行队列必须为空才能进行自旋,否则调度其它协程上来执行更好。

自旋的优势

自旋可以尽量避免协程切换,不必进入阻塞状态,如果自旋获取锁之后可以继续运行。

自旋的问题

由于正在自旋的协程当前在 CPU 上运行,所以相比于刚被唤醒的协程更容易获取锁,那么之前被阻塞的锁将很难获得锁,从而进入饥饿状态。

为了解决这个问题,Go 1.8 以来增加了一个饥饿状态,在饥饿模式下,新来的协程直接去最后排队,这样避免了自旋。

Woken 状态

Woken 状态用于加锁和解锁过程的通信,两个协程一个在自旋加锁一个在解锁,自旋加锁的这个协程会把 Woken 标记为 1,也就是告诉解锁协程不必去释放信号量,因为这个自旋的协程就相当于是唤醒了。

为什么重复 Unlock 会 panic?

Unlock 的过程会先将 Locked 置为 0,然后判断 Waiter 值,如果值 > 0 则释放信号量,如果多次 Unlock 而不 panic 的话,则会释放多个信号量,会唤醒多个协程,这多个协程会继续在 Lock() 逻辑里抢锁,增加了 Lock() 实现的难度,也引起了不必要的协程切换。

Mutex 的使用技巧

  1. 加锁后可以使用 defer 来解锁,防止死锁;
  2. 加锁、解锁最好出现在同一层的代码块中,如同一个函数;
  3. 避免重复的 Unlock,否则会出现 panic。