内存模型
本节阅读量:为什么需要规定内存操作的顺序?
在现代多核处理器系统中,编译器和CPU为了优化性能,可能会对内存操作进行重排序。这种重排序在单线程环境下不会造成问题,但在多线程环境下可能导致意外的行为。C++ 内存模型提供了一种机制,让我们能够控制内存操作的顺序,确保多线程程序的正确性。
考虑如下的例子:
|
|
在单线程中,上面一段代码执行完后,z的结果与如下的程序结果一致,因此编译器和CPU可以任意的调整x与y的赋值顺序:
|
|
假设我们对x和y的赋值都是原子的,在多线程中,如果其它线程对x与y有进行访问,那么x与y的操作顺序就很关键。例如,在其它的线程中有如下的逻辑:
|
|
在其它线程 C 中,执行打印逻辑时。因为样例 A 与样例 B 在实际执行时效果等价,运行时可能会任意调整顺序,所以不一定实际会走哪段逻辑。所以,不管代码是样例 A 还是 样例 B,当感知到 y 等于 2 时,都有可能打印 「0」或者 「1」。
出现上面问题的原因,就是当得知 y 等于 2 时,无法确定这之前的操作已经对本线程可见,指令重排导致了读写顺序的变化。也就是说,在多线程的情况下,对内存进行访问时,只保证单条操作的原子性是不够的。
C++11引入了内存模型,定义了多线程环境下内存访问的行为规则。主要解决三个问题:
- 原子性(Atomicity):确保操作不可分割
- 可见性(Visibility):确保一个线程的写操作对其他线程可见
- 顺序性(Ordering):确保内存操作的相对顺序
注:
- 以上代码仅作为示例,实际普通的变量在多线程中存在同时读写行为是未定义的行为
- 实际上述的 x 与 y 是原子变量的话,如果是 relaxed 语义的话,就会出现上述的行为
Happens-Before关系
为了解决顺序性的问题,C++对原子指令进行操作时,可以指定如下的几种语义:
| 操作 | 说明 |
|---|---|
| memory_order_relaxed | 不限制顺序 |
| memory_order_acquire | 本条语句之后的指令,不往前重排 |
| memory_order_release | 本条语句之前的指令,不往后重排。也就是说,其它线程访问到本条指令所写的数据时,本线程之前所有的指令结果都可见 |
| memory_order_acq_rel | acquire + release |
| memory_order_seq_cst | acq_rel之外,外加使用seq_cst的指令全局有序,这也是原子变量默认的行为 |
release,意味着,before is before。acquire,意味着,after is after。在这样的语义之上,才能实现各种锁的逻辑。
那么对于上面的样例A,做如下的改下:
|
|
然后对于线程C,做如下的修改:
|
|
这时候,线程 C 感知到 y 等于 2 ,打印出来的就一定是「1」了。
注意,这些语义只解决了顺序性的问题,而不是可见性的问题。例如我们将样例 B 做如下的修改:
|
|
这时候,线程 C 执行时,当获得到 y 等于 2 时,线程 C 会打印出来什么?
对于线程 C 而言,能知道的就是 x 初始化为 0,但是 x = 1 是否执行过仍然未知,所以有可能打印 「0」或者 「1」。
此外在线程 C,除非循环等待,否则没有任何办法能知道什么时候, y 被赋值了。
最佳实践建议
对于普通的开发者而言,有如下的建议:
- 优先使用高级同步原语:能用std::mutex就别用原子操作
- 默认使用seq_cst:除非你确定懂如何优化,否则使用默认的memory_order_seq_cst
- 逐步优化:先确保正确性,再考虑性能优化
- 充分测试:在不同架构和编译器上测试代码
- 文档化:清楚注释为什么使用特定的内存顺序
同时也有一些如下的常见错误:
- 使用relaxed进行同步:这几乎总是错误的
- 混合不同内存顺序:除非完全理解,否则不要混用
- 忽略编译器重排序:只考虑CPU重排序而忽略编译器优化
- 过度优化:过早优化是万恶之源
总结
C++ memory order提供了强大的底层控制能力,但也带来了复杂性。
理解各种内存顺序的语义和适用场景是编写正确、高效多线程程序的关键。记住:
- seq_cst:最安全,适合大多数情况
- acquire/release:适合同步场景
- relaxed:仅用于特殊优化场景,例如计数
- 优先正确性,其次性能