go底层系列-mutex原理剖析
文章目录
前言
- 互斥锁是并发程序中对共享资源进行访问控制的主要手段
- Go语言提供了非常简单易用的Mutex
- Mutex为一结构体类型
- 对外暴露两个方法Lock()和Unlock()
- 分别用于加锁和解锁
- Mutex使用起来非常方便,但其内部实现却复杂得多
- 这包括Mutex的几种状态
- Mutex为一结构体类型
- Mutex重复解锁引起panic。
Mutex数据结构
Mutex结构体
-
Mutex.state表示互斥锁的状态
- 比如是否被锁定等
-
Mutex.sema表示信号量
- 协程阻塞等待该信号量
- 解锁的协程释放信号量从而唤醒等待信号量的协程
- 协程阻塞等待该信号量
-
Mutex.state是32位的整型变量
- 内部实现时把该变量分成四份
- 用于记录Mutex的四种状态
- 内部实现时把该变量分成四份
下图展示Mutex的内存布局:
-
Locked: 表示该Mutex是否已被锁定
- 0:没有锁定
- 1:已被锁定
-
Woken: 表示是否有协程已被唤醒
- 0:没有协程唤醒
- 1:已有协程唤醒,正在加锁过程中
-
Starving:表示该Mutex是否处于饥饿状态
- 0:没有饥饿
- 1:饥饿状态
- 说明有协程阻塞了超过1ms
-
Waiter: 表示阻塞等待锁的协程个数
- 协程解锁时根据此值来判断是否需要释放信号量
-
协程之间抢锁实际上是抢给Locked赋值的权利
- 能给Locked域置1,就说明抢锁成功
- 抢不到的话就阻塞等待 Mutex.sema信号量
- 一旦持有锁的协程解锁,等待的协程会依次被唤醒
-
Woken和Starving主要用于控制协程间的抢锁过程
Mutex方法
- Mutext对外提供两个方法,实际上也只有这两个方法:
- Lock() : 加锁方法
- Unlock(): 解锁方法
- 下面我们分析一下加锁和解锁的过程
- 加锁分成功和失败两种情况
- 成功的话直接获取锁
- 失败后当前协程被阻塞
- 同样,解锁时跟据是否有阻塞协程也有两种处理
- 加锁分成功和失败两种情况
加解锁过程
简单加锁
假定当前只有一个协程在加锁,没有其他协程干扰,那么过程如下图所示:
- 加锁过程会去判断Locked标志位是否为0
- 如果是0则把Locked位置1,代表加锁成功
- 从上图可见,加锁成功后
- 只是Locked位置1,其他状态位没发生变化
加锁被阻塞
假定加锁时,锁已被其他协程占用了,此时加锁过程如下图所示:
- 从上图可看到
- 当协程B对一个已被占用的锁再次加锁时
- Waiter计数器增加了1
- 此时协程B将被阻塞
- 直到 Locked值变为0后才会被唤醒
- 当协程B对一个已被占用的锁再次加锁时
简单解锁
假定解锁时,没有其他协程阻塞,此时解锁过程如下图所示:
- 由于没有其他协程阻塞等待加锁
- 所以此时解锁时
- 只需要把Locked位置为0即可
- 不需要释放信号量
解锁并唤醒协程
假定解锁时,有1个或多个协程阻塞,此时解锁过程如下图所示:
- 协程A解锁过程分为两个步骤
- 一是把Locked位置0
- 二是查看到Waiter>0
- 所以释放一个信号量
- 唤醒一个阻塞的协程
- 被唤醒的协程B把Locked位置设为1
- 于是协程B获得锁
自旋过程
- 加锁时,如果当前Locked位为1
- 说明该锁当前由其他协程持有
- 尝试加锁的协程并不是马上转入阻塞
- 而是会持续的探测Locked位是否变为0
- 这个过程即为自旋过程
- 自旋时间很短,但如果在自旋过程中发现锁已被释放
- 那么协程可以立即获取锁
- 此时即便有协程被唤醒也无法获取锁,只能再次阻塞
- 自旋的好处是
- 当加锁失败时不必立即转入阻塞
- 有一定机会获取到锁
- 这样可以避免协程的切换
什么是自旋?
- 自旋对应于CPU的”PAUSE”指令
- CPU对该指令什么都不做,相当于CPU空转
- 对程序而言相当于sleep了一小段时间,时间非常短
- 当前实现是30个时钟周期
- 自旋过程中会持续探测Locked是否变为0
- 连续两次探测间隔就是执行这些PAUSE指令
- 它不同于sleep
- 不需要将协程转为睡眠状态
自旋条件
- 加锁时程序会自动判断是否可以自旋
- 无限制的自旋将会给CPU带来巨大压力
- 所以判断是否可以自旋就很重要了
- 自旋必须满足以下所有条件:
- 自旋次数要足够小,通常为4
- 即自旋最多4次
- CPU核数要大于1
- 否则自旋没有意义
- 因为此时不可能有其他协程释放锁
- 协程调度机制中的Process数量要大于1
- 比如使用GOMAXPROCS()将处理器设置为1就不能启用自旋
- 协程调度机制中的可运行队列必须为空
- 否则会延迟协程调度
- 自旋次数要足够小,通常为4
- 可见,自旋的条件是很苛刻的
- 总而言之就是不忙的时候才会启用自旋
自旋的优势
- 自旋的优势是
- 更充分的利用CPU,尽量避免协程切换
- 因为当前申请加锁的协程拥有CPU
- 如果经过短时间的自旋可以获得锁
- 当前协程可以继续运行,不必进入阻塞状态
- 更充分的利用CPU,尽量避免协程切换
自旋的问题
- 如果自旋过程中获得锁
- 那么之前被阻塞的协程将无法获得锁
- 如果加锁的协程特别多,每次都通过自旋获得锁
- 那么之前被阻塞的进程将很难获得锁,从而进入饥饿状态
- 为了避免协程长时间无法获取锁
- 自1.8版本以来增加了一个状态,即Mutex的Starving状态
- 这个状态下不会自旋
- 一旦有协程释放锁
- 那么一定会唤醒一个协程并成功加锁
Mutex模式
- 前面分析加锁和解锁过程中只关注了Waiter和Locked位的变化
- 现在我们看一下Starving位的作用
- 每个Mutex都有两个模式
- Normal
- Starving
normal模式
- 默认情况下,Mutex的模式为normal,该模式下
- 协程如果加锁不成功
- 不会立即转入阻塞排队
- 而是判断是否满足自旋的条件
- 如果满足则会启动自旋过程
- 尝试抢锁
- 协程如果加锁不成功
starvation模式
- 自旋过程中能抢到锁
- 一定意味着同一时刻有协程释放了锁
- 释放锁时如果发现有阻塞等待的协程
- 还会释放一个信号量来唤醒一个等待协程
- 被唤醒的协程得到CPU后开始运行
- 此时发现锁已被抢占了,自己只好再次阻塞
- 不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间
- 如果超过1ms的话
- 会将Mutex标记为”饥饿”模式
- 然后再阻塞
- 处于饥饿模式下
- 不会启动自旋过程
- 也即一旦有协程释放了锁,那么一定会唤醒协程
- 被唤醒的协程将会成功获取锁
- 同时也会把等待计数减1
- 还会释放一个信号量来唤醒一个等待协程
woken状态
- Woken状态用于加锁和解锁过程的通信
- 举个例子:同一时刻,两个协程一个在加锁,一个在解锁
- 在加锁的协程可能在自旋过程中
- 此时把Woken标记为1
- 用于通知解锁协程不必释放信号量了
- 好比在说:你只管解锁好了,不必释放信号量,我马上就拿到锁了
- 在加锁的协程可能在自旋过程中
- 举个例子:同一时刻,两个协程一个在加锁,一个在解锁
为什么重复解锁要panic
- 可能你会想
- 为什么Go不能实现得更健壮些,多次执行Unlock()也不要panic?
- 仔细想想Unlock的逻辑就可以理解,这实际上很难做到
- Unlock过程分为将Locked置为0
- 然后判断Waiter值
- 如果值>0,则释放信号量
- 如果多次Unlock()
- 那么可能每次都释放一个信号量
- 这样会唤醒多个协程
- 多个协程唤醒后会继续在Lock()的逻辑里抢锁
- 势必会增加Lock()实现的复杂度
- 也会引起不必要的协程切换。
- 那么可能每次都释放一个信号量
编程Tips
- 使用defer避免死锁
- 加锁后立即使用defer对其解锁
- 可以有效的避免死锁
- 加锁和解锁应该成对出现
- 加锁和解锁最好出现在同一个层次的代码块中
- 比如同一个函数
- 重复解锁会引起panic
- 应避免这种操作的可能性
- 加锁和解锁最好出现在同一个层次的代码块中
rwmutex
前言
-
所谓读写锁RWMutex
-
完整的表述应该是读写互斥锁
-
可以说是Mutex的一个改进版
-
在某些场景下可以发挥更加灵活的控制能力
- 比如:读取数据频率远远大于写数据频率的场景
- 例如,程序中写操作少而读操作多
-
简单的说,如果执行过程是1次写然后N次读的话
-
使用Mutex,这个过程将是串行的
- 因为即便N次读操作互相之间并不影响
- 但也都需要持有Mutex后才可以操作
-
如果使用读写锁
-
多个读操作可以同时持有锁
- 并发能力将大大提升
-
-
-
-
实现读写锁需要解决如下几个问题:
- 写锁需要阻塞写锁:
- 一个协程拥有写锁时,其他协程写锁定需要阻塞
- 写锁需要阻塞读锁:
- 一个协程拥有写锁时,其他协程读锁定需要阻塞
- 读锁需要阻塞写锁:
- 一个协程拥有读锁时,其他协程写锁定需要阻塞
- 读锁不能阻塞读锁:
- 一个协程拥有读锁时,其他协程也可以拥有读锁
- 写锁需要阻塞写锁:
读写锁数据结构
类型定义
- 由以上数据结构可见
- 读写锁内部仍有一个互斥锁
- 用于将两个写操作隔离开来
- 其他的几个都用于隔离读操作和写 操作
- 读写锁内部仍有一个互斥锁
接口定义
- RLock():读锁定
- RUnlock():解除读锁定
- Lock(): 写锁定
- 与Mutex完全一致
- Unlock():解除写锁定
- 与Mutex完全一致
Lock()实现逻辑
- 写锁定操作需要做两件事:
- 获取互斥锁
- 阻塞等待所有读操作结束(如果有的话)
- 所以 func (rw RWMutex) Lock() 接口实现流程如下图所示:
Unlock()实现逻辑
- 解除写锁定要做两件事:
- 唤醒因读锁定而被阻塞的协程(如果有的话)
- 解除互斥锁
所以 func (rw RWMutex) Unlock() 接口实现流程如下图所示:
RLock()实现逻辑
- 读锁定需要做两件事:
- 增加读操作计数
- 即readerCount++
- 阻塞等待写操作结束(如果有的话)
- 增加读操作计数
- 所以 func (rw RWMutex) RLock() 接口实现流程如下图所示:
RUnlock()实现逻辑
- 解除读锁定需要做两件事:
- 减少读操作计数
- 即readerCount—
- 唤醒等待写操作的协程(如果有的话)
- 减少读操作计数
- 所以 func (rw RWMutex) RUnlock() 接口实现流程如下图所示:
场景分析
上面我们简单看了下4个接口实现原理,接下来我们看一下是如何解决前面提到的几个问题的。
写操作是如何阻止写操作的
- 读写锁包含一个互斥锁(Mutex)
- 写锁定必须要先获取该互斥锁
- 如果互斥锁已被协程A获取(或者协程A在阻塞等待读结束)
- 那么协程B只能阻塞等待该互斥锁。
- 所以
- 写操作依赖互斥锁阻止其他的写操作
写操作是如何阻止读操作的
- 这个是读写锁实现中最精华的技巧
- 我们知道RWMutex.readerCount是个整型值
- 用于表示读者数量
- 不考虑写操作的情况下,每次读锁定将该值+1, 每次解除读锁定将该值-1
- 所以readerCount取值为[0, N],N为读者个数
- 实际上最大可支持2^30个并发读者
- 当写锁定进行时
- 会先将readerCount减去2^30
- 从而readerCount变成了负值
- 此时再有读锁定到来时检测到 readerCount为负值
- 便知道有写操作在进行
- 只好阻塞等待
- 而真实的读操作个数并不会丢失
- 只需要将 readerCount加上2^30即可获得
- 所以,写操作将readerCount变成负值来阻止读操作的
读操作是如何阻止写操作的
- 读锁定会先将RWMutext.readerCount加1
- 此时写操作到来时发现读者数量不为0
- 会阻塞等待所有读操作结束
- 所以,读操作通过readerCount来将来阻止写操作的
为什么写锁定不会被饿死
- 我们知道,写操作要等待读操作结束后才可以获得锁
- 写操作等待期间可能还有新的读操作持续到来
- 如果写操作等待所有读操作结束,很可能被饿死
- 然而,通过RWMutex.readerWait可完美解决这个问题
- 写操作到来时
- 会把RWMutex.readerCount值拷贝到RWMutex.readerWait中
- 用于标记排在写操作前面的读者个数
- 前面的读操作结束后
- 除了会递减RWMutex.readerCount
- 还会递减RWMutex.readerWait值
- 当 RWMutex.readerWait值变为0时唤醒写操作
- 前面的读操作结束后
- 所以,写操作就相当于把一段连续的读操作划分成两部分
- 前面的读操作结束后唤醒写操作
- 写操作结束后唤醒后 面的读操作
- 写操作到来时
- 如下图所示: