T、volatile T 和 std::atomic<T> 有什么区别?

What's the difference between T, volatile T, and std::atomic<T>?

鉴于下面的示例打算等待另一个线程将 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,没有优化/调试一致性,有点类似于让每个变量都volatile。)

要找到根本原因,我们必须检查生成的程序集。首先,基于 GCC 4.8.5 -O0 的 x86_64 程序集对应于未优化的工作二进制文件:

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

        // Thread A:
        // while (shared != 42) {
        // }
.L11:
        movq    -32(%rbp), %rax     # Check shared every iteration
        cmpq    , %rax
        jne     .L11

线程 B 在 shared 中执行值 42 的简单存储。 线程 A 为每个循环迭代读取 shared,直到比较表明相等。

现在,我们将其与 -O3 结果进行比较:

        // Thread B:
        // shared = 42;
        movq    8(%rdi), %rax
        movq    , (%rax)

        // Thread A:
        // while (shared != 42) {
        // }
        cmpq    , (%rsp)         # check shared once
        je      .L87                # and skip the infinite loop or not
.L88:
        jmp     .L88                # infinite loop
.L87:

-O3 相关的优化将循环替换为单个比较,如果不相等,则使用无限循环来匹配预期行为。使用 GCC 10.2,优化了循环。 (与 C 不同,没有副作用或易失性访问的无限循环在 C++ 中是未定义的行为。)

问题是编译器及其优化器不知道实现的并发含义。因此,结论需要是 shared 不能在线程 A 中更改 - 循环等同于死代码。 (或者换句话说,数据竞争是 UB,允许优化器假设程序没有遇到 UB。如果你正在读取一个非原子变量,那一定意味着没有其他人在写它。这是什么允许编译器将负载提升到循环之外,并类似地吸收存储,这对于非共享变量的正常情况是非常有价值的优化。)

该解决方案要求我们与编译器沟通,shared 涉及线程间通信。实现这一目标的一种方法可能是 volatile。虽然 volatile 的实际含义因编译器而异,并且保证(如果有的话)是特定于编译器的,但普遍的共识是 volatile 阻止编译器在基于寄存器的缓存方面优化易失性访问。这对于与硬件交互并在并发编程中占有一席之地的低级代码至关重要,尽管由于 std::atomic.

的引入而呈下降趋势

使用volatile int64_t shared,生成的指令变化如下:

        // Thread B:
        // shared = 42;
        movq    24(%rdi), %rax
        movq    , (%rax)

        // Thread A:
        // while (shared != 42) {
        // }
.L87:
        movq    8(%rsp), %rax
        cmpq    , %rax
        jne     .L87

无法再消除循环,因为必须假设 shared 已更改,即使没有代码形式的证据。因此,示例现在适用于 -O3.

如果 volatile 解决了问题,为什么还需要 std::atomic?与无锁代码相关的两个方面使 std::atomic 必不可少:内存操作原子性和内存顺序。

为了构建 load/store 原子性的案例,我们回顾了使用 GCC4.8.5 -O3 -m32(32 位版本)为 volatile int64_t shared 编译生成的程序集:

        // Thread B:
        // shared = 42;
        movl    4(%esp), %eax
        movl    12(%eax), %eax
        movl    , (%eax)
        movl    [=13=], 4(%eax)

        // Thread A:
        // while (shared != 42) {
        // }
.L88:                               # do {
        movl    40(%esp), %eax
        movl    44(%esp), %edx
        xorl    , %eax
        movl    %eax, %ecx
        orl     %edx, %ecx
        jne     .L88                # } while(shared ^ 42 != 0);

对于 32 位 x86 代码生成,64 位加载和存储通常分为两条指令。对于单线程代码,这不是问题。对于多线程代码,这意味着另一个线程可以看到 64 位内存操作的部分结果,从而为意外的不一致留下空间,这些不一致可能不会 100% 地导致问题,但可能随机发生并且发生的概率受周围代码和软件使用模式的严重影响。即使 GCC 选择生成默认保证原子性的指令,这仍然不会影响其他编译器,并且可能不适用于所有支持的平台。

为了在所有情况下以及在所有编译器和支持的平台上防止部分 loads/stores,可以使用 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生成的32位程序集(-O3:https://godbolt.org/z/8sPs55nzT):

        // Thread B:
        // shared.store(42, std::memory_order_relaxed);
        movl    , %ecx
        xorl    %ebx, %ebx
        subl    , %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    , %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    , %eax
        orl     %eax, %edx
        jne     .L9                 # } while(shared.load() ^ 42 != 0);

为了保证加载和存储的原子性,编译器发出一个 8 字节 SSE2 movq instruction(to/from 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)显然是一种违规行为——存储可能永远不会变得可见。当前编译器 ,例如在同一迭代中进行 2 次访问。

在 x86 上,自然对齐的 loads/stores(地址是 load/store 大小的倍数)是 。这就是 GCC 能够使用 movq.

的原因

atomic<T> 具有较大的 T 可能不受硬件直接支持,在这种情况下,编译器可以回退 .

某些平台上的大 T(例如 2 个寄存器的大小)可能需要原子 RMW 操作(如果编译器不简单地回退到锁定),有时会提供更大的大小比保证原子性的最大高效纯加载/纯存储。 (例如,在 x86-64 上,lock cmpxchg16,或 ARM ldrexd/strexd 重试循环)。单指令原子 RMW(如 x86 使用)。例如,用于 x86 的旧版本 clang -m32 将使用 lock cmpxchg8b 而不是 movq 来进行 8 字节纯加载或纯存储。

上面说的第二个方面是什么,std::memory_order_relaxed是什么意思? 编译器和 CPU 都可以重新排序内存操作以优化效率。重新排序的主要约束是所有加载和存储必须看起来是按照代码给定的顺序(程序顺序)执行的。因此,在线程间通信的情况下,必须考虑内存顺序以建立所需的顺序,尽管有重新排序尝试。可以为 std::atomic 加载和存储指定所需的内存顺序。 std::memory_order_relaxed 不强加任何特定顺序。

互斥原语强制执行特定的内存顺序(获取-释放顺序),以便内存操作保留在锁范围内,并且保证前一个锁所有者执行的存储对后续锁所有者可见。因此,使用锁,这里提出的所有方面都可以通过使用锁定工具简单地解决。一旦您突破了舒适锁提供的限制,您就必须注意后果和影响并发正确性的因素。

尽可能明确地了解线程间通信是一个很好的起点,以便编译器了解 load/store 上下文并可以相应地生成代码。只要有可能,prefer std::atomic<T>std::memory_order_relaxed(除非场景要求特定的内存顺序)到 volatile T(当然还有 T)。此外,只要有可能,最好不要推出自己的无锁代码,以降低代码复杂性并最大限度地提高正确性。

如果您不使用显式共享结构,就像您提到的那样,当 main() 看到 shared 的值为 42 时,它是未定义的:请参阅下面的“优化和重新排序”。即使您的测试没有发现问题:请查看下面的“关于您的测试”!

在多线程中,给出“正确”答案的测试(几乎)永远不会证明正确性。

“成功”测试最多 anecdotal evidence 需要考虑的太多了,例如:

  • memory model:什么是有保证的,更有可能:什么不是!
  • 编译器优化和CPU
  • 正在安排。例如,thread 可以在 while 循环之前和 thread.join() 函数内部之间的任何位置终止。
  • 运行-time 东西,比如有多少其他线程和程序正在 运行ning,内存使用量等。这取决于硬件和操作系统。
  • 我忘了更多的东西......

您唯一可以信任的是您的语言 memory model 提供的 保证

幸运的是,C++ 从 C++11 开始就有了内存模型!

不幸的是,该模型并没有提供太多保证。编译器可以生成允许做任何事情的代码,只要程序的语义不改变从单线程的角度来看。这包括省略代码、推迟代码或更改事情发生的顺序。唯一的例外是当您制作 guaranteed progress 时,或者当您使用显式共享结构时,就像您提到的那样。

调试多线程情况也非常困难。添加“调试代码”来调试你的程序通常会改变它的行为。例如,将某些内容写入标准输出会 I/O,从而确保进度。这可能会导致值被其他线程可见,而通常情况下不会出现这种情况!

确保您了解您提到的原子、易失性和互斥体等结构的作用。这样,您就可以构建在多线程环境下行为完全可预测的程序。

关于你的测试

为了好玩,让我们探索一些围绕您的测试程序的有趣案例。

线程调度

操作系统决定线程何时 运行 和终止。

完全可以接受 thread 甚至在执行 main() 中的 while 循环之前就已经终止。因为线程终止是 progressshared 可能会在 main() 可以看到它的地方结束,在 while 循环之前。在那种情况下,测试似乎成功了。但如果调度有任何不同,测试可能会失败。你应该永远不要依赖调度。

因此,即使您的测试没有发现问题,那也最多是轶事证据。

优化和重新排序

正如 已经指出的那样,编译器和 CPU 可以优化您的代码。任何事情都是允许的,只要程序语义不改变从单线程的角度

想象一下,您分配给一个您在该线程中再也不会读取的变量(就像您在 thread 中所做的那样)。编译器可以根据需要推迟实际赋值,因为就编译器而言,没有任何依赖于该线程中 shared 值的东西。您的线程中必须有 guaranteed progress 以确保实际分配。在您的示例中,仅当 thread 终止时才能保证此进度:可能在线程函数的末尾。话又说回来:您不知道线程计划何时调用您的函数。

使用 atomic<>volatile 等结构强制编译器生成确保可预测行为的代码。如果您知道如何使用它们,您就可以制作出在多线程环境下可以正常运行的程序。