1.介绍
CAS是英文单词Compare and Swap的缩写,翻译过来就是比较并替换。
CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
更新时,比较之前读取到的A与现在的实际值V是否相等,若相等,则认为中间没有被更改过,将B赋值给B。
当V!=A时说明有其他线程抢先修改,会放弃当前操作,之后再重新查询V,重新尝试的过程被称为自旋(Spin)。
2.缺点
2.1 CPU开销过大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。(应该是自旋尝试的过程仍然占用cpu时间。)
2.2 不能保证代码块的原子性
https://segmentfault.com/a/1190000016611415
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i = 2,j = a
,合并一下ij = 2a
,然后用CAS
来操作ij。
2.3 ABA问题
第一个链接中提供了带图的例子,非常容易理解。
简单来说,就是有三个线程,编号为1、2、3,1和2同时查询获取当前值为A,都要修改为B,3查询值为B,要修改为A,执行顺序如下:
- 1操作成功了,将A修改为B,但2阻塞住了;
- 此时3查询值为B,成功将B修改为A;
- 2能运行了,查询当前值为A符合条件,修改为B。
https://zhuanlan.zhihu.com/p/400817892
解决办法:
添加版本号。
真正要做到严谨的CAS机制,在compare阶段不仅要比较期望值A和地址V中的实际值,还要比较变量的版本号是否一致。
那么当1和2在查询时,获取到A值和版本号1,当1修改完后,值为B,版本号为2,当3修改完后值为A,但版本号为3,在2开始执行时判断值虽然一致,但版本号不一致,所以更新失败。
3.例子
bool compare_and_swap (int *accum, int *dest, int newval)
{
if ( *accum == *dest )
{
*dest = newval;
return true;
}
return false;
}
3.1 基于链表的非阻塞堆栈实现
// 栈数据结构
template
class Stack {
typedef struct Node {
T data;
Node* next;
Node(const T& d) : data(d), next(0) { }
} Node;
Node *top; // 一个节点,当前数据和指向下一个的指针
public:
Stack( ) : top(0) { }
void push(const T& data);
T pop( ) throw (…);
};
// 压入数据
void Stack::push(const T& data)
{
Node *n = new Node(data);
while (1) {
n->next = top;
if (__sync_bool_compare_and_swap(&top, n->next, n)) { // CAS
break;
}
}
}
// 弹出数据
T Stack::pop( )
{
while (1) {
Node* result = top;
if (result == NULL)
throw std::string(“Cannot pop from empty stack”);
if (top && __sync_bool_compare_and_swap(&top, result, result->next)) { // CAS
return result->data;
}
}
}
4.CAS和自旋锁
自旋锁的实现依赖于 CAS 操作:
- 自旋锁是一种忙等待机制,线程在尝试获取锁时不会进入阻塞状态,而是不断地检查锁是否可用(通过不断“自旋”进行轮询)。
- 在自旋锁中,线程会通过 CAS 操作不断尝试获取锁。通常的做法是用一个共享变量来表示锁的状态(如 0 表示未锁定,1 表示已锁定)。线程通过 CAS 检查这个变量是否为 0,如果是,则使用 CAS 操作将其置为 1,从而获取锁。如果 CAS 操作失败,说明其他线程已经获取了锁,当前线程则继续自旋直到获取锁。
std::atomic<int> lock_flag(0); // 0 表示锁未被持有,1 表示锁已被持有
// 加锁
void acquire_lock() {
while (!lock_flag.compare_exchange_weak(0, 1, std::memory_order_acquire)) {
// 自旋,直到成功获取锁
}
}
// 释放锁
void release_lock() {
lock_flag.store(0, std::memory_order_release); // 释放锁
}
自旋锁的优点与缺点:
- 优点:
- 在短期锁定场景下,自旋锁效率很高,因为它避免了线程的上下文切换(上下文切换在阻塞型锁中发生,会有一定的性能开销)【线程切换时涉及到内核操作】。
- 由于自旋锁不涉及内核操作,线程只在用户态进行自旋,减少了系统调用的开销。
- 缺点:
- 忙等待:如果锁被一个线程长时间持有,其他线程会陷入无效的自旋消耗 CPU 资源。
- 在高并发下,如果很多线程同时尝试获取锁,自旋锁的竞争会导致 CPU 负载增加。
自旋锁通常用于需要高性能和短期锁定的场景,特别是在实现一些无锁数据结构时,比如无锁队列、无锁栈等。利用 CAS 结合自旋锁,可以实现比传统互斥锁更高效的无锁同步机制。CAS 是一种乐观锁机制。