zoukankan      html  css  js  c++  java
  • 无锁同步-C++11之Atomic和CAS

    1、概要

          本文是无锁同步系列文章的第一篇,主要探讨C++11中的Atomic。

          我们知道在C++11中引入了mutex和方便优雅的lock_guard。但是有时候我们想要的是性能更高的无锁实现,下面我们来讨论C++11中新增的原子操作类Atomic,我们可以利用它巧妙地实现无锁同步。

    2、传统的线程同步

     1 #include <thread>
     2 #include <mutex>
     3 
     4 #include <iostream>
     5 
     6 using namespace std;
     7 
     8 mutex g_mutex;
     9 int g_count = 0;
    10 
    11 int main()
    12 {
    13     thread thr1([]() {
    14         for (int i = 0;i < 5;i++) {
    15             lock_guard<mutex> lock(g_mutex);    //
    16             g_count += 10;
    17         }
    18     });
    19 
    20     thread thr2([]() {
    21         for (int i = 0;i < 5;i++) {
    22             lock_guard<mutex> lock(g_mutex);    //
    23             g_count += 20;
    24         }
    25     });
    26 
    27     thr1.join();
    28     thr2.join();
    29 
    30     cout << g_count << endl;
    31 
    32 
    33 }

           在上述例子中,如果把①②的锁注释后,我们可能无法得到正确的结果。原因是C++并没有给我们保证+=操作具有原子性(其本质应该是读-加-写3个操作)。

    3、Atomic

           C++11给我们带来的Atomic一系列原子操作类,它们提供的方法能保证具有原子性。这些方法是不可再分的,获取这些变量的值时,永远获得修改前的值或修改后的值,不会获得修改过程中的中间数值。

           这些类都禁用了拷贝构造函数,原因是原子读和原子写是2个独立原子操作,无法保证2个独立的操作加在一起仍然保证原子性。

           这些类中,最简单的是atomic_flag(其实和atomic<bool>相似),它只有test_and_set()和clear()方法。其中,test_and_set会检查变量的值是否为false,如果为false则把值改为true。

           除了atomic_flag外,其他类型可以通过atomic<T>获得。atomic<T>提供了常见且容易理解的方法:

    1. store
    2. load
    3. exchange
    4. compare_exchange_weak
    5. compare_exchange_strong

           store是原子写操作,而load则是对应的原子读操作。

           exchange允许2个数值进行交换,并保证整个过程是原子的。

           而compare_exchange_weak和compare_exchange_strong则是著名的CAS(compare and set)。参数会要求在这里传入期待的数值和新的数值。它们对比变量的值和期待的值是否一致,如果是,则替换为用户指定的一个新的数值。如果不是,则将变量的值和期待的值交换。

           weak版本的CAS允许偶然出乎意料的返回(比如在字段值和期待值一样的时候却返回了false),不过在一些循环算法中,这是可以接受的。通常它比起strong有更高的性能。

    3、例子

           下面举个简单的例子,使用CAS操作实现一个不带锁的并发栈。这个例子从《C++并发编程》摘抄而来。

      Push

           在非并发条件下,要实现一个栈的Push操作,我们可能有如下操作:

      1. 新建一个节点
      2. 将该节点的next指针指向现有栈顶
      3. 更新栈顶    

           但是在并发条件下,上述无保护的操作明显可能出现问题。下面举一个例子:

    1. 原栈顶为A。(此时栈状态: A->P->Q->...,我们约定从左到右第一个值为栈顶,P->Q代表p.next = Q)
    2. 线程1准备将B压栈。线程1执行完步骤2后被强占。(新建节点B,并使 B.next = A,即B->A)
    3. 线程2得到cpu时间片并完成将C压栈的操作,即完成步骤1、2、3。此时栈状态(此时栈状态: C->A->...)
    4. 这时线程1重新获得cpu时间片,执行步骤3。导致栈状态变为(此时栈状态: B->A->...)

           结果线程2的操作丢失,这显然不是我们想要的结果。

           那么我们如何解决这个问题呢?只要保证步骤3更新栈顶时候,栈顶是我们在步骤2中获得顶栈顶即可。因为如果有其它线程进行操作,栈顶必然改变。

           我们可以利用CAS轻松解决这个问题:如果栈顶是我们步骤2中获取顶栈顶,则执行步骤3。否则,自旋(即重新执行步骤2)。

           因此,不带锁的压栈Push操作比较简单。

     1 template<typename T>
     2 class lock_free_stack
     3 {
     4 private:
     5   struct node
     6   {
     7     T data;
     8     node* next;
     9 
    10     node(T const& data_): 
    11      data(data_)
    12     {}
    13   };
    14 
    15   std::atomic<node*> head;
    16 public:
    17   void push(T const& data)
    18   {
    19     node* const new_node=new node(data); 
    20     new_node->next=head.load(); 
    21     while(!head.compare_exchange_weak(new_node->next,new_node));
    22   }
    23 };

           我们可以注意到一个非常巧妙的设计。在push方法里,atomic_compare_exchange_weak如果失败,证明有其他线程更新了栈顶,而这个时候被其他线程更新的新栈顶值会被更新到new_node->next中,因此循环可以直接再次尝试压栈而无需由程序员更新new_node->next

      Pop

         2017.3.14:

         发现原文Pop部分有错误,所以暂时删除

  • 相关阅读:
    华为超大云数据中心落地贵州,这些硬核技术有利支撑“东数西算”
    在高并发环境下该如何构建应用级缓存
    使用 Python Poetry 进行依赖管理
    AI新手语音入门:认识词错率WER与字错率CER
    一文带你了解什么是GitOps
    需求蔓延,常见但不正常,教你如何破
    云图说|初识ModelArts开发者生态社区——AI Gallery
    XML学习笔记:关于字符编码的理解~
    Python中单引号、双引号和三双引号的区别:
    LBFGS算法的使用~
  • 原文地址:https://www.cnblogs.com/dengzz/p/5686866.html
Copyright © 2011-2022 走看看