zoukankan      html  css  js  c++  java
  • C++并行编程之原子操作的内存顺序

    读完了C++并发编程实战,记录一下对memory order的理解。


    1. C++原子操作的内存顺序概述

    1. memory order主要有以下几种:
    • memory_order_relaxed
      只提供对单个atomic变量的原子读/写,不和前后语句有任何memory order的约束关系。

    • memory_order_consume
      程序可以说明哪些变量有依赖关系,从而只需要同步这些变量的内存。
      类似于memory_order_acquire,但是只对有依赖关系的内存。意思是别的CPU执行了memory_order_release操作,而其他依赖于这个atomic变量的内存会被执行memory_order_consume的CPU看到。这个操作是C++特有的,x86也不支持这种类型的memory order,不清楚其他种类的cpu是否支持,这还涉及到编译器是否支持这种细粒度控制,也许它直接按memory_order_acquire来处理。

    • memory_order_acquire
      执行memory_order_acquire的cpu,可以看到别的cpu执行memory_order_release为止的语句对内存的修改。执行memory_order_acquire这条指令犹如一道栅栏,这条指令没执行完之前,后续的访问内存的指令都不能执行,这包括读和写

    • memory_order_release
      执行memory_order_release的cpu,在这条指令执行前的对内存的读写指令都执行完毕,这条语句之后的对内存的修改指令不能超越这条指令优先执行。这也象一道栅栏。
      在这之后,别的cpu执行memory_order_acquire,都可以看到这个cpu所做的memory修改

    • memory_order_acq_rel
      是memory_order_acquire和memory_order_release的合并,这条语句前后的语句都不能被reorder。

    • memory_order_seq_cst
      这是比memory_order_acq_rel更加严格的顺序保证,memory_order_seq_cst执行完毕后,所有其cpu都是确保可以看到之前修改的最新数据的。如果前面的几个memory order模式允许有缓冲存在的话,memory_order_seq_cst指令执行后则保证真正写入内存。一个普通的读就可以看到由memory_order_seq_cst修改的数据,而memory_order_acquire则需要由memory_order_release配合才能看到,否则什么时候一个普通的load能看到memory_order_release修改的数据是不保证的。

    1. x86的memory order
      x86的memory order是一种strong memory order,它保证:
    • LoadLoad是顺序的
      一个cpu上前后两条load指令是顺序执行的,前面一条没执行完毕,后面一条不能执行
    • StoreStore是顺序的
      一个cpu上前后两条store指令是顺序执行的,前面一条没执行完毕,后面一条不能执行
    • LoadStore
      一个cpu上前面一条是Load指令,这条指令没执行完毕,后面一条store不能执行

    x86不保证StoreLoad的顺序,一条Store指令在前,后面一条不相关的load指令可以先执行。因为这个顺序的不保证,导致Peterson lock实际上需要使用mfence指令才能在x86上实现。

    x86上很多原子操作需要使用lock前缀或者隐含lock语义,例如xchg指令。这个lock语义是上面memory_order_seq_cst的语义,是一个full memory barrier。相对来说在x86上的memory order 比较容易使用,但是性能有所损失,例如上面的LoadLoad是顺序执行的,但是如果第一个Load因为cache不命中,就引起从内存Load而导致的延迟,虽然第二个Load是可以cache命中的,但是因为第一个Load的delay,影响到第二个Load的执行,继而导致后续运算都delay。

    2. 为什么需要定义这样内存顺序

    简单的解释,就是cache的存在,虽然前面更新了变量的值,但是可能存在某个CPU的缓存中,而其他CPU的缓存还是原来的值,这样就可能产生错误,定义内存顺序就可以强制性的约束一些值的更新。
    为什么需要这么多类型?灵活度更大,合理的使用可以最大化利用系统的性能。

    3. 内存操作的种类

    • Store operations —— 写
      which can have memory_order_relaxed , memory_order_release ,or memory_order_seq_cst ordering
    • Load operations —— 读
      which can have memory_order_relaxed , memory_order_consume ,memory_order_acquire, or memory_order_seq_cst ordering
    • Read-modify-write operations —— 读-修改-写
      which can have memory_order_relaxed , memory_order_consume , memory_order_acquire , memory_order_release , memory_order_acq_rel, or memory_order_seq_cst ordering

    4. 内存模型关系

    为什么要理解这个,其实这就是定义内存顺序的目的,因为有了这些关系的需求,才要定义这些顺序。

    • happens-before
      对同一线程,就是一个操作要在另一个操作之前执行。对多个线程,某一个线程的操作A要在另一线程的操作B之前发生。若顺序变了,可能就不能达到我们希望的效果。

    • synchronizes-with
      某一线程的load操作,依赖于另一线程的store操作,简单说,就是线程A读取的变量值a,应该是线程B修改后的值。

    5. C++定义的内存顺序分别可以实现哪些关系?

    • relaxed 松散顺序
      memory_order_relaxed 由这个值指定的,这个很简单,就是各个CPU读取的值是未定义的,一个CPU在一个线程中修改一个值后,其他CPU不知道。

    • sequentially-consistent 顺序一致顺序
      memory_order_seq_cst 由这个值指定,这个也很简单,相当于各CPU的原子操作都是在一个线程上工作,一个修改后,其他CPU都会更新到新的值。
      如下例,在此顺序下,只要x,y的值修改了,其他线程的load操作就会是最新的值,所以线程c和d,不管哪个先执行完,后执行完的肯定会修改z,所以一定不会报异常。但是要是松散模型,即使x,y变了,但是在线程cd load时,也可以是之前为未修改的值。

     1 #include <atomic>
     2 #include <iostream>
     3 #include <thread>
     4 
     5 using namespace std;
     6 
     7 atomic<int> a{0};
     8 atomic<int> b{0};
     9 
    10 int valueset(int) {
    11   int t = 1;
    12   a.store(t, memory_order_relaxed);
    13   b.store(2, memory_order_release);    // 本原子操作前所有的写原子操作必须完成
    14   return 0;
    15 }
    16 
    17 int observer(int) {
    18   while (b.load(memory_order_acquire) != 2) // 本原子操作必须完成才能执行之后所有的
    19     ;
    20   cout << a.load(memory_order_relaxed) << endl; // 必然会打印 1
    21   return 0;
    22 }
    23 
    24 int main() {
    25   thread t1(valueset, 0);
    26   thread t2(observer, 0);
    27 
    28   t1.join();
    29   t2.join();
    30   cout << "Got (" << a << ", " << b << ")" << endl;
    31 }

    以上在 13 行和 18 行,分别使用了 memory_order_release 和 memory_order_acquire 的参数,这样就保证了执行顺序,也就是 b.store 的操作必须在 a.store 完成后再进行,而 a.load 操作必须在 18 行的循环结束后才能进行。

    由于 memory_order_release 和 memory_order_acquire 常常结合使用,所以称这种内存顺序为 release-acquire 内存顺序。

    还有一种 memory_order_consume 的 memory_order 枚举值,相比 memory_order_acquire,它进一步放松了一些依赖关系,参考以下代码:

     1 #include <atomic>
     2 #include <iostream>
     3 #include <thread>
     4 #include <assert.h>
     5 
     6 using namespace std;
     7 
     8 atomic<string*> ptr;
     9 atomic<int> data;
    10 
    11 void producer() {
    12         string* p = new string("Hello");
    13   data.store(42, memory_order_relaxed);
    14   ptr.store(p, memory_order_release);
    15 }
    16 
    17 void consumer() {
    18   string* p2=NULL;
    19   while (!(p2 = ptr.load(memory_order_consume)))
    20           ;
    21   assert(*p2 == "Hello");  // 总是相等
    22   assert(data.load(memory_order_relaxed) == 42);        // 可能断言失败
    23 }
    24 
    25 int main() {
    26   thread t1(producer);
    27   thread t2(consumer);
    28 
    29   t1.join();
    30   t2.join();
    31   return 0;
    32 }

    上面的 19 行只保证该原子操作发生在其他与 ptr 有关的原子操作之前,所以并不能保证第 22 行读 data 的操作不会被乱序执行。memory_order_release 和 memory_order_consume 的配合被称为 release-consume 内存顺序。

    root@ubuntu:/data1# g++  -std=c++11 test11.cpp -pthread  -o test11
    root@ubuntu:/data1# ./test11
    root@ubuntu:/data1# ./test11
    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
    32
    33
    34
    35
    36
    37
    38
    39
    40
    #include <atomic>
    #include <thread>
    #include <assert.h>
    std::atomic<bool> x,y;
    std::atomic<int> z;
    void write_x() //a
    {
    x.store(true,std::memory_order_seq_cst);
    }
    void write_y() //b
    {
    y.store(true,std::memory_order_seq_cst);
    }
    void read_x_then_y() //c
    {
    while(!x.load(std::memory_order_seq_cst)); // 1
    if(y.load(std::memory_order_seq_cst)) // 2
    ++z;
    }
    void read_y_then_x() //d
    {
    while(!y.load(std::memory_order_seq_cst)); // 3
    if(x.load(std::memory_order_seq_cst)) // 4
    ++z;
    }
    int main()
    {
    x=false;
    y=false;
    z=0;
    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);
    a.join();
    b.join();
    c.join();
    d.join();
    assert(z.load()!=0);
    }
    • acquire-release 获得-释放顺序
      acquire操作(load)—— memory_order_consume , memory_order_acquire
      release操作(store)—— memory_order_release
      acquire-release操作 —— memory_order_acq_rel (先acquire再做一次release,对于fetch_add这样的操作,既要load一下,又要store一下的,就用这个)
      一个原子做了这个acquire操作,肯定是读的是这个原子最后一次release操作修改的值。换句话说,要是采用release的方式store一个值,那么其他CPU都会看到这一次的修改。
      (不举例子了,C++并发编程实战这本书上有,理解后再去看还是比较简单的)
    • memory_order_consume 和 memory_order_acquire 有什么区别?
      memory_order_consume: 只能保证当前的变量,不能保证此线程中此load操作语句之前的其他变量的操作也被其他CPU看到,也就是只能保证自己在其他CPU上都更新了。(这样说不准确,应该做可以保证当前变量,以及此线程中此语句之前的,并且与当前变量有依赖关系的变量的修改被其他CPU看到)
      memory_order_acquire:不仅能保证当前变量的修改被其他CPU看到,也能保证这条语句之前的其他变量的load操作的值,也被其他CPU看到,不论其他变量是什么顺序的(relax的也会更新)。

    参考

    [1] 知乎上的这个回答,写的很好: 如何理解 C++11 的六种 memory order? - 知乎
    [2] C++11 memory order-博客-云栖社区-阿里云
    [3] 官方文档写的还可以(当我理解了,才觉得很好) std::memory_order - cppreference.com
    [4] 《C++并发编程实战》或者《C++ Concurrency in Action》

    [5] https://blog.csdn.net/netyeaxi/article/details/80718781

     [6]  https://sf-zhou.github.io/programming/memory_barrier.html

    [7]  C++ 原子类型与内存屏障 https://guodong.plus/2020/0518-174312/

  • 相关阅读:
    Jzoj5408 Dark
    Jzoj5407 Deep
    Jzoj5407 Deep
    Jzoj5456【NOIP2017提高A组冲刺11.6】奇怪的队列
    Jzoj5456【NOIP2017提高A组冲刺11.6】奇怪的队列
    Jzoj5455【NOIP2017提高A组冲刺11.6】拆网线
    Codeforces Round #621 (Div. 1 + Div. 2)C
    Codeforces Round #621 (Div. 1 + Div. 2)B Cow and Friend
    PAT甲级——A1031 Hello World for U
    PAT甲级——A1029 Median
  • 原文地址:https://www.cnblogs.com/dream397/p/14545052.html
Copyright © 2011-2022 走看看