CAS原理分析与CAS操作在ARM和x86下的不同实现

一、锁机制

 

常用的锁机制有两种:

1、悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。悲观锁的实现,往往依靠底层提供的锁机制;悲观锁会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。

2、乐观锁:假设不会发生并发冲突,每次不加锁而是假设没有冲突而去完成某项操作,只在提交操作时检查是否违反数据完整性。如果因为冲突失败就重试,直到成功为止。乐观锁大多是基于数据版本记录机制实现。为数据增加一个版本标识,比如在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。 

乐观锁的缺点是不能解决脏读的问题。

在实际生产环境里边,如果并发量不大且不允许脏读,可以使用悲观锁解决并发问题;但如果系统的并发非常大的话,悲观锁定会带来非常大的性能问题,所以我们就要选择乐观锁定的方法.

锁机制存在以下问题:

(1)在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
(2)一个线程持有锁会导致其它所有需要此锁的线程挂起。
(3)如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

volatile是不错的机制,但是volatile不能保证原子性。因此对于同步最终还是要回到锁机制上来。


独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。

二、CAS 操作

 

JDK 5之前Java语言是靠synchronized关键字保证同步的,这是一种独占锁,也是是悲观锁。java.util.concurrent(J.U.C)种提供的atomic包中的类,使用的是乐观锁,用到的机制就是CAS,CAS(Compare and Swap)有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

现代的CPU提供了特殊的指令,允许算法执行读-修改-写操作,而无需害怕其他线程同时修改变量,因为如果其他线程修改变量,那么CAS会检测它(并失败),算法可以对该操作重新计算。而 compareAndSet() 就用这些代替了锁定。

以AtomicInteger为例,研究在没有锁的情况下是如何做到数据正确性的。

 

public class AtomicInteger extends Number implements java.io.Serializable {
        private volatile int value;

        public final int get() {
            return value;
        }

        public final int getAndIncrement() {
            for (; ; ) {
                int current = get();
                int next = current + 1;
                if (compareAndSet(current, next))
                    return current;
            }
        }


        public final boolean compareAndSet(int expect, int update) {
            return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
        }
}

  

字段value需要借助volatile原语,保证线程间的数据是可见的(共享的)。这样在获取变量的值的时候才能直接读取。然后来看看++i是怎么做到的。getAndIncrement采用了CAS操作,每次从内存中读取数据然后将此数据和+1后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。而compareAndSet利用JNI来完成CPU指令的操作。

 

public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

整体的过程就是这样子的,利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。
而整个J.U.C都是建立在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了很大的提升。

CAS第一个问题是会导致“ABA问题”。
aba实际上是乐观锁无法解决脏数据读取的一种体现。CAS算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。如果链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化。因此AtomicStampedReference/AtomicMarkableReference就很有用了。

 

AtomicMarkableReference 类描述的一个<Object,Boolean>的对,可以原子的修改Object或者Boolean的值,这种数据结构在一些缓存或者状态描述中比较有用。这种结构在单个或者同时修改Object/Boolean的时候能够有效的提高吞吐量。 


AtomicStampedReference 类维护带有整数“标志”的对象引用,可以用原子方式对其进行更新。对比AtomicMarkableReference 类的<Object,Boolean>,AtomicStampedReference 维护的是一种类似<Object,int>的数据结构,其实就是对对象(引用)的一个并发计数(标记版本戳stamp)。但是与AtomicInteger 不同的是,此数据结构可以携带一个对象引用(Object),并且能够对此对象和计数同时进行原子操作。

 

上述内容来自: https://blog.csdn.net/HEYUTAO007/article/details/19975665

 

CAS操作在ARM和x86下的不同实现

cmpxchg是X86比较交换指令,这个指令在各大底层系统实现的原子操作和各种同步原语中都有广泛的使用,比如linux内核,JVM,GCC编译器等,cmpxchg就是比较交换指令,了解cmpxchg之前先了解原子操作。

   intel P6以及最新系列处理器保证了以下操作是原子的:1.读写一个字节。2.读写16位对齐的字。3.读写32位对齐的双字。4.读写64位对齐的四字。5.读写16位,32位,64位在cache line内的未对齐的字。所以普通的load store指令都是原子的。cache一致性协议保证了不可能有两个cpu同时写一个内存。对于cmpxchg这种比较交换指令肯定不是原子的,intel是CISC复杂指令集架构,在内部流水线执行的时候,肯定会将cmpxchg指令翻译成几条微码执行(对比ARM精简指令集)。所以英特尔对于一些指令提供了LOCK前缀来保证这个指令的原子性。Intel 64和IA-32处理器提供LOCK#信号,该信号在某些关键存储器操作期间自动置位,以锁定系统总线或等效链路。当该输出信号被断言时,来自其他处理器或总线代理的用于控制总线的请求被阻止。对于Intel386,Intel486和Pentium处理器,明确锁定的指令将导致LOCK#信号的置位。硬件设计人员有责任在系统硬件中使用LOCK#信号来控制处理器之间的存储器访问。对于P6和更新的处理器系列,如果被访问的存储区域在处理器内部高速缓存,则LOCK#信号通常不被断言;相反,锁定仅应用于处理器的缓存。对于Intel486和Pentium处理器,LOCK#信号在LOCK操作期间始终在总线上置位,即使被锁定的存储器区域缓存在处理器中也是如此。所以这个性能会降低很多,导致其它cpu不能访问内存。对于P6和更新的处理器系列,如果在LOCK操作期间被锁定的存储器区域被高速缓存在执行LOCK操作作为回写存储器并且完全包含在高速缓存行中的处理器中,则处理器可能不会断言总线上的LOCK#信号。相反,它将在内部修改内存位置并允许其缓存一致性机制,以确保操作以原子方式执行。此操作称为“缓存锁定”。缓存一致性机制自动阻止缓存相同内存区域的两个或多个处理器同时修改该区域中的数据。

  为了更清楚理解cmxchg,需要同时看ARM和x86两种架构下的实现一个RISC,一个CISC,linux内核提供了两种架构下的实现。linux内核的原子变量定义如下:

//原子变量
    typedef struct {
        volatile int counter; //volatile禁止编译器把变量缓冲到寄存器
    } atomic_t;

先看ARM架构下,ARM架构是精简指令集,没有提供cmpxchg这种复杂指令,和其它所有RISC架构一样提供了LL/SC(链接加载,条件存储)操作,这个操作是很多原子操作的基础。ARMv8指令是LDXR\STXR,ARMv7指令是LDREX\STREX,大同小异,都属于独占访问,需要有local monitor和global monitor配合使用。这两条指令一般需要成对出现。ldrex是从内存取出数据放到寄存器,然后监视器将此地址标记为独占,strex会先测试是否是当前cpu的独占,如果是则存储成功返回0,如果不是则存储失败返回1。例如cpu0将地址m标记为独占,在strex执行前,线程被调出了,cpu1调用ldrex会清除cpu0的独占,而将自己标记为独占,然后执行strxr,然后cpu0的线程重新被调度,此时执行strex会失败,因为自己的独占位被清除了。这样也会导致后进入ldrex的线程可能比先进入的先执行。标记为独占的地址调用strex后都会清除独占标志。

/**

* 比较ptr->counter和old的值如果相等,则ptr->counter = new,并且返回old,否则ptr->counter不变

* 返回ptr->counter

*/

static inline int atomic_cmpxchg(atomic_t *ptr, int old, int new)

{

unsigned long oldval, res;



smp_mb(); //内存屏障,保证cmpxchg不会在屏障前执行



do {

__asm__ __volatile__("@ atomic_cmpxchg\n"

"ldrex %1, [%2]\n" //独占访问,监视器会将此地址标志独占并且将ptr->counter给oldvalue

"mov %0, #0\n" //res = 0

"teq %1, %3\n" //测试oldvalue是否和old相等也就是ptr->counter和old



//独占访问成功并且如果相等则把new赋值给ptr->counter,否则不执行这条指令

"strexeq %0, %4, [%2]\n"

: "=&r" (res), "=&r" (oldval)

: "r" (&ptr->counter), "Ir" (old), "r" (new)

: "cc");

} while (res); //while res是因为strexeq指令是独占访存指令从,此时可能未标记访存,而res为1



smp_mb();//内存屏障,保证cmpxchg不会在屏障后执行



return oldval;

}

 

x86架构也是类似:

/*

* 根据size大小比较交换字节,字或者双字,如果返回old则交换成功,否则交换失败

*/

static inline unsigned long __cmpxchg(volatile void *ptr, unsigned long old,

unsigned long new, int size)

{

unsigned long prev;

switch (size) {

case 1:

__asm__ __volatile__(LOCK_PREFIX "cmpxchgb %b1,%2"

: "=a"(prev)

: "q"(new), "m"(*__xg(ptr)), "0"(old)

: "memory");

return prev;

case 2:

__asm__ __volatile__(LOCK_PREFIX "cmpxchgw %w1,%2"

: "=a"(prev)

: "r"(new), "m"(*__xg(ptr)), "0"(old)

: "memory");

return prev;

//eax = old,比较%2 = ptr->counter和eax是否相等,如果相等则ZF置位,并把%1 = new赋值

//给ptr->counter,返回old值,否则ZF清除,并且将ptr->counter赋值给eax

case 4:

__asm__ __volatile__(LOCK_PREFIX "cmpxchgl %1,%2"

: "=a"(prev)

: "r"(new), "m"(*__xg(ptr)), "0"(old) //0表示eax = old

: "memory");

return prev;

}

return old;

}

  在cmpxchg指令前加了lock前缀,保证在进行操作的时候,不会让其它cpu操作同一个内存。使得整个操作保持原子性。对比来看虽然X86只用了一条指令,但是处理器内部肯定将这条指令转成了类RISC的微码。

以上来自:https://blog.csdn.net/a7980718/article/details/82860505

posted @ 2020-11-28 09:44  looyee  阅读(999)  评论(0编辑  收藏  举报