并发编程中常见的锁机制:乐观锁、悲观锁、CAS、自旋锁、互斥锁、读写锁


乐观锁 VS 悲观锁

乐观锁和悲观锁故名思意,它们的区别就是做事的心态不同

悲观锁

悲观锁做事比较悲观,它始终认为共享资源在我们使用的时候会被其他线程修改,容易导致线程安全的问题,因此在访问共享数据之前就要先加锁,阻塞其他线程的访问

常见的例子就是数据库中的行锁、表锁、读锁、写锁等


乐观锁

乐观锁则于悲观锁相反,它则比较乐观。它始终认为多线程同时修改共享资源的概率较低,所以先不管三七二十一,改了再说。

乐观锁会直接对共享资源进行修改,但是在更新修改结果之前它会验证这段时间有没有其他线程对资源进行修改,如果没有则提交更新,如果有的话则放弃本次操作。

由于乐观锁全程没有进行加锁,所以它也被称为无锁编程通常以CAS操作+版本号机制实现


CAS

CAS机制

CAS是英文单词Compare And Swap的缩写,也就是比较和替换,这也正是它的核心。
CAS机制中用到了三个基本操作数,内存地址V,旧预期值A,新预期值B

当我们需要对一个变量进行修改时,会对内存地址V和旧预期值进行比较,如果两者相同,则将旧预期值A替换成新预期值B。而如果不同,则将V中的值作为旧预期值,继续重复以上操作,即自旋

下面分别举出成功和失败的例子
此时内存地址中存储的值为9,线程1的旧预期值为9,新预期值为10,即我们要对里面的值进行一个加一操作
在这里插入图片描述
此时旧预期值与V相同,将B与V交换
在这里插入图片描述
此时修改成功。

扫描二维码关注公众号,回复: 11978648 查看本文章

接着看看修改失败的情况

此时V中的值为9,线程1中的旧预期值为9,想将V中的值修改为10
在这里插入图片描述
当我们正要开始修改时,突然一个线程抢先更新数据,此时V的值变为了14
在这里插入图片描述
由于此时A的值与V不同,我们就需要重新获取V中的值,并计算出新的预期值
在这里插入图片描述

此时两者相同,完成替换,V=15

从上面可以看出,CAS是乐观锁,它乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。


ABA问题

所谓的ABA问题,就是将一个变量从A变为了B,再从B变为了A

假设我正在银行中提款,此时我的账户中有1000元,我想从中取出500元,但是由于忽然的网络波动,此时这个操作被重复了两次,于是如下图
在这里插入图片描述
此时我们只能执行第一个扣费,由于执行完后A != V,所以第二个线程即不断自旋比较
在这里插入图片描述
此时正好舍友还了你几年前借的500块钱,你的金额又重新变为了1000
在这里插入图片描述
这时线程2又给你扣了500,于是你取出了500块钱,却意外的扣了1000
在这里插入图片描述
那么这个问题如何解决呢?我们可以引入版本号机制只有版本号相同的时候才能进行替换操作
在这里插入图片描述
当舍友给你转账的时候,由于数值发生了变化,版本号也得到了修改
在这里插入图片描述
此时虽然我们A和V中的数值相同,但是版本号不同,所以无法进行交换


CAS的优缺点

优点

  • 在并发量少或者对变量修改操作少的时候,效率会比传统的加锁高,因为不涉及用户态和内核态的切换。

缺点

  • 自旋进行比较和替换,当并发量大的时候可能会因为变量一直更新而无法比较成功,而不断地进行自旋,导致CPU压力过大
  • CAS只能保证一个变量的原子性,并不能保证整个代码块的原子性,所以在处理多个变量的原子性更新的时候还是得加锁。
  • 上述的ABA问题,可以通过引入版本号解决

互斥锁 VS 自旋锁

互斥锁和自旋锁是最底层的两种锁,大部分的高级锁都是基于它们实现的,下面就来讲讲它们的区别

互斥锁

互斥锁是一种睡眠锁,即当一个线程占据了锁之后,其他加锁失败的线程就会进行睡眠

例如我们有A、B两个线程一同争抢互斥锁,当线程A成功抢到了互斥锁时,该锁就被他独占,在它释放锁之前,B的加锁操作就会失败,并且此时线程B将CPU让给其他线程,而自己则被阻塞。

对于互斥锁加锁失败后进入阻塞的现象,由操作系统的内核实现,如下图

在这里插入图片描述

  • 当加锁失败时,内核会将线程置为睡眠状态,并将CPU切换给其他线程运行。此时从用户态切换至内核态
  • 当锁被释放时,内核将线程至为就绪状态,然后在合适的时候唤醒线程获取锁,继续执行业务。此时从内核态切换至用户态

所以当互斥锁加锁失败的时候,就伴随着两次上下文切换的开销,而如果我们锁定的时间较短,可能上下文切换的时间会比锁定的时间还要长。

虽然互斥锁的使用难度较低,但是考虑到上下文切换的开销,在某些情况下我们还是会优先考虑自旋锁。


自旋锁

自旋锁是基于CAS实现的,它在用户态完成了加锁和解锁的操作,不会主动进行上下文的切换,因此它的开销相比于互斥锁也会少一些。

任何尝试获取该锁的线程都将一直进行尝试(即自旋),直到获得该锁,并且同一时间内只能由一个线程能够获得自旋锁。

自旋锁的本质其实就是对内存中一个整数的CAS操作,加锁包含以下步骤

  1. 查看整数的值,如果为0则说明锁空闲,则执行第二步,如果为1则说明锁忙碌,执行第三步
  2. 将整数的值设为1,当前线程进入临界区中
  3. 继续自旋检查(回到第一步),直到整数的值为0

从上面可以看出,对于获取自旋锁失败的线程会一直处于忙等待的情况,不断自旋直至获取锁资源,这也就要求我们必须要尽快释放锁,否则会占用大量的CPU资源


对比及应用场景

由于自旋锁和互斥锁的失败策略不同,自旋锁采用忙等待的策略,而互斥锁采用线程切换的策略,由于策略不同,它们的应用场景也不同。

由于自旋锁不需要进行线程切换,所以它完全在用户态下实现,加锁开销低,但是由于其采用忙等待的策略,对于短期加锁来说没问题,但是长期锁定的时候就会导致CPU资源的大量消耗。并且由于它不会睡眠,所以它可以应用于中断处理程序中。

互斥锁采用线程切换的策略,当切换到别的线程的时候,原线程就会进入睡眠(阻塞)状态,所以如果对睡眠有要求的情况可以考虑使用互斥锁。并且由于睡眠不会占用CPU资源,在长期加锁中它比起自旋锁有极大的优势

具体的应用场景如下表格所示

需求 加锁方式
低开销加锁 自旋锁
短期锁定 自旋锁
长期锁定 互斥锁
中断上下文中加锁 自旋锁
持有锁需要睡眠 互斥锁

读写锁

读写锁用于明确区分读操作和写操作的场景

其核心在于写独占,读共享

  • 读锁是一个共享锁,当没有线程持有写锁的时候,读锁就可以被多个线程并发的持有,大大的提高了共享资源的访问效率。由于读锁只具备读权限,因此不存在线程安全问题。
  • 写锁是一个独占锁(排他锁),当有任何一个线程持有写锁的时候,其余线程获取读锁和写锁的操作都会被阻塞

如下图

读锁 写锁
读锁 兼容 不兼容
写锁 不兼容 不兼容

实现方式

根据实现方式的不同,读写锁又分为读者优先、写者优先、读写公平

读者优先

读者优先期望的是读锁能够被更多的线程持有,以提高读线程的并发性。

为了做到这一点,它的规则如下:即使有线程申请了写锁,但是只要还有读者在读取内容,就允许其他的读线程继续申请读锁,而将申请写锁的进程阻塞,直到没有读线程在读时,才允许该线程写

流程如下图
在这里插入图片描述

写者优先

而写者优先则是优先服务于写进程

假设此时有读线程已经持有读锁,正在读,而另一写线程申请了写锁,写线程被阻塞。为了能够保证写者优先,此时后来的读线程获取读锁时则会被阻塞。而当先前的读线程释放读锁时,写线程则进行写操作,直到写线程写完之前,其他的线程都会被阻塞。

流程如下图
在这里插入图片描述

读写公平

从上面两个规则可以看出,读写优先都会导致另一方饥饿

  • 读者优先时,对于读进程并发性高,但是如果一直有都进程获取读锁,就会导致写进程永远获取不到写锁,此时就会导致写进程饥饿。
  • 写者优先时,虽然可以保证写进程不会饿死,但是如果一直有写进程获取写锁,导致读进程永远获取不到读锁,此时就会导致读进程饥饿。

既然偏袒哪一方都会导致另一方被饿死,所以我们可以搞一个读写公平的规则

实现方式:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁,这也读线程仍然可以并发,也不会出现饥饿的情况。


读写锁 VS 互斥锁

性能方面来说,读写锁的效率并不比互斥锁高。读锁加锁的开销并不比互斥锁小,因为它要实时维护当前读者的数量,在临界区很小,锁竞争不激烈的情况下,互斥锁的效率往往更快

虽然读写锁在速度上可能不如互斥锁,但是并发性好,对于并发要求高的地方,应该优先考虑读写锁。

猜你喜欢

转载自blog.csdn.net/qq_35423154/article/details/109259881