C++并发编程 | CAS的基本原理剖析(无锁编程、无锁数据结构)

文章目录

    • 关于volatile
    • CAS的基本原理
      • CAS的结构
      • 原子操作
      • 自旋锁
      • ABA问题
      • CAS的不同实现版本
      • CAS与普通锁的区别
      • CAS的优缺点
    • 无锁编程
    • 基于CAS的无锁并发队列原理
      • 解决ABA问题
    • 基于CAS实现无锁并发栈原理

关于volatile

C/C++ 语言中的volatile关键字作用

  • 易变性:volatile告诉编译器,某个变量是易变的,当编译器遇到这个变量的时候,只能从变量的内存地址中读取这个变量,不可以从缓存、寄存器、或者其它任何地方读取。

  • 顺序性:两个包含volatile变量的指令,编译后不可以乱序。注意是编译后不乱序,但是在执行的过程中还是可能会乱序的,这点需要由其它机制来保证,例如memory- barriers。

    上面提到的俩个点已经全面覆盖了c/c++下volatile的全部功能。这里特别说明的是各个语言下volatile的意义和功能作用是不一样的,在java下volatile的功能要比上述提到的功能还要强大。

C/C++中的volatile并不是用来解决多线竞争问题的,而是用来修饰一些程序不可控因素导致变化的变量,比如访问底层硬件设备的变量,来提醒编译器不要对该变量的访问擅自进行优化。

C++11标准明确指出解决多线程的数据竞争问题应该使用原子操作或互斥锁

CAS的基本原理

高并发服务器经常用到多线程编程,需要对共享数据进行操作,为了保护数据的正确性,有一种有效的方法就是加锁机制,但这种方式存在以下一些缺点:

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

为了解决多线程并行情况下使用锁造成性能损耗的问题,我们引入了CAS机制
Compare and Swap,现在几乎所有的CPU指令都支持CAS的原子操作,X86下对应的是CMPXCHG 汇编指令。有了这个原子操作,我们就可以用其来实现各种无锁(lock free)的数据结构


CAS的结构

CAS操作包含三个操作数

  • 内存位置(V)
  • 预期原值(A)
  • 新值(B)

如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了 “我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

示例如下,判断内存 reg 里的值是不是 oldval,如果是的话,则对其赋值newval。

int compare_and_swap (int* reg, int oldval, int newval)
{
  int old_reg_val = *reg;
  if(old_reg_val == oldval)
     *reg = newval;
     
  return old_reg_val;
}

这个操作可以变种为返回bool值的形式(返回 bool值的好处在于,调用者可以知道有没有更新成功):

bool compare_and_swap (int* reg, int oldval, int newval)
{
  if(*reg == oldval)
  {
  	 *reg = newval;
  	 return true;
  }
  return false;
}

CAS是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有现成被阻塞的情况下实现变量的同步。


总结如下:

  • CAS(Compare And Swap)比较并替换,是线程并发运行时用到的一种技术
  • CAS是原子操作,保证并发安全,而不能保证并发同步
  • CAS是CPU的一个指令
  • CAS是非阻塞的、轻量级的乐观锁

原子操作

我们写的代码最终都会被翻译为CPU指令,一条最简单加减法语句都有可能会被翻译成几条指令执行;为了避免语句在CPU这一层级上的指令交叉带来的行为不可知,在多线程程序设计时我们必须通过一些方式来进行规范;这里面最常见的做法就是引入互斥锁,其大概的模型就是篮球模式:几个人一起抢球,谁抢到了谁玩,玩完了再把球丢出来重新抢;但互斥锁是操作系统这一层级的,最终映射到CPU上也是一堆指令,是指令就必然会带来额外的开销

既然CPU指令是多线程不可再分的最小单元,那我们如果有办法将代码语句和指令对应起来,不就不需要引入互斥锁从而提高性能了吗? 而这个对应关系就是所谓的原子操作;在C++11的atomic中有两种做法:

  • 模拟, 比如说对于一个atomic< T >类型,我们可以给它附带一个mutex,操作时lock/unlock一下,这种在多线程下进行访问,必然会导致线程阻塞;
  • 有相应的CPU层级的对应,这就是一个标准的lock-free类型;

可以通过is_lock_free函数,判断一个atomic是否是lock-free类型

自旋锁

使用原子操作模拟互斥锁的行为就是自旋锁,互斥锁状态是由操作系统控制的,自旋锁的状态是程序员自己控制的;要搞清楚自旋锁我们首先要搞清楚自旋锁模型,常用的自旋锁模型有:

TAS, Test-and-set,/* 有且只有atomic_flag类型与之对应 */

CAS, Compare-and-swap,
/*对应atomic的compare_exchange_strong 和 compare_exchange_weak,
这两个版本的区别是:Weak版本如果数据符合条件被修改,其也可能返回false,
就好像不符合修改状态一致;而Strong版本不会有这个问题,但在某些平台上
Strong版本比Weak版本慢;绝大多数情况下,我们应该优先选择使用Strong版本;*/

LOCK时自旋锁是自己轮询状态,如果不引入中断机制,会有大量计算资源浪费到轮询本身上;

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting

它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

ABA问题

所谓ABA问题基本是这个样子:

  • 进程P1在共享变量中读到值为A
  • P1被抢占了,进程P2执行
  • P2把共享变量里的值从A改成了B,再改回到A,此时被P1抢占。
  • P1回来看到共享变量里的值没有被改变,于是继续执行。

虽然P1以为变量值没有改变,继续执行了,但是这个会引发一些潜在的问题。ABA问题最容易发生在lock free 的算法中的,CAS首当其冲,因为CAS判断的是指针的地址。如果这个地址被重用了呢,问题就很大了。(地址被重用是很经常发生的,一个内存分配后释放了,再分配,很有可能还是原来的地址)

为解决ABA为题,我们可以采用具有原子性的内存引用计数等等办法。

CAS的不同实现版本

在实际的C/C++程序中,CAS的各种实现版本如下:

1、GCC的CAS

GCC4.1+版本中支持CAS的原子操作(完整的原子操作可参看 GCC Atomic Builtins)

bool __sync_bool_compare_and_swap (type *ptr, type oldval, 
                                    type newval, ...);
type __sync_val_compare_and_swap (type *ptr, type oldval, 
                            type newval, ...)

这两个函数提供原子的比较和交换,如果ptr == oldval,就将newval写入ptr,
第一个函数在相等并写入的情况下返回true,这个函数比第二个好在,返回bool值可以知道有没有更新成功。 第二个函数在返回操作之前的值。

第二个函数用c语言描述:

type __sync_val_compare_and_swap (type *ptr, type oldval, 
                            type newval, ...)
{
    type cur = *ptr;
    if (cur == oldval)
    {
        *ptr = newval;
    }
    return cur;// 返回操作之前的值
}

2、Windows的CAS

在Windows下,你可以使用下面的Windows API来完成CAS:(完整的Windows原子操作可参看MSDN的InterLocked Functions)

InterlockedCompareExchange ( __inoutLONGvolatile*Target,
                                __inLONGExchange,
                                __inLONGComperand);

3、C++11中的CAS

C++11中的STL中的atomic类的函数可以跨平台。(完整的C++11的原子操作可参看 Atomic Operation Library)

template<classT >
bool atomic_compare_exchange_weak(std::atomic* obj,
                                   T* expected, T desired );
template<classT >
bool atomic_compare_exchange_weak(volatile std::atomic* obj,
                                   T* expected, T desired );

CAS与普通锁的区别

  • CAS:一般是系统底层的一套指令,作用就是同一时刻做这套指令的时候,其他相同的指令的线程不会干扰,保持原子性,常用于自旋锁
  • Linux普通锁:同步代码块或者函数,当一个线程拿到锁后,其他线程等待,直到拿到锁的时候释放锁。

根据上述,我们可以知道,CAS失败的情况下,自旋锁会一直旋转;普通锁虽然不会旋转,但是在获得锁的情况下,除了异常,其他情况下是肯定成功的。

所以两者的时间差别就是,自旋的时间 和 线程上下文切换的时间,虽然在其他资源在充足情况下,自旋成功下的时间肯定比线程加锁要来的快,但是考虑一下,如果竞争很激烈的情况下,是不是只有一个CAS能成功,那么其他的都是失败自旋,那么在这种情况下,CPU的资源的消耗可以说十分大的,那么还不如给这个共享资源加锁,剩下的线程等待不占用CPU资源,这种情况下,你可以考虑负载均衡,你可以考虑在别的情况下并行或者并发处理别的事务。

CAS的优缺点

优点:在一般情况下,性能优先于锁的使用。
缺点:它将使调用者处理竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题。自旋时间过长,消耗CPU资源,如果资源竞争激烈,多线程自旋长时间消耗资源。

无锁编程

当今一个提高应用性能的主要方式是采取并发编程的模式,而其中尤其以多线程编程方式为主。线程是共享其所属进程内存空间的独立执行实体,在linux系统里是没有所谓线程的,其实现方式就是标准进程,只不过这种进程能和其他某些进程共享一定的资源罢了。现如今的操作系统都具有切换线程上下文环境,允许线程并发执行的能力。这种能力的的优势是显而易见的,当某线程拥有慢速操作的时候(最主要是各种I/O操作),其他更需要CPU的线程可以“取而代之”执行,让应用的整体的执行效率得到提高。

但是多线程编程是一柄双刃剑,因为当多线程需要共享数据和通信的时候,事情就会变得非常麻烦,会发生诸如死锁,非控制的共享数据存取,内存动态分配与释放等等问题。即便你运气好到没有遇到这些问题,还是有诸如Cache失效率过高等应用性能的掣肘,因此无锁数据结构就应运而生了。

基于CAS的无锁并发队列原理

我们先来看一下进队列的CAS实现的方式(伪代码):

EnQueue(x) //进队列
{
    //准备新加入的结点数据
    q = new record();
    q->value = x;
    q->next = NULL;
 
    do {
        p = tail; //取链表尾指针的快照
    } while( CAS(p->next, NULL, q) != TRUE); //如果没有把结点链在尾指针上,一直自旋
 
    CAS(tail, p, q); //置尾结点
}

我们可以看到,程序中的那个 do- while 的 Re-Try-Loop。就是说,很有可能在准备在队列尾加入结点时,别的线程已经加成功了,于是tail指针就变了,于是CAS返回了false,于是程序再试,直到试成功为止,即自旋操作

为什么我们的“置尾结点”的操作不判断是否成功,因为:

  • 如果有一个线程T1,它的while中的CAS如果成功的话,那么其它所有的 随后线程的CAS都会失败,然后就会再循环,此时,如果T1 线程还没有更新tail指针,其它的线程继续失败,因为tail->next不是NULL了。
  • 直到T1线程更新完tail指针,于是其它的线程中的某个线程就可以得到新的tail指针,继续往下走了。

这里有一个潜在的问题——如果T1线程在用CAS更新tail指针的之前,线程停掉或是挂掉了,那么其它线程就进入死循环了。下面是改良版的EnQueue():

EnQueue(x) //进队列改良版
{
    q = new record();
    q->value = x;
    q->next = NULL;
 
    p = tail;
    oldp = p
    do {
        while (p->next != NULL) // 每个线程的p自己循环到链表尾
            p = p->next;
    } while( CAS(p->next, NULL, q) != TRUE); //如果没有把结点链在尾上,再试
 
    CAS(tail, oldp, q); //置尾结点
}

我们让每个线程,自己 fetch 指针 p 到链表尾。但是这样的fetch会很影响性能。而从实际情况看下来,99.9%的情况不会有线程停转的情况,所以,更好的做法是,可以结合上述的这两个版本,如果retry的次数超了一个值的话(比如说3次),那么,就自己fetch指针。


接下来我们再来看一下出队列的CAS实现:

DeQueue() //出队列
{
    do{
        p = head;
        if (p->next == NULL){
            return ERR_EMPTY_QUEUE;
        }
    while( CAS(head, p, p->next) != TRUE );
    return p->next->value;
}

我们可以看到,DeQueue的代码操作的是 head->next,而不是head本身。这样考虑是因为一个边界条件,我们需要一个dummy的头指针来解决链表中如果只有一个元素,head和tail都指向同一个结点的问题,这样EnQueue和DeQueue要互相排斥了。
C++并发编程 | CAS的基本原理剖析(无锁编程、无锁数据结构)_第1张图片

解决ABA问题

维基百科上给了一个解——使用double-CAS(双保险的CAS),例如,在32位系统上,我们要检查64位的内容

  • 一次用CAS检查双倍长度的值,前半部是指针,后半部分是一个计数器。
  • 只有这两个都一样,才算通过检查,要吧赋新的值。并把计数器累加1。

这样一来,ABA发生时,虽然值一样,但是计数器就不一样(但是在32位的系统上,这个计数器会溢出回来又从1开始的,这还是会有ABA的问题)

当然,我们这个队列的问题就是不想让那个内存重用,这样明确的业务问题比较好解决,使用结点内存引用计数refcnt

SafeRead(q)
{
    loop:
        p = q->next;
        if (p == NULL){
            return p;
        }
 
        Fetch&Add(p->refcnt, 1);
 
        if (p == q->next){
            return p;
        }else{
            Release(p);
        }
    goto loop;
}

其中的 Fetch&Add和Release分是是加引用计数和减引用计数都是原子操作,这样就可以阻止内存被回收了。

基于CAS实现无锁并发栈原理

接下来我们设计一个并发堆栈。这个堆栈没有锁;这种无锁的并发数据结构也称为非阻塞数据结构。
既然了解了 CAS,现在就来设计一个并发堆栈。这个堆栈没有锁;这种无锁的并发数据结构也称为非阻塞数据结构。

基于链表的非阻塞堆栈实现

template <typename T> 
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<T>::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;
        }
    }
}

压入(Push)操作做了什么?从单一线程的角度来看,创建了一个新节点,它的 next 指针指向堆栈的顶部。接下来,调用 CAS 内置函数,把新的节点复制到 top 位置。

从多个线程的角度来看,完全可能有两个或更多线程同时试图把数据压入堆栈。假设线程 A 试图把 20 压入堆栈,线程 B 试图压入 30,而线程 A 先获得了时间片。但是,在 n->next = top 指令结束之后,调度程序暂停了线程 A。现在,线程 B 获得了时间片,它能够完成 CAS,把 30 压入堆栈后结束。接下来,线程 A 恢复执行,显然对于这个线程 *top 和 n->next 不匹配,因为线程 B 修改了 top 位置的内容。因此,代码回到循环的开头,指向正确的 top 指针(线程 B 修改后的),调用 CAS,把 20 压入堆栈后结束。整个过程没有使用任何锁


从非阻塞堆栈弹出数据

T Stack<T>::pop( ) 
{ 
    if (top == NULL) 
       throw std::string(“Cannot pop from empty stack”);
    while (1) { 
        Node* next = top->next;
        if (__sync_bool_compare_and_swap(&top, top, next)) { // CAS
            return top->data;
        }
    }
}

用与 push 相似的代码定义弹出操作语义。使用 CAS 把 top 位置更新为 top->next 并返回适当的数据。如果恰在执行 CAS 之前线程失去执行权,那么在线程恢复执行之后,CAS 会失败,继续循环,直到有有效的数据可用为止。


不幸的是,这种堆栈弹出实现有问题 — 包括明显的问题和不太明显的问题。

明显的问题是 NULL 检查必须放在 while 循环中。如果线程 P 和线程 Q 都试图从只剩一个元素的堆栈弹出数据,而线程 P 恰在执行 CAS 之前失去执行权,那么当它重新获得执行权时,堆栈中已经没有可弹出的数据了。因为 top 是 NULL,访问 &top 肯定会导致崩溃 — 这显然是可以避免的 bug。这个问题也突显了并发数据结构的基本设计原则之一:决不要假设任何代码会连续执行

从非阻塞堆栈弹出数据

T Stack<T>::pop( ) 
{ 
    while (1) { 
        if (top == NULL) 
           throw std::string(“Cannot pop from empty stack”);
        Node* next = top->next;
        if (top && __sync_bool_compare_and_swap(&top, top, next)) { // CAS
            return top->data;
        }
    }
}

下一个问题即为我们之前所讲的ABA问题

内存的回收利用会导致 CAS 出现严重的问题

T* ptr1 = new T(8, 18);
T* old = ptr1; 
// .. do stuff with ptr1
delete ptr1;
T* ptr2 = new T(0, 1);
 
// We can't guarantee that the operating system will not recycle memory
// Custom memory managers recycle memory often
if (old1 == ptr2) {}

在此代码中,无法保证 old 和 ptr2 有不同的值。根据操作系统和定制的应用程序内存管理系统的具体情况,完全可能回收利用已删除的内存 — 也就是说,删除的内存放在应用程序专用的池中,可在需要时重用,而不返回给系统。这显然会改进性能,因为不需要通过系统调用请求更多内存。尽管在一般情况下这是有利的,但是对于非阻塞堆栈不好。现在我们来看看这是为什么。

T Stack<T>::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 top->data;
        }
    }
}

这样,即使线程 B 在线程 A 试图弹出数据的同时修改了堆栈顶,也可以确保不会跳过堆栈中的元素。

你可能感兴趣的:(C++,C++程序设计)