c++ - T、volatile T 和 std::atomic<T> 之间有什么区别?

标签 c++ multithreading c++11 concurrency stdatomic

鉴于以下示例打算等待另一个线程存储 42在共享变量中 shared没有锁也没有等待线程终止,为什么会 volatile Tstd::atomic<T>需要或推荐以保证并发正确性?

#include <atomic>
#include <cassert>
#include <cstdint>
#include <thread>

int main()
{
  int64_t shared = 0;
  std::thread thread([&shared]() {
    shared = 42;
  });
  while (shared != 42) {
  }
  assert(shared == 42);
  thread.join();
  return 0;
}
使用 GCC 4.8.5 和默认选项,示例按预期工作。

最佳答案

测试似乎表明样本是正确的但它不是 .类似的代码很容易在生产中结束,甚至可以完美运行多年。
我们可以从编译样本开始 -O3 .现在,样本无限期地挂起。 (默认是 -O0 ,没有优化/调试一致性,这有点类似于让每个变量 volatilewhich is the reason the test didn't reveal the code as unsafe 。)
为了找到根本原因,我们必须检查生成的程序集。一、GCC 4.8.5 -O0基于 x86_64 程序集对应于未优化的工作二进制文件:

        // Thread B:
        // shared = 42;
        movq    -8(%rbp), %rax
        movq    (%rax), %rax
        movq    $42, (%rax)

        // Thread A:
        // while (shared != 42) {
        // }
.L11:
        movq    -32(%rbp), %rax     # Check shared every iteration
        cmpq    $42, %rax
        jne     .L11
线程 B 执行值 42 的简单存储在 shared .
线程 A 读取 shared对于每次循环迭代,直到比较表明相等。
现在,我们将其与 -O3 进行比较。结果:
        // Thread B:
        // shared = 42;
        movq    8(%rdi), %rax
        movq    $42, (%rax)

        // Thread A:
        // while (shared != 42) {
        // }
        cmpq    $42, (%rsp)         # check shared once
        je      .L87                # and skip the infinite loop or not
.L88:
        jmp     .L88                # infinite loop
.L87:
-O3 相关的优化用单个比较替换循环,如果不相等,则用无限循环来匹配预期行为。使用 GCC 10.2,优化了循环。 (与 C 不同,没有副作用或 volatile 访问的无限循环在 C++ 中是未定义的行为。)
问题是编译器及其优化器不知道实现的并发影响。因此,结论必须是shared不能在线程 A 中更改 - 循环相当于死代码。 (或者换句话说,数据竞争是 UB,并且优化器可以假设程序没有遇到 UB。如果您正在读取一个非原子变量,那一定意味着没有其他人在写它。这个是什么允许编译器从循环中提升负载,以及类似的接收器存储,这对于非共享变量的正常情况是非常有值(value)的优化。)
该解决方案要求我们与编译器通信 shared参与线程间通信。实现这一点的一种方法可能是 volatile .而volatile的实际含义因编译器而异,并且保证(如果有)是特定于编译器的,普遍的共识是 volatile阻止编译器在基于寄存器的缓存方面优化 volatile 访问。这对于与硬件交互并在并发编程中占有一席之地的低级代码至关重要,尽管由于 std::atomic 的引入而呈下降趋势。 .
volatile int64_t shared ,生成的指令变化如下:
        // Thread B:
        // shared = 42;
        movq    24(%rdi), %rax
        movq    $42, (%rax)

        // Thread A:
        // while (shared != 42) {
        // }
.L87:
        movq    8(%rsp), %rax
        cmpq    $42, %rax
        jne     .L87
由于必须假设 shared,因此无法再消除循环。即使没有代码形式的证据也改变了。因此,该示例现在适用于 -O3 .
volatile解决了这个问题,你为什么需要 std::atomic ?与无锁代码相关的两个方面是 std::atomic本质:内存操作原子性和内存顺序。
为了构建加载/存储原子性的案例,我们回顾了使用 GCC4.8.5 编译的生成的程序集。 -O3 -m32 (32 位版本)适用于 volatile int64_t shared :
        // Thread B:
        // shared = 42;
        movl    4(%esp), %eax
        movl    12(%eax), %eax
        movl    $42, (%eax)
        movl    $0, 4(%eax)

        // Thread A:
        // while (shared != 42) {
        // }
.L88:                               # do {
        movl    40(%esp), %eax
        movl    44(%esp), %edx
        xorl    $42, %eax
        movl    %eax, %ecx
        orl     %edx, %ecx
        jne     .L88                # } while(shared ^ 42 != 0);
对于 32 位 x86 代码生成,64 位加载和存储通常分为两条指令。对于单线程代码,这不是问题。对于多线程代码,这意味着另一个线程可以看到 64 位内存操作的部分结果,为意外的不一致留出空间,这些不一致可能不会 100% 的时间导致问题,但可能会随机发生并且出现概率受周围代码和软件使用模式的严重影响。即使 GCC 选择生成默认保证原子性的指令,这仍然不会影响其他编译器,并且可能不适用于所有支持的平台。
为了防止在所有情况下以及跨所有编译器和受支持平台的部分加载/存储,std::atomic可以就业。让我们回顾一下如何std::atomic影响生成的程序集。更新的样本:
#include <atomic>
#include <cassert>
#include <cstdint>
#include <thread>

int main()
{
  std::atomic<int64_t> shared;
  std::thread thread([&shared]() {
    shared.store(42, std::memory_order_relaxed);
  });
  while (shared.load(std::memory_order_relaxed) != 42) {
  }
  assert(shared.load(std::memory_order_relaxed) == 42);
  thread.join();
  return 0;
}
基于 GCC 10.2 ( -O3 : https://godbolt.org/z/8sPs55nzT ) 生成的 32 位程序集:
        // Thread B:
        // shared.store(42, std::memory_order_relaxed);
        movl    $42, %ecx
        xorl    %ebx, %ebx
        subl    $8, %esp
        movl    16(%esp), %eax
        movl    4(%eax), %eax       # function arg: pointer to  shared
        movl    %ecx, (%esp)
        movl    %ebx, 4(%esp)
        movq    (%esp), %xmm0       # 8-byte reload
        movq    %xmm0, (%eax)       # 8-byte store to  shared
        addl    $8, %esp

        // Thread A:
        // while (shared.load(std::memory_order_relaxed) != 42) {
        // }
.L9:                                # do {
        movq    -16(%ebp), %xmm1       # 8-byte load from shared
        movq    %xmm1, -32(%ebp)       # copy to a dummy temporary
        movl    -32(%ebp), %edx
        movl    -28(%ebp), %ecx        # and scalar reload
        movl    %edx, %eax
        movl    %ecx, %edx
        xorl    $42, %eax
        orl     %eax, %edx
        jne     .L9                 # } while(shared.load() ^ 42 != 0);
为了保证加载和存储的原子性,编译器发出一个 8 字节 SSE2 movq instruction (到/从 128 位 SSE 寄存器的下半部分)。此外,程序集显示即使 volatile 环仍保持完整去掉了。
通过使用 std::atomic在样本中,可以保证
  • std::atomic 加载和存储不受基于寄存器的缓存
  • std::atomic 加载和存储不允许观察部分值

  • C++ 标准根本没有谈论寄存器,但它确实说:

    Implementations should make atomic stores visible to atomic loads within a reasonable amount of time.


    虽然这留下了解释的空间,但缓存 std::atomic跨迭代加载,例如在我们的示例中触发(没有 volatile 或 atomic)显然是一种违规——存储可能永远不会变得可见。当前编译器 don't even optimize atomics within one block , 就像在同一次迭代中进行 2 次访问一样。
    在 x86 上,自然对齐的加载/存储(其中地址是加载/存储大小的倍数)是 atomic up to 8 bytes without special instructions .这就是为什么 GCC 能够使用 movq .atomic<T>带大号T硬件可能不直接支持,在这种情况下编译器可以回退to using a mutex .
    大号T (例如 2 个寄存器的大小)在某些平台上可能需要原子 RMW 操作(如果编译器不简单地回退到锁定),有时提供的大小比最大的有效纯加载/纯存储更大保证原子。 (例如,在 x86-64、 lock cmpxchg16 或 ARM ldrexd/strexd 重试循环上)。单指令原子 RMW(如 x86 使用)internally involve a cache line lock or a bus lock .例如,旧版本的 clang -m32 x86 将使用 lock cmpxchg8b而不是 movq用于 8 字节纯加载或纯存储。
    上面提到的第二个方面是什么,是什么std::memory_order_relaxed意思?
    编译器和 CPU 都可以重新排序内存操作以优化效率。重新排序的主要约束是所有加载和存储都必须按照代码给出的顺序(程序顺序)执行。因此,在线程间通信的情况下,尽管重新排序尝试,但必须考虑内存顺序以建立所需的顺序。可以为 std::atomic 指定所需的内存顺序加载和存储。 std::memory_order_relaxed不强加任何特定的顺序。
    互斥原语强制执行特定的内存顺序(获取-释放顺序),以便内存操作保持在锁范围内,并且保证先前锁所有者执行的存储对后续锁所有者可见。因此,使用锁,这里提出的所有方面都可以通过使用锁定工具来解决。一旦您打破了提供的舒适锁,您就必须注意影响并发正确性的后果和因素。
    尽可能明确地说明线程间通信是一个很好的起点,以便编译器了解加载/存储上下文并可以相应地生成代码。只要有可能,prefer std::atomic<T>std::memory_order_relaxed (除非场景要求特定的内存顺序)到 volatile T (当然还有 T )。此外,只要有可能,最好不要推出自己的无锁代码,以降低代码复杂性并最大限度地提高正确性的概率。

    关于c++ - T、volatile T 和 std::atomic<T> 之间有什么区别?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/66789935/

    相关文章:

    c++ - 为什么我的代码无法处理大数组输入(>10000)?

    c# - 在 C# 应用程序退出时清理非托管 C++ 线程

    java - 关闭两个 ExecutorService 实例

    c++ - 使用模板对单个 c++ 对象和 std::pair 对象进行抽象

    c++ - 线程抛出异常后出现"Bad promise"错误

    c++ - 如何正确使用动态数组?

    c++ - 为什么必须在哪里放置 “template”和 “typename”关键字?

    c++ - 可能有也可能没有成员的模板结构

    c++ - 我可以在单读/单写队列中用 volatile 替换原子吗?

    c++ - 为什么我无法使用 C++11 基于范围的 for 循环迭代 vector ?