无锁CAS

一、自旋锁

1.1 自旋锁定义

自旋锁是专为防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,即在标志寄存器中关闭/打开中断标志位,不需要自旋锁)。

自旋锁是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者。也就是说,在任何时刻最多只能有一个执行单元获得锁。


1.2 自旋锁的基本形式

#include <pthread.h>

static pthread_spinlock_t spinlock;
pthread_spin_init(&spinlock, PTHREAD_PROCESS_PRIVATE);

pthread_spin_lock(&spinlock);
//临界区
pthread_spin_unlock(&spinlock);

1.3 自旋锁特性

1、自旋锁具有以下特点
1)线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
2)自旋锁等待期间,线程状态不会改变,线程一直是用户态并且使活动的(active)。
3)自旋锁如果持有锁的时间太长,则会阻止其他线程的运行和调度,导致其他等待获取锁的线程耗尽cpu。
4)自旋锁本身无法保证公平性,同时也无法保证可重入性。

2、优点
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的,不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快。

非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。(线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)


1.4 自旋锁与互斥锁区别

对于互斥锁mutex,如果资源已经被占用,资源申请者只能进入睡眠状态。
但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。

自旋锁:如果不能及时获取,自旋锁是一个盲等待的过程,占CPU。
执行任务特点:

  • 不存在阻塞
  • 任务耗时短

以下执行两个语句的情况没法使用原子变量:

count++;
sum+=count;

归纳有以下几点区别:

  • Mutex:获取不到锁就休眠,让出CPU。尝试获取锁并不耗时,等待锁比较耗时。
    自旋锁:获取不到锁,继续检测。
  • Mutex:适合保持时间较长的情况,会导致调用者睡眠,只能在进程上下文使用。
    自旋锁:适合保持时间较短的情况,不会导致调用者睡眠,可以用在任意上下文。
  • 如果被保护的共享资源需要在终端上下文访问(包括底半部即中断处理句柄和顶半部即软中断),就必要使用自旋锁。
  • 信号量和读写信号量保持期间是可以被抢占的。
    自旋锁保持期间是抢占失效的。
  • 自旋锁只有在内核可抢占或SMP(多处理器)的情况下才真正需要,在单CPU且不可抢占的内核下,自旋锁的所有操作都是空操作基本没有什么作用。自旋锁在内核中主要用来防止多处理器中并发访问临界区,防止内核抢占造成的竞争。
  • 自旋锁不允许任务睡眠,持有自旋锁的任务睡眠会造成死锁(因为睡眠有可能造成持有锁的内核任务被重新调度,而再次申请自己已持有的锁)。

获取公共资源的时候一般使用mutex。
自旋锁比较适用于锁使用保持时间短的情况。正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋锁而不是睡眠非常必要的。旋锁的效率高于互斥锁,可以在任何上下文使用。

比如:
mysql只有一条连接,有3个线程请求,选择使用mutex。
如果使用自旋锁就A线程请求的时候,B和C的请求就会一直耗时等待。
如果每个请求端需要耗时30ms,那么B执行是在30ms后,C是在60ms后执行。这样非常占用CPU。
在这里插入图片描述


1.5 锁的选择

什么时候使用mutex、什么时候使用atomic、什么时候使用spinlock?
mutex:共享区域运行时间比较长。
atomic:简单的数值加减操作。
spinlock:执行的语句少,非阻塞。



二、CAS

2.1 CAS定义

比较并交换(compare and swap)是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。该操作通过将内存中的值与指定的数据进行比较,当数值一样时将内存中的数据替换为新的值。

bool CAS(int * pAddr, int nExpected, int nNew)
 atomically {
    
    
     if ( *pAddr == nExpected ) {
    
    
          *pAddr = nNew ;
          return true ;
     }
     else
         return false ;
}

2.2 CAS算法理解

CAS是一种无锁算法,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当旧的预期值A和内存旧值V相同时,将内存值V修改为B,否则什么都不做。

CAS比较与交换的伪代码可以表示为:

do{
    
    
		备份旧数据;
		基于旧数据构造新数据;
}while(!CAS(内存地址, 备份的旧数据, 新数据))

流程示例:
在这里插入图片描述
实现:线程1和线程2同时更新变量10
分析:因为线程1和线程2同时去访问同一个变量10,它们会把主内存的值完全拷贝一份到自己的工作内存空间,所以线程1和线程2 的预期值都为10;假设线程1在与线程2竞争中,线程1能去更新变量的值,而线程2失败(失败的线程并不会被挂起,而是被告知这次竞争失败,并可以再次发起尝试)。线程1将数据更新为11,然后写到内存中。此时对于线程2来说,内存值变为了11,与预期值10不一致,就操作失败了。

即当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。

容易看出 CAS 操作是基于共享数据不会被修改的假设,采用了类似于数据库的commit-retry 的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。


2.3 CAS开销

CAS是CPU指令级的操作,只有一步原子操作,所以非常快。
而且CAS避免了请求操作系统来裁定锁的问题,不用麻烦操作系统,直接在CPU内部就搞定了。

是不是CAS就没有开销了吗?…不!有cache miss的情况。

为了理清CAS开销,我们首先需要了解CPU的硬件体系结构
在这里插入图片描述
上图可以看到,一个8核CPU计算机系统,每个CPU有cache(CPU内部的高速缓存,寄存器),管芯内还带有一个互联模块,使管芯内的两个核可以相互通信。

在图中央的系统互联模块可以让四个管芯相互通信,并且将管芯与主存连接起来。数据以“缓存线”为单位在系统中传输,“缓存线”对应于内存中一个 2 的幂大小的字节块,大小通常为 32 到 256 字节之间。

当 CPU 从内存中读取一个变量到它的寄存器中时,必须首先将包含了该变量的缓存线读取到 CPU 高速缓存。同样地,CPU 将寄存器中的一个值存储到内存时,不仅必须将包含了该值的缓存线读到 CPU 高速缓存,还必须确保没有其他 CPU 拥有该缓存线的拷贝。

Eg:
如果CPU0在对第一个变量执行“比较并交换”(CAS操作),而该变量所在的缓存线在CPU7的高速缓存中,就会发生以下经过简化的事件序列:
1)CPU0检查本地高速缓存,没有找到缓存线。
2)请求被转发到 CPU0 和 CPU1 的互联模块,检查 CPU1 的本地高速缓存,没有找到缓存线。
3)请求被转发到系统互联模块,检查其他三个管芯,得知缓存线被 CPU6和 CPU7 所在的管芯持有。
4)请求被转发到 CPU6 和 CPU7 的互联模块,检查这两个 CPU 的高速缓存,在 CPU7 的高速缓存中找到缓存线。
5)CPU7 将缓存线发送给所属的互联模块,并且刷新自己高速缓存中的缓存线。
6)CPU6 和 CPU7 的互联模块将缓存线发送给系统互联模块。
7)系统互联模块将缓存线发送给 CPU0 和 CPU1 的互联模块。
8)CPU0 和 CPU1 的互联模块将缓存线发送给 CPU0 的高速缓存。
9)CPU0 现在可以对高速缓存中的变量执行 CAS 操作了。

Eg分析:
1)最好情况下的 CAS 操作消耗大概 40 纳秒,超过 60 个时钟周期。这里的“最好情况”是指对某一个变量执行 CAS 操作的 CPU 正好是最后一个操作该变量的CPU,所以对应的缓存线已经在 CPU 的高速缓存中了。
2)最好情况下的锁操作(一个“round trip 对”包括获取锁和随后的释放锁)消耗超过 60 纳秒,超过 100 个时钟周期。这里的“最好情况”意味着用于表示锁的数据结构已经在获取和释放锁的 CPU 所属的高速缓存中了。
3)锁操作比 CAS 操作更加耗时。
4)为锁操作的数据结构中需要两个原子操作。缓存未命中消耗大概 140 纳秒,超过 200 个时钟周期。需要在存储新值时查询变量的旧值的 CAS 操作,消耗大概 300 纳秒,超过 500 个时钟周期。想想这个,在执行一次 CAS 操作的时间里,CPU 可以执行 500 条普通指令。这表明了细粒度锁的局限性。


2.4 CAS属性

  • CAS其实也是一种锁,只是粒度与一般的锁不一样。
  • CAS开源应用场景:ZeroMQ 、Disruptor。
  • CAS在多线程的时候性能不是很高。

2.5 ABA问题

1、问题描述:
假设两个线程T1和T2访问同一个变量V,当T1访问变量V时,读取到V的值为A;此时线程T1被抢占了,T2开始执行,T2先将变量V的值从A变成了B,然后又将变量V从B变回了A;此时T1又抢占了主动权,继续执行,它发现变量V的值还是A,以为没有发生变化,所以就继续执行了。这个过程中,变量V从A变为B,再由B变为A就被形象地称为ABA问题了。

上面的描述看上去并不会导致什么问题。T1中的判断V的值是A就不应该有问题的,无论是开始的A,还是ABA后面的A,判断的结果应该是一样的才对。

2、解决方案
解决的思路就是引入类似乐观锁的版本号控制,不止比较预期值和内存位置的值,还要比较版本号是否正确。

3、实现案例
从JDK5开始,atomic包就提供了AtomicStampedReference类来解决ABA问题,相较CAS引入了一个标志,在比较完预期值与内存地址值之后,再对预期标志和现有标志做比较,都通过才执行更新操作。

猜你喜欢

转载自blog.csdn.net/locahuang/article/details/111030632