内存模型

本节阅读量:

为什么需要规定内存操作的顺序?

在现代多核处理器系统中,编译器和CPU为了优化性能,可能会对内存操作进行重排序。这种重排序在单线程环境下不会造成问题,但在多线程环境下可能导致意外的行为。C++ 内存模型提供了一种机制,让我们能够控制内存操作的顺序,确保多线程程序的正确性。

考虑如下的例子:

1
2
3
4
5
6
7
8
// 样例A
int x{0};

x = 1;
y = 2;
// 在单线程中,在这一行之前,对x与y的操作顺序可以任意切换
// 对程序的执行结果不会有任何影响
z = x + y;

在单线程中,上面一段代码执行完后,z的结果与如下的程序结果一致,因此编译器和CPU可以任意的调整x与y的赋值顺序:

1
2
3
4
5
6
7
8
// 样例B
int x{0};

y = 2;
x = 1;
// 在单线程中,在这一行之前,对x与y的操作顺序可以任意切换
// 对程序的执行结果不会有任何影响
z = x + y;

假设我们对x和y的赋值都是原子的,在多线程中,如果其它线程对x与y有进行访问,那么x与y的操作顺序就很关键。例如,在其它的线程中有如下的逻辑:

1
2
3
4
// 其它线程C的逻辑
if (y == 2) {
    std::cout << x << std::endl;
}

在其它线程 C 中,执行打印逻辑时。因为样例 A 与样例 B 在实际执行时效果等价,运行时可能会任意调整顺序,所以不一定实际会走哪段逻辑。所以,不管代码是样例 A 还是 样例 B,当感知到 y 等于 2 时,都有可能打印 「0」或者 「1」。

出现上面问题的原因,就是当得知 y 等于 2 时,无法确定这之前的操作已经对本线程可见,指令重排导致了读写顺序的变化。也就是说,在多线程的情况下,对内存进行访问时,只保证单条操作的原子性是不够的。

C++11引入了内存模型,定义了多线程环境下内存访问的行为规则。主要解决三个问题:

  1. 原子性(Atomicity):确保操作不可分割
  2. 可见性(Visibility):确保一个线程的写操作对其他线程可见
  3. 顺序性(Ordering):确保内存操作的相对顺序

注:

  1. 以上代码仅作为示例,实际普通的变量在多线程中存在同时读写行为是未定义的行为
  2. 实际上述的 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,做如下的改下:

1
2
3
4
5
6
// 样例A
int x{0};

x = 1;  // x = 1 不会重排到 y 的赋值之后了
y.store(2, std::memory_order_release);
z = x + y;

然后对于线程C,做如下的修改:

1
2
3
4
// 其它线程C的逻辑
if (y.load(std::memory_order_acquire) == 2) {
    std::cout << x << std::endl;
}

这时候,线程 C 感知到 y 等于 2 ,打印出来的就一定是「1」了。

注意,这些语义只解决了顺序性的问题,而不是可见性的问题。例如我们将样例 B 做如下的修改:

1
2
3
4
5
6
7
8
// 样例B
int x{0};

y.store(2, std::memory_order_release);
x = 1;
// 在单线程中,在这一行之前,对x与y的操作顺序可以任意切换
// 对程序的执行结果不会有任何影响
z = x + y;

这时候,线程 C 执行时,当获得到 y 等于 2 时,线程 C 会打印出来什么?

对于线程 C 而言,能知道的就是 x 初始化为 0,但是 x = 1 是否执行过仍然未知,所以有可能打印 「0」或者 「1」。

此外在线程 C,除非循环等待,否则没有任何办法能知道什么时候, y 被赋值了。

最佳实践建议

对于普通的开发者而言,有如下的建议:

  1. 优先使用高级同步原语:能用std::mutex就别用原子操作
  2. 默认使用seq_cst:除非你确定懂如何优化,否则使用默认的memory_order_seq_cst
  3. 逐步优化:先确保正确性,再考虑性能优化
  4. 充分测试:在不同架构和编译器上测试代码
  5. 文档化:清楚注释为什么使用特定的内存顺序

同时也有一些如下的常见错误:

  1. 使用relaxed进行同步:这几乎总是错误的
  2. 混合不同内存顺序:除非完全理解,否则不要混用
  3. 忽略编译器重排序:只考虑CPU重排序而忽略编译器优化
  4. 过度优化:过早优化是万恶之源

总结

C++ memory order提供了强大的底层控制能力,但也带来了复杂性。

理解各种内存顺序的语义和适用场景是编写正确、高效多线程程序的关键。记住:

  1. seq_cst:最安全,适合大多数情况
  2. acquire/release:适合同步场景
  3. relaxed:仅用于特殊优化场景,例如计数
  4. 优先正确性,其次性能

0.6 原子变量

上一节

常用库

下一节