C++ 中的 volatile 和 atomic

C++ 中的 volatile 和 atomic

0. TL;DR

  • std::atomic 用于多线程并发场景,有两个典型使用场景:

    • 原子操作:对 atomic 变量的操作(读/写/自增/自减)仿佛受互斥量保护。一般通过特殊的机器指令实现,比使用互斥量更高效
    • 限制编译器/硬件对赋值操作的重新排序
  • volatile 和多线程并发没有任何关系,用于防止编译器优化掉对特殊变量的“冗余”读写操作

1. Data Race 和未定义行为

C++ 中有很多未定义行为,Data Race 便是其中之一。

⚠️ 如果一个变量不是 atomic 变量,且没有互斥量保护,在一个线程中执行写操作,同时在另一个线程中读取,则会产生 Data Race,其行为未定义!

例如在执行下面代码的过程中,另一个线程同时读取 i 的值,读到的值可能是 -13,42,0,43923... 任何值!

int i = 0;
++i;
--i;

虽然在这种场景下,你读到的大概率是0/1,但要知道,对于未定义行为,理论上可能读到任何值!!

2. atomic

std::atomic 是 C++ 中的模版类,一般用于 bool、整型、指针类型,如 atomic<bool>atomic<int>atomic<Widget*> 等。对 atomic 变量的操作(读/写/自增/自减)仿佛受互斥量保护(底层一般通过特殊的机器指令实现,比使用互斥量更高效)。

2.1 原子操作

atomic 的第一个应用场景就是多线程读写变量

atomic<int> ai {0};
ai = 10;         // 原子写
std::cout << ai; // 原子读
++ai;            // 原子自增为11
--ai;            // 原子自减为10

执行上述代码期间,如果在另一个线程读取 ai 的值,只可能读到 0/10/11,不可能有其他结果

2.2 限制重排序

atomic 的第二个应用场景是,当某个变量在两个任务之间传递信息时,防止对该变量赋值进行重新排序。假设 a、b、x、y 是 4 个独立的变量:

a = b;
x = y;

为了提升性能,编译器可能会对不相关的赋值进行重新排序为:

x = y;
a = b;

即使编译器不这么做,底层硬件也可能这么做。atomic 可以避免这种重排序。例如在一个任务中计算 value,另一个任务中依赖 value 的可用性,则可以借助 atomic 变量实现:

atomic<bool> valueAvailable(false);
auto value = computeValue();
valueAvailable = true;

std::atomic 默认采用顺序一致性模型,会限制重新排序:不仅编译器会保证 value 在 valueAvailable 之前赋值,底层硬件也保证这个顺序。

⚠️ C++ 还提供了其他更灵活的一致性模型(如 memory_order_relaxed),除非你是这方面的专家,很清楚不同内存序产生的影响,否则不要轻易使用。

2.3 load/store

有的开发者喜欢使用 load()/store() 成员函数,这不是必须,但可以起到强调作用:

  • 强调该变量涉及多线程并发操作
  • 强调 atomic 变量可能导致性能问题
    • 虽然 atomic 比互斥量更高效,但仍然比普通变量慢、开销大
    • atomic 变量可能会阻止重新排序优化

3. volatile

如果将上面的例子中的 atomic<bool> 换成 volatile bool,既无法保证操作的原子性,也无法限制对 value 和 valueAvailable 赋值的重新排序。

volatile bool valueAvailable(false);
auto value = computeValue();
valueAvailable = true;

对于普通内存来说,如果向某个内存写一个值,该值会一直保留在内存,直到被下一个写操作覆盖。编译器可以对普通内存的变量读写进行优化,例如下面这段代码(虽然一般开发者不会直接写出这样的代码,但是经过模版实例化、内联、重排序等优化后,很可能产生类似的代码):

int x;
auto y = x; // 读取 x
y = x;  // 再次读取 x
x = 10; // 写入 x
x = 20; // 再次写入 x

可能被优化为:

auto y = x;
x = 20;

但是在有些特殊场景下(例如和外设交互),变量对应特殊的内存区域:

  • x 可能是一个传感器的值,连续两次读取 x 的值可能不同,不应该被优化

  • x 可能对应某个无线电发射器的控制端口,x = 10x = 20 对应两条不同的指令,不应该被优化

volatile 的作用正是告诉编译器,某个变量所对应内存是特殊内存,不要进行任何优化!

将 x 声明为 volatile int x 即可避免上述优化,而声明为 atomic<int> x 则没有这个效果。

⚠️ 经常听到一种说法:开启编译器优化选项之后,会导致程序行为异常。但通常都不是编译器的问题,而是自己的代码不规范,或依赖了未定义行为导致的!

思考:

  • volatile 和 atomic 可以同时使用吗,用于什么场景?
  • volatile 和 const 可以同时使用吗,用于什么场景?

4. Reference

《Effective Morden C++》条款 40

posted @ 2024-03-31 13:52  Zijian/TENG  阅读(188)  评论(0编辑  收藏  举报