高并发,golang推出的最大卖点之一;那么,在并发中,临界资源的同步又是怎么保护的呢?
今天,我们从浅入深的解析解析golang的互斥锁对象Mutex
注释说明:本文源码基于1.16版本进行分析解读
简单使用
使用非常简单,Lock()进行加锁,Unlock()进行解锁;加锁失败时,Lock()方法堵塞,等待直至获取锁
实现原理分析
- 结构定义
走进golang源码,先看下Sync.Lock的定义
非常简单,就两个成员:
- state
- sema
其中,state采用不同位表示不同含义的方式进行定义的,按从低到高位一次排列:
- 第一位表示加锁状态:0表示未加锁,1表示已加锁
- 第二位表示唤醒状态:0表示未设置被唤醒状态,1表示已设置被唤醒状态
- 第三位表示运行状态:0表示正常状态,1表示饥饿状态
- 第四位以及其他字节位:表示等待获取锁的数目
另外的sema为信号量,通过其P/V操作实现锁的等待和唤醒
加锁逻辑
- 快速加锁
采用CAS实现快速加锁
2. 缓慢加锁(slowLock)
为了充分提高加锁效率,golang的mutex的运行状态有两种:正常状态和饥饿状态
正常状态下:刚进入锁的协程可通过自旋锁的方式快速获取锁;
饥饿模式下:所有锁的获取者都通过FIFO的方式公平的获取锁资源
- 自旋锁
从操作系统的角度来看,相比睡眠方式的互斥锁,自旋锁通过自旋占用CPU,避免的CPU的上下文切换,因此在持锁时长较短的情况下,性能更优
golang的mutex在实现时,充分考虑了自旋锁跟睡眠锁的取舍;即:在持锁时长较短时,采用自旋锁,在持锁时长较大时,采用睡眠锁
再看看其源码实现
slowLock part1
非饥饿状态下,自旋后获取新的state锁信息,如果发现锁状态为未加锁状态,则使用CAS获取锁
runtime_canSpin
源码解析,在以下情况下不可进入自旋:
- 迭代次数(入参i) 大于等于4
- cpu为单核
- GOMAXPROCS大于1且至少有一个其他处理运行状态的P
- 当前P没有其他等待运行的G
doSpin
深入分析,procyield底层采用了循环条用PAUSE和JNZ指令
饥饿状态
正常模式下,由于刚进入锁的协程有较高的获取锁优势,所以可能会出现某个进程一直获取不到锁的情况发生,golang的Mutex为了避免这种情况,引入了饥饿状态
在某个协程超过1ms依旧没有获取到锁的情况下,mutex进入饥饿模式;进入饥饿模式后,刚进入锁的协程不进行自旋,直接放入到sema信号量的队尾,并将锁的等待着加1;
依旧看看其源码实现
解锁逻辑
相对加锁逻辑,解锁逻辑相对简单;
- 快速解锁
解锁后,锁状态恢复到初始状态(即没有其他获取锁的协程)
2. 缓慢解锁(slowUnlock)
异常处理
针对未加锁的情况下调用解锁逻辑,直接抛出异常
饥饿模式
饥饿模式下,直接调用runtime_Semrelease唤醒对头协程
正常模式
正常模式下,如果锁的等待者为0或者锁已经被其他进入锁的协程获取了,则不唤醒等待协程
锁特性总结
- 总体上,golang的互斥锁实现为一公平锁,不会出现某进程被“饿死”的情况;但正常模式下,被唤醒的协程需要与当前进入锁的协程竞争锁,但通常情况下,当前进入锁的协程拥有更好的获取锁的优势
- 非并发场景下,mutex通过CAS实现快速加锁、解锁
- 正常模式下,通过自旋实现快速获取锁
- 饥饿模式下,采用FIFO方式获取锁
- 加锁解锁不限制于相同的协程中
学习总结
golang的Mutex实现非常简洁、代码量也才100来行;但是其设计思想、理念却值得深思、学习,学习过程中,这100来行代码,断断续续花了2天的时间,收获却是非常的饱满,非常值得回顾总结