CAS 原理分析
首先认识一下 CAS:CAS是支持并发的第一个处理器提供原子的测试并设置操作,通常在单位上运行这项操作。操作数为V,A,B。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置的值即可。”
CAS 是处理器级别的原语,也就是说单核情况下处理器可以保证 CAS 操作的原子性。
但事实是多核环境下,CAS 的原子性也是可以得到保证的。在单核环境下保证原子性的基础上,多核情况下 CAS 通过以下方式保证操作的原子性:
1. 通过总线锁,保证其修改动作的线程排他性。
2. 通过缓存一致性协议,保证处理器缓存中的值对其它核心的可见性。
3. 多核环境下通过 lock 内存屏障,保证多线程下的有序性,并保证其值立即从写缓冲区刷新到缓存,结合2,保证了操作结果对其它核心的可见性。
我们看一下 Intel X86 架构下 CAS 的一段实现:
// Adding a lock prefix to an instruction on MP machine // VC++ doesn't like the lock prefix to be on a single line // so we can't insert a label after the lock prefix. // By emitting a lock prefix, we can define a label after it. #define LOCK_IF_MP(mp) __asm cmp mp, 0 \ __asm je L0 \ __asm _emit 0xF0 \ __asm L0: inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) { // alternative for InterlockedCompareExchange int mp = os::is_MP(); __asm { mov edx, dest mov ecx, exchange_value mov eax, compare_value LOCK_IF_MP(mp) cmpxchg dword ptr [edx], ecx } }
程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀。
因为但处理器情况下,有序性有天然的保证。同时不需要强制将结果从缓冲区刷新入缓存,因为处理器在读取值时会先从写缓冲区中寻找,找不到才回去缓存中寻找,所以单核情况下无论是否将结果刷入缓存,操作结果都是对所有线程可见的。
可以看到 CAS 保证可见性与有序性的原理与 volatile 一样,都是通过处理器提供的内存屏障,尽管它们不在一个抽象层级的,这样比较并不合适。
CAS 效率的一个重要影响因素是cache miss,如果 CAS 操作的内存地址不在当前处理器的缓存中的话,需要通过缓存一致性协议在其它处理器的缓存中寻找,如果都找不到则去内存加载。最好情况下,也就是没有发生 cache miss 的话,CAS 大概需要 60 个时钟周期,而锁操作在最好情况下需要大约 100 个时钟周期(一个“round trip 对”包括获取锁和随后的释放锁),通常情况下 CAS 的效率是高于锁操作的。
CAS 是一种乐观锁,再补充一下乐观锁与悲观锁:
悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
之所以叫做悲观锁,是因为这是一种对数据的修改抱有悲观态度的并发控制方式。我们一般认为数据被并发修改的概率比较大,所以需要在修改之前先加锁。悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。
乐观锁机制采取了更加宽松的加锁机制。乐观锁是相对悲观锁而言,也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。
相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本。乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。
1、cpu开销大,在高并发下,许多线程,更新一变量,多次更新不成功,循环反复,给cpu带来大量压力。
2、只是一个变量的原子性操作,不能保证代码块的原子性。
3、ABA问题
ABA问题:内存值V=100;
threadA 将50,改为0;
threadB 将50,改为0;
threadC 将0,改为50
正常情况下 A 执行完 B 应该失败,但是如果 B 阻塞住了,期间 C 执行成功,那么 A 与 B 都可以执行成功,一次本应该失败的操作成功了。因为不是先上锁再操作,所以我们不能保证线程对共享变量访问的有序性(这可不是三大特性里的有序性),这样在实际生产过程中,因为每个操作都有自己的意义,乱序的访问共享变量会导致操作错误。
比如一杯水,A喝完了,B倒满了,C又喝完了。C无法分辨杯子里的水有没有被人喝过,项目中的逻辑很可能是如果杯子被人用过,C就不喝了,但是B将其倒满导致C无法对该条件进行正确的判断。在特定情况下,我们不能只看数据的当前值,也应该看数据的状态,我们可以为数据增加一个状态--版本号来解决这个问题。
其实悲观锁也存在ABA问题,碰到这种情况也需要为数据增加一个状态来监控数据。但是悲观锁并不是根据数据的当前状态来判断操作是否执行的,而是有锁的逻辑,所以我们在编写程序时本身就不会出现原值被改过又被改回来所以我能执行这种逻辑漏洞,我们会有其它控制位来调度线程的执行。