开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第3天,点击查看活动详情
前言
互斥锁是并发程序中对共享资源进行访问控制的主要手段,对此Golang官方提供了非常简单易用的两种锁,一种是互斥锁Mutex,另一种是读写锁RWMutex。互斥锁又叫写锁,读写锁又叫读锁,相互之间的关系是:
- 写锁需要阻塞写锁:一个协程拥有写锁时,其他协程写锁定需要阻塞
- 写锁需要阻塞读锁:一个协程拥有写锁时,其他协程读锁定需要阻塞
- 读锁需要阻塞写锁:一个协程拥有读锁时,其他协程写锁定需要阻塞
- 读锁不能阻塞读锁:一个协程拥有读锁时,其他协程也可以拥有读锁
两个锁对外都是暴露了两个方法:Lock()、UnLock()分别用于加锁和解锁
Mutex
源码包src/sync/mutex.go定义了互斥锁的数据结构
type Mutex struct {
state int32
sema uint32
}
Mutex.state表示互斥锁的状态,比如是否被锁定等Mutex.sema表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程
其中state是32位的整型变量,内部实现时将该变量分成四份,用于记录Mutex的四种状态
const (
mutexLocked = 1 << iota // mutex is locked //1
mutexWoken //2
mutexStarving //4
mutexWaiterShift = iota //3 这里没有写错哦
)
- Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。
- Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。释放锁时,如果正常模式下,不会再唤醒其他协程
- Starving:表示该Mutex是否处理饥饿状态, 0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms。
- Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。
源码中其实对锁如何使用已经做了详细的解释(都是英文,想看的可以自己去看,我直接写翻译好的):
互斥锁可以处于 2 种模式:正常模式和饥饿模式。
在正常模式下,等待的协程按 FIFO 先进先出顺序排队,但醒来的协程不拥有互斥锁,并与新到来的 goroutines 竞争锁的所有权。新到来的goroutine有一个优势 - 它们已经在CPU上运行,并且可能新到来的goroutine有很多,所以被唤醒的协程很有可能竞争锁失败。在这种情况下,被唤醒的协程会被放在等待队列的对头。如果协程超过 1 ms未能获取互斥锁,则会将互斥锁切换到饥饿模式。
在饥饿模式下,互斥锁的所有权直接从解锁程序移交给等待队列最前面的协程。新到来的goroutines不会尝试自旋获取锁,相反,他们将自己排在等待队列的尾部。
如果协程获取到互斥锁,他会查看: (1)它是否是等待队列中的最后一个协程,或(2)它等待的时间是否少于1ms。如果有一个条件满足,它将互斥锁切换回正常模式。
正常模式具有更好的性能,因为即使有在等待的协程,新的goroutine也可以连续获取互斥锁。而饥饿模式能够防止等待协程长时间获取不到锁
自旋
自旋其实就是进程不能进入临界区时是如何处理的,具体来说就是加锁时,如果发现该锁当前由其他协程持有,尝试加锁的协程并不是马上转入阻塞,而是会持续的探测锁是否被释放,这个过程即为自旋过程。自旋时间很短,但如果在自旋过程中发现锁已被释放,那么协程可以立即获取锁。
优势
自旋主要为了更加高效,减少损耗,自旋的优势是更充分的利用CPU,尽量避免协程切换。因为当前申请加锁的协程拥有CPU,如果经过短时间的自旋可以获得锁,当前协程可以继续运行,不必进入阻塞状态
条件
自旋不能随便使用,否则不但发挥不了优势,还会带来更多损耗,举个简单的例子:如果自旋次数不限制,而获得锁的进程很长时间后才释放锁,则自旋的进程这段时间CPU完全浪费了。
所以使用自旋,一定要满足一下条件:
- 自旋次数要足够小,通常为4,即自旋最多4次
- CPU核数要大于1,否则自旋没有意义,因为此时不可能有其他协程释放锁
- 调度机制中的Process数量要大于1,否则自旋没有意义
- 调度机制中的可运行队列必须为空,否则会延迟协程调度,需要把CPU让给更需要的进程
在了解了自旋的含义后,我们就可以对上面的解释加以总结:
Mutex模式
-
正常模式(不公平)
默认情况下,Mutex的模式为normal,该模式下,协程如果加锁不成功不会立即转入阻塞排队等待,而是判断是否满足自旋的条件,如果满足则会启动自旋过程,尝试抢锁。
-
饥饿模式(公平)
自旋过程中能抢到锁,一定意味着同一时刻有协程释放了锁,我们知道释放锁时如果发现有阻塞等待的协程,还会释放一个信号量来唤醒一个等待协程,被唤醒的协程得到CPU后开始运行,此时发现锁已被抢占了,自己只好再次阻塞,不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过1ms的话,会将Mutex标记为"饥饿"模式,然后再阻塞。
处于饥饿模式下,不会启动自旋过程,也即一旦有协程释放了锁,那么一定会唤醒等待队列中的队首协程,被唤醒的协程将会成功获取锁,同时也会把等待计数减1
Lock()加锁
假定当前只有一个协程在加锁,没有其他协程干扰,那么过程如下图所示:
加锁过程会判断Locked标志位是否为0,如果是0则把Locked置1,代表加锁成功
当加锁时,锁已经被其他协程占用
waiter等待队列计数器加一,此时协程B被阻塞,直到解锁时才会被唤醒
UnLock()解锁
解锁时,有1个或多个协程阻塞
协程A解锁过程分为两个步骤,一是把Locked位置0,二是查看到Waiter>0,所以释放一个信号量,唤醒一个阻塞的协程,被唤醒的协程B把Locked位置1,于是协程B获得锁。
为什么重复解锁会报panic
因为解锁的过程就是将Locked标志位置为0,然后判断等待队列中是否有值。有则释放信号量唤醒等待的协程,如果允许重复解锁,那么每次都要释放一个信号量,最终会唤醒多个协程,还是在Lock()的逻辑里抢锁,势必会影响复杂度,也会引起不必要的协程切换
参考
(18条消息) GO 互斥锁(Mutex)原理_Marvellous丶的博客-CSDN博客_go mutex原理
Go互斥锁实现原理 - 掘金 (juejin.cn)(大佬很强,解读源码很棒)