互斥锁是并发程序中对共享资源进行访问控制的主要手段,对此 Go 语言提供了非常简单易用的 Mutex,Mutex 是一个结构体类型,对外暴露 Lock() 和 Unlock() 两个方法分别用于加锁和解锁。
在 src/sync/mutex.go:Mutex 中定义了互斥锁的数据结构:
type Mutex struct {
state int32
sema uint32
}
结构体中变量说明:
从结构体定义可知,Mutex.state 是32位的整型变量,内部实现时把该变量分成四份,用于记录 Mutex 的四种状态。
下图展示 Mutex 的内存布局:

协程之间抢锁实际上是抢给 Locked 赋值的权利,能给 Locked 域置1,则说明抢锁成功。
抢不到的话就阻塞等待 Mutex.sema 信号量,一旦持有锁的协程解锁,等待的协程会依次被唤醒。
Mutex 对外提供两个方法:
假设当前只有一个协程在加锁,没有其他协程干预,加锁过程如下图:

说明: 加锁过程会去判断 Locked 标志位是否为0,如果是0则把 Locked 域置为1,代表加锁成功。
从上图看,加锁成功后,只是 Locked 置为1,其他状态位没发生变化。
假设加锁时,锁已被其他协程占用了,此时加锁过程如下:

说明: 当协程B对一个已被占用的锁再次加锁时,Waiter 计数器增加了1,此时协程B将被阻塞,直到 Locked 值变为0后才会被唤醒。
假设解锁时,没有其他协程阻塞,此时解锁过程如下:

说明: 由于没有其他协程被阻塞等待加锁,所以此时解锁时只需要把 Locked 域置为0即可,不需要释放信号量。
假设解锁时有一个或多个协程阻塞,此时解锁过程如下:

说明: 协程A解锁过程分为两个步骤,一是把 Locked 域置为0,而是检测到 Waiter > 0,所以释放一个信号量,唤醒一个阻塞的协程,被唤醒的协程B把 Locked 域置为1,于是协程B抢到了锁。
加锁时,如果当前 Locked 域为1,说明该锁当前右其他协程持有,尝试加锁的协程并不是马上转入阻塞,而是会持续的探测 Locked 域是否变为0,这个过程即为自旋过程。
自旋时间很短,但如果在自旋过程中发现锁已被释放,那么协程可以立即获得锁。此时即便有协程被唤醒也无法获得锁,只能再次阻塞。
自旋的好处是,当加锁失败时不必立即转入阻塞,有一定机会获取到锁,这样可以避免协程的切换。
自旋对应 CPU 的 “PAUSE” 指令,CPU 对该指令什么也不做,相当于 CPU 空转,对程序而言相当于 sleep 了一小段时间,时间非常短,当前实现是30个时钟周期。
自旋过程会持续探测 Locked 是否变为0,连续两次探测间隔就是执行这些 “PAUSE” 指令,它不同于 sleep,不需要将协程转为睡眠状态。
加锁时程序会自动判断是否可以自旋,无限制的自旋将会给 CPU 带来巨大压力。
自旋必须满足以下所有条件:
可见,自旋的条件是很苛刻的,总而言之,就是不忙的时候才会启用自旋。
自旋的优势是更加充分的利用 CPU,尽量避免协程切换。因为当前申请加锁的协程拥有 CPU,如果经过短时间的自旋可以获得锁,当前协程可以继续运行,不必进入阻塞状态。
如果自旋过程中获得锁,那么之前被阻塞的协程将无法获得锁,如果加锁的协程特别多,每次都通过自旋获得锁,那么之前被阻塞的进程将很难获得锁,从而进入饥饿状态。
为了避免协程长时间无法获取锁,自1.8版本以来增加了一个状态,即 Mutex.Starving 状态。这个状态下不会自旋,一旦有协程释放锁,那么一定会唤醒一个协程并成功加锁。
我们来看下 Starving 域的作用。
每个 Mutex 都有两个模式,成为 Normal 和 Starvation
默认情况下,Mutex 为 Normal 模式。
该模式下,协程如果加锁不成功不会立即转入阻塞排队,而是判断是否满足自旋的条件,如果满足则会启动自旋锁,尝试抢锁。
自旋过程中如果能抢到锁,一定意味着同一时刻有协程释放了锁,我们知道释放锁时如果发现有阻塞等待的协程,还会释放一个信号量来唤醒一个等待中的协程,被等待的协程得到 CPU 后开始运行,此时发现锁已经被抢占了,自己只好再次阻塞。
不过在阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过了 1ms 的话,则会将 Mutex 标记为“饥饿模式”,然后再阻塞。
处于饥饿模式下,不会启动自旋过程,即此状态下,一旦有协程释放了锁,那么一定会唤醒等待中的协程,被唤醒的协程将会成功获取到锁,同时也会把 Waiter 等待计数减1。
Woken 域用于加锁和解锁过程的通信,举个例子,同一时刻,两个协程,一个加锁,一个在解锁,在加锁的协程可能在自旋过程中,此时把 Woken 标记为1,用于通知解锁协程不必释放信号量了,好比在说,你只管解锁好了,不必释放信号量,我马上就拿到锁了。
可能你会想,为什么Go不能实现得更健壮些,多次执行Unlock()也不要panic?
仔细想想Unlock的逻辑就可以理解,这实际上很难做到。Unlock过程分为将Locked置为0,然后判断Waiter值,如果值>0,则释放信号量。
如果多次Unlock(),那么可能每次都释放一个信号量,这样会唤醒多个协程,多个协程唤醒后会继续在Lock()的逻辑里抢锁,势必会增加Lock()实现的复杂度,也会引起不必要的协程切换。
本文参考 https://www.bookstack.cn/read/GoExpertProgramming/chapter02-2.4-mutex.md