读完了C++并发编程实战,记录一下对memory order的理解。
1. C++原子操作的内存顺序概述
- 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修改的数据是不保证的。
- 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
|
|
- 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/