CAS详解

前言

CAS全称CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性,它包含三个操作数:

  1. 变量内存地址,V表示
  2. 旧的预期值,A表示
  3. 准备设置的新值,B表示

当执行CAS指令时,只有当V等于A时,才会用B去更新V的值,否则就不会执行更新操作。

正文

CAS原理

CAS的伪代码如下:

1
2
3
4
do {
//备份旧数据
//基于旧数据构造新数据B
}while (!CAS(变量内存地址,旧的预期值,准备设置的新值));

如下图:

upload successful

  1. 我们假设线程1和线程2同时访问变量V=33,两线程将变量值33拷贝到各自工作空间内存;
  2. 两线程分别进行 +1 操作,分别得到准备设置的新值34,而后进行值设置,对V进行 CAS 操作;
  3. 线程1操作成功,将值设置为34,完成后并更新自己本地值A=34;
  4. 这时候线程2操作就会返回失败,因为V的值以及被线程1设置为了34;
  5. 失败后,它会进行重试,它需要在获取34到本地,进行 +1 操作,再进行CAS(34,34,35)的操作,而后线程2将值设置成功。

大家可能会有个疑问,如果在第2步,线程1和线程2同时进行CAS操作,是如何保证原子性的呢?

sun.misc.Unsafe类中,我们可以看到CompareAndSwap方法是调用的原生代码。

1
2
3
4
5
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

我们来分析一下它们的相关代码。

CAS源码解析

我们以compareAndSwapInt为例,在openjdk 1.8 unsafe.cpp里可以找到如下代码:

1
2
3
4
5
6
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

其对应我们的sun.misc.Unsafe类中的compareAndSwapInt方法。

关键方法为Atomic::cmpxchg(x, addr, e),它位于openjdk 1.8 atomic.cpp文件中,如下:

1
2
3
4
5
6
unsigned Atomic::cmpxchg(unsigned int exchange_value,
volatile unsigned int* dest, unsigned int compare_value) {
assert(sizeof(unsigned int) == sizeof(jint), "more work to do");
return (unsigned int)Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest,
(jint)compare_value);
}

Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest,(jint)compare_value)方法位于openjdk 1.8 atomic_windows_x86.inline.hpp文件中,如下:

1
2
3
4
5
6
7
8
9
10
11
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
}
}

mpos::is_MP()的返回结果,os::is_MP()是一个内联函数,用来判断当前系统是否为多处理器。

如果当前系统是多处理器,该函数返回1,否则,返回0。

LOCK_IF_MP(mp)会根据mp的值来决定是否为cmpxchg指令添加lock前缀。

  • 如果通过mp判断当前系统是多处理器(即mp值为1),则为cmpxchg指令添加lock前缀。
  • 否则,不加lock前缀。

这是一种优化手段,单处理器的环境没有必要添加lock前缀,只有在多核情况下才会添加lock前缀,因为lock会导致性能下降。

cmpxchg是汇编指令,作用是比较并交换操作数。

关于lock指令

  1. 确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
  2. 禁止该指令与之前和之后的读和写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

上面的第1点保证了CAS操作是一个原子操作,第2点和第3点所具有的内存屏障效果,保证了CAS同时具有volatile读和volatile写的内存语义。

有关lock指令更多内容,我们可以下载Intel 官方文档 来查看。

CAS的缺点

虽然CAS很高效的解决了原子操作问题,但CAS仍有3个主要缺点:

  1. ABA问题:ABA的问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。只是ABA的问题大部分场景下都不影响并发的最终效果。

Java中有AtomicStampedReference来解决这个问题,它加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。

我们来简单看下它的部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class AtomicStampedReference<V> {

private static class Pair<T> {
final T reference;
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}

private volatile Pair<V> pair;
//....部分代码略

public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
//...部分代码略
}

可以看到AtomicStampedReference类中除了对象引用reference,还加入了标志字段stamp来解决ABA问题。

  1. 循环时间长开销大:自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。

上面图示中我们提到线程2如果更新不成功,会进行重试,其采用自旋方式进行重试,如果有多个线程操作共享变量时,部分线程可能自旋时间过长,对CPU造成较大开销。

  1. 只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized实现。

我们来看下AtomicReference的部分代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class AtomicReference<V> implements java.io.Serializable {
private static final long serialVersionUID = -1848883965231344442L;

private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicReference.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile V value;

//...部分代码略

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

//...部分代码略
}

可以看到它是通过compareAndSwapObject函数来实现的。

经过上面分析,我们可以看到CAS操作和乐观锁的性质类似。

CAS应用

CAS在java.util.concurrent包下的类中有大量被应用,有兴趣的同学可以看一下。

CAS的开销

CAS(比较并交换)是CPU指令级的操作,只有一步原子操作,所以非常快。而且CAS避免了请求操作系统来裁定锁的问题,不用麻烦操作系统,直接在CPU内部就搞定了。但CAS就没有开销了吗?不!有cache miss的情况。这个问题比较复杂,首先需要了解CPU的硬件体系结构:

upload successful

上图可以看到一个8核CPU计算机系统,每个CPU有cache(CPU内部的高速缓存,寄存器),管芯内还带有一个互联模块,使管芯内的两个核可以互相通信。在图中央的系统互联模块可以让四个管芯相互通信,并且将管芯与主存连接起来。数据以“缓存线”为单位在系统中传输,“缓存线”对应于内存中一个 2 的幂大小的字节块,大小通常为 32 到 256 字节之间。当 CPU 从内存中读取一个变量到它的寄存器中时,必须首先将包含了该变量的缓存线读取到 CPU 高速缓存。同样地,CPU 将寄存器中的一个值存储到内存时,不仅必须将包含了该值的缓存线读到 CPU 高速缓存,还必须确保没有其他 CPU 拥有该缓存线的拷贝。

比如,如果 CPU0 在对一个变量执行“比较并交换”(CAS)操作,而该变量所在的缓存线在 CPU7 的高速缓存中,就会发生以下经过简化的事件序列:

  • CPU0 检查本地高速缓存,没有找到缓存线。
  • 请求被转发到 CPU0 和 CPU1 的互联模块,检查 CPU1 的本地高速缓存,没有找到缓存线。
  • 请求被转发到系统互联模块,检查其他三个管芯,得知缓存线被 CPU6和 CPU7 所在的管芯持有。
  • 请求被转发到 CPU6 和 CPU7 的互联模块,检查这两个 CPU 的高速缓存,在 CPU7 的高速缓存中找到缓存线。
  • CPU7 将缓存线发送给所属的互联模块,并且刷新自己高速缓存中的缓存线。
  • CPU6 和 CPU7 的互联模块将缓存线发送给系统互联模块。
  • 系统互联模块将缓存线发送给 CPU0 和 CPU1 的互联模块。
  • CPU0 和 CPU1 的互联模块将缓存线发送给 CPU0 的高速缓存。
  • CPU0 现在可以对高速缓存中的变量执行 CAS 操作了

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

以下是cache miss cas 和lock的性能对比:

upload successful

总结

本篇文章我们讲到了CAS的一些特点,大致了解了CAS的一些原理,在实际工作中,针对于一些场景,也可以使用CAS来操作。

参考文档




-------------文章结束啦 ~\(≧▽≦)/~ 感谢您的阅读-------------

您的支持就是我创作的动力!

欢迎关注我的其它发布渠道