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/

posted on 2021-03-16 18:09  tycoon3  阅读(730)  评论(0编辑  收藏  举报

导航