大家好我是无尘,今天我们再来深入了解下互斥锁
互斥锁是对于并发程序的共享资源进行访问控制的主要手段,之前在介绍并发的时候已经对互斥锁的使用进行过介绍:并发控制,同步原语 sync 包
Mutex 使用非常方便,但它的内部实现却复杂的很,今天我们来介绍下它的内部实现原理。
在源码包 src/sync/mutex.go:Mutex 定义了互斥锁的数据结构:
// A Mutex is a mutual exclusion lock.
// The zero value for a Mutex is an unlocked mutex.
//
// A Mutex must not be copied after first use.
type Mutex struct {
state int32
sema uint32
}
state 是32位的整型变量,内部实现是把它分成了四份,用来记录 Mutex 的四种状态。Mutex 的内部布局:
协程之间抢锁实际上是抢给 Locked 赋值的权利,能够给 Locked 域置 1,就说明抢锁成功。抢不到的话就阻塞等待 Mutex.sema 信号量,一旦持有锁的协程解锁,等待的协程会依次被唤醒。
假设当前只有一个协程在加锁,且没有其他协程的干扰,其过程如下图:
加锁会查看 Locked 标志位是否为 0,若为 0 则改为 1,表示加锁成功。
假设加锁时,锁已经被其他协程占用了,其过程如下图:
当 B 协程对一个已被占用的锁再次加锁时,Waiter 计数器增加了1,此时 B 协程将被阻塞,直到 Locked 值变为0后才会被唤醒。
假设解锁时,没有其他协程阻塞,其过程如下图:
由于没有其他协程阻塞等待加锁,所以此时解锁时只需要把 Locked 位改为 0 即可,不需要释放信号量。
假设解锁时,有1个或多个协程阻塞,其过程如下图:
A协程解锁分为两个步,一是把 Locked 位置0,二是查看到 Waiter>0,所以释放一个信号量,唤醒一个阻塞的协程,被唤醒的B协程把 Locked 位改为 1,于是 B协程获得锁。
加锁时程序会自动判断是否可以自旋,但无限制的自旋会给 CPU 带来巨大的压力,所以判断是否可以自旋就很重要了。
自旋必须满足以下所有条件:
自旋的优势是更充分的利用CPU,尽量避免协程切换。
如果自旋过程中获得锁,那么之前被阻塞的协程将无法获得锁,如果加锁的协程特别多,每次都通过自旋获得锁,那么之前被阻塞的进程将很难获得锁,从而进入「饥饿状态」。
为了避免协程长时间无法获取锁,自1.8版本以来增加了一个状态,即 Mutex 的 Starving 状态。这个状态下不会自旋,一旦有协程释放锁,那么一定会唤醒一个协程并成功加锁。
每个 Mutex 都有两个模式,Normal 和 Starving。
默认情况下,Mutex 的模式为 normal。在该模式下,协程如果加锁不成功不会立即转入阻塞排队,而是判断是否满足自旋的条件,如果满足则会启动自旋过程,尝试抢锁。
自旋过程中能抢到锁,一定意味着同一时刻有协程释放了锁,释放锁时如果发现有阻塞等待的协程,还会释放一个信号量来唤醒一个等待协程,被唤醒的协程得到 CPU 后开始运行,此时发现锁已被抢占了,自己只好再次阻塞,不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过 1ms 的话,会将 Mutex 标记为”饥饿”模式,然后 再阻塞。 处于饥饿模式下,不会启动自旋过程,也即一旦有协程释放了锁,那么一定会唤醒协程,被唤醒的协程将会成功获取锁,同时也会把等待计数减 1。
Woken 状态用于加锁和解锁过程的通信,例如,同一时刻,两个协程一个在加锁,一个在解锁,在加锁的协程可能在自旋过程中,此时把 Woken 标记为 1,用于通知解锁协程不必释放信号量了。
Unlock 过程分为将 Locked 改为 0,然后判断 Waiter 的值: