斥锁是并发程序中对共享资源进行访问控制的主要手段.Go语言提供了非常简单易用
的Mutex.Mutex为结构体类型.对外暴露了Lock()和Unlock()方法.用于加锁和解锁.
1.Mutex数据结构:
源码位置src/internal/sync/mutex.go:Mutex.
// A Mutex is a mutual exclusion lock.
//
// See package [sync.Mutex] documentation.
type Mutex struct {
state int32
sema uint32
}
Mutex.state表示互斥锁的状态.比如锁是否被锁定.
Mutex.sema表示信号量.协程阻塞等待该信号量.解锁的协程释放信号量从而唤醒等
待信号量的协程.
2.Mutex内存布局:
Locked:表示Mutex是否已被锁定.0表示没有锁定.1表示已锁定.
Woken:表示是否有协程已被唤醒.0表示没有协程唤醒.1表示已有协程唤醒.正在加锁
过程中.
Starving:表示Mutex是否处于饥饿状态.0表示没有饥饿.1表示饥饿.说明有协程阻
塞了超过1ms.
Waiter:表示阻塞等待锁的协程个数.协程解锁时根据此值来判断是否需要释放信号
量.协程之间的抢锁实际上是抢给Locked赋值的权利.能给Locked域置1.就说明抢锁
成功.抢不到就阻塞等待Mutex.sema信号量.一旦持有锁的协程解锁.等待的协程就
会依次被唤醒.
3.Mutex对外方法:
Mutex对外提供的方法主要是加锁和解锁.
Lock():加锁方法.
Unlock():解锁方法.
TryLock():以非阻塞方式尝试加锁(Go1.18引入).
4.加解锁过程:
1).简单加锁:
加锁过程会判断Locked标记位是否为0.如果是0则把Locked位置为1.代表加锁成功.
由上图可知.加锁成功后.只是Locked变为1.其他状态为没有变化.
2).加锁被阻塞:
假定加锁时.锁被其他线程占用了.如下图所示:
由上图可知.当协程B对一个已被占用的锁再次进行加锁时.Waiter计数器增加了1.此
时协程B将被阻塞.直到Locked为0才会被唤醒.
3).简单解锁:
假定解锁时.没有其他协程阻塞.过程如下图.
由于没有其他协程阻塞等待加锁.所以此时解锁只需要把Locked位置置为0即可.不需
要释放信号量.
4).解锁并唤醒协程:
协程A解锁分为两个步骤.一是把Locked置为0.然后是查看Waiter>0.然后释放一个
信号量.唤醒一个阻塞的协程.被唤醒的协程把Locked置为1.于是协程B获得锁.
5.自旋过程:
加锁时如果当前Locked位为1.则说明该锁当前由其他协程持有.尝试加锁的协程并不
能马上转入阻塞.而是会持续的探测Locked位是否变为0.这个过程称为自旋.自旋的
时间很短.如果在自旋的过程中发现锁已经被释放.那么协程立即获取锁.此时即便有协
程被唤醒也无法获取锁.只能再次阻塞.
自旋的好处是.当加锁失败时不必立即转入阻塞.有一定机会获取锁.可以避免协程的切
换.
1).什么是自旋:
自旋对应于CPU的PAUSE指令.CPU对该指令什么都不做.相当于CPU空转.对程序而
言相当于"sleep"了一段时间.时间非常短.当前实现是30时钟周期.
自旋过程会持续探测Locked是否变为0.连续两次探测间隔就是在执行这些PAUSE指
令.它不同于sleep.不需要将协程转为睡眠状态.
2).自旋条件:
加锁过程时会自动判断是否可以自旋.无限制的自旋会给CPU带来巨大的压力.
1.自旋次数要足够小.通常为4.即自旋最多为4次.
2.CPU核数要大于1.否则自旋没有意义.因为此时不可能有其他协程释放锁.
3.协程调度机制中的Processor数量要大于1.比如使用COMAXPROCS()将处理器
设置为1就不能启用自旋.
4.协程调度机制中的可运行队列必须为空.否则会延迟调度.
3).自旋的优势:
更充分的利用CPU.尽量避免协程切换.因为当前申请加锁的过程拥有CPU.如果经过
短时间的自旋可以获取锁.则当前协程可以继续运行.不必进入阻塞状态.
4).自旋的问题:
如果自旋过程中获得锁.那么之前被阻塞的协程将无法获得锁.如果加锁的协程特别多.
每次都通过自旋获得锁.会导致之前被阻塞的进程将很难获得锁.从而进入Straving.
为了避免协程长时间无法获取锁.自1.8版本以来增加了一个状态.Mutex的
Straving.在这个状态下不会自旋.一旦有协程释放锁.一定会唤醒一个协程加锁成功.
6.Mutex模式:
每个Mutex都有两个模式.称为Normal和Straving.
1)Normal模式:
默认情况下.Mutex模式为Normal.
在该模式下.如果协程加锁不成功不会立即转入阻塞排队.而是会判断是否满足自旋条
件.如果满足则会启动自旋过程.尝试抢锁.
2).Straving模式(也称作饥饿模式):
自旋过程中能抢到锁.一定意味着同一时刻有协程释放了锁.释放锁如果发现有阻塞等
待的协程.那么还会释放一个信号量来唤醒一个等待协程.被唤醒的协程得到CPU后开
始运行.发现锁已经被抢占了.自己再次阻塞.阻塞之前会判断自己阻塞了多长时间.如
果超过1ms.将会把Mutex标记位Straving模式.然后阻塞.
7.Woken状态:
Woken状态用于加锁和解锁过程的通信.同一时刻.两个协程一个在加锁.另一个在解
锁.加锁的协程可能在自旋中.此时把Woken状态标记位1.通知解锁协程不用释放信
号量了.好比再说.你只管解锁.不必释放信号量.我马上拿到锁了.
8.为什么重复解锁会触发panic:
从Unlock释放可以理解.Unlock分为将Locked置为0和判断Waiter值两个过程.如
果Waiter>0.释放信号量.多次执行Unlock会释放多个信号量.这样会唤醒多个协程.
多个协程在Lock()逻辑中抢锁.会增加Lock()逻辑的复杂性.也会引起不必要的协程切
换.
9.Tips:
1).使用defer避免死锁
加锁后立即使用defer对其解锁.可以有效的避免死锁.
2)加锁和解锁应该成对出现.
加锁和解锁最好出现在同一个层次的代码块中.比如同一个函数.
重复解锁会引起panic.应该避免出现这种操作.
为你.千千万万遍.