一、锁资源介绍
1、乐观锁
乐观锁操作数据的时候很乐观,认为没有人同时修改数据,因此乐观锁不会上锁,在执行更新的时候判断在次期间别人是否修改了数据,如果修改了则进行回滚。使用版本号机制或者CAS算法实现。通常读多写少使用乐观锁。
版本号机制:修改数据的时候获取版本,更新操作进行的时候带上版本,若版本一致才会进行修改
CAS算法:包括3个操作数。
需要读写内存位置(V),进行比较的预期值(A),拟写入的新值(B)
操作流程如下:如果内存位置V的值与预期A值相等,则将A值改为B值。否则,不做任何操作。CAS一般是自旋的,如果操作不成功,稍后重试,直到成功为止。值得注意的是CAS是原子操作,虽然是2个步骤,但这是硬件支持的。
CAS的缺点:(一)ABA问题,也就是2个线程依次读内存的值。例如线程1和2,1线程将内存值改为X,2线程将内存值改为Y,2线程将内存值改为X,1线程继续操作。对于1线程看起来这个值没有被动过,但事实上已经被修改。解决的办法就是再带上版本号。(二)开销问题,CAS一直处于自旋状态,消耗CPU。(三)只能保证一个共享变量的原子操作。
乐观锁加锁吗?不加锁,只是修改之前比较内存的值。
2、悲观锁
总是觉得有人会修改数据,所以在修改数据之前先加锁。一旦加锁,只允许一个线程访问共享数据。通常写多读少使用悲观锁。Mysql的读锁、写锁、行锁都是使用悲观锁。
3、自旋锁
与互斥锁类似,在获取不到锁的时候是处于循环忙等,而不是休眠。
4、读写锁
当共享变量被加了写锁,其他线程对该锁加读锁和写锁都是阻塞的;
当共享变量被加了读锁,其他线程对该锁加写锁会阻塞,加读锁会成功。
所以读写锁是一种读共享,写独占的锁,适用与读多写少的情景。
二、mutex互斥锁源码解析
1、mutex工作模式
主要分为正常模式与饥饿模式
一个尝试加锁的goroutine会先自选几次,尝试通过原子操作获得锁,若自旋几次仍不能获得锁,则会通过信号量排队等待,(这种模式有更高的吞吐量,因为频繁的挂起和唤醒goroutine会带来较多的开销但不能无限制的自旋,)所有的等待者都会按照先进先出的顺序排队,但是当锁被释放后,第一个等待者被唤醒后,并不会直接拥有锁,而是需要后来者竞争,也就是那些处于自旋阶段,尚未排队等待的goroutine。这种情况下,后来者更有优势,一方面他们正在CPU上运行,自然比刚被唤醒的goroutine更有优势;另一方面处于自旋状态的goroutine可以有很多,而被唤醒的goroutine每次只有1个,所以被唤醒的goroutine有很大的概率拿不到锁。这种情况下会被重新插入到队列的头部,而不是尾部。
当goroutine加速等待时间超过1ms之后,会把当前mutex从正常模式切换到饥饿模式。在饥饿模式下,mutex的所有权从执行unlock的goroutine直接传递给等待队列头部的goutine。后来者不会自旋也不会尝试获得锁,即使mutex处于unlock的状态,他们会直接到队列的尾部排队等待。当一个等待者获得锁之后,它会在以下2种情况由饥饿模式切换到正常模式。1、等待时间小于1ms,刚来不久。2、是最后一个等待者,等待队列已经空了。后面自然就没有饥饿的goroutine了。所以在正常模式下,自旋和排队是同时存在的。饥饿模式下,没有自旋,都去排队,严格的先来后到。
2、源码分析
2.1 mutex结构体
1 | // mutex.go |
state是一个32位的bit,每一位都有其意义。
2.2 加锁
分为2种情况:
1)锁处于空闲状态,可以直接加锁。比如第一次goroutine抢占的时候
2)当有goroutine占用锁的时候,开始等待
1 | while(0) { |
精彩的部分就看源码吧!
1 | func (m *Mutex) Lock() { |
2.3 解锁
分为2种情况
1)若没有等待锁资源的goroutine,成功释放锁
2)有等待的goroutine,把锁给其他goroutine
1 | // 不能给未加锁的锁解锁 |
精彩部分看源码
1 | // 这里的释放锁的意思就是如果还有等待该资源的goroutine,会把该锁资源继续让给另外的goroutine(也就是加锁) |
后续文章还继续对以下内容进行分析
1、信号量的PV操作
1 | func runtime_SemacquireMutex(s *uint32, lifo bool, skipframes int) |
2、自旋的操作
1 | func runtime_canSpin(i int) bool |
3、Rwmutex 读写互斥锁
引用
- 编程迷失
- 乐观锁与悲观锁这一篇就够了
- 微信公众号:幼麟实验室
- 源码剖析golang中sync.Mutex