【问题标题】:What's the difference between T, volatile T, and std::atomic<T>?T、volatile T 和 std::atomic<T> 之间有什么区别?
【发布时间】:2021-06-21 16:31:16
【问题描述】:

鉴于以下示例打算等到另一个线程将42 存储在共享变量shared 中而没有锁定且不等待线程终止,为什么需要或建议使用volatile Tstd::atomic&lt;T&gt; 来保证并发性正确吗?

#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 和默认选项,示例按预期工作。

【问题讨论】:

  • 我为什么投票重新开放:恕我直言,这个问题和答案增加了价值,因为他们基于特定示例而不是广泛而没有细节来讨论该主题,这使得回答变得更加困难,并且要求答案更笼统比在这种情况下。此外,因为有一个样本,所以答案提供了证据来支持关于 volatile 和 std::atomic 应该如何工作的说法。
  • 同意。特定应胜过一般,除非特定本质上重复一般。不过,前一个骗子应该继续作为一个很好的参考。 Concurrency: Atomic and volatile in C++11 memory model
  • 仅使用atomic 是正确的,其他都是UB,至少有比赛访问权限(即使使用volatile)。
  • 我不认为问题的第一段是自我辩解的好地方。如果有的话,评论(已经存在)。 IMO,即使是问题底部的注释也太多了,但也许。特别是如果您将其保持得更短,例如“这个问题主要是我回答的占位符和示例”。 (如果您觉得需要,“我的回答”可以是指向帮助页面的链接,说明自我回答很好。但我认为此时问题的得分为正数且票数接近,您不要'不需要进一步的理由。)

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


【解决方案1】:

测试似乎表明样本是正确的但不是。类似的代码很容易最终投入生产,甚至可以完美运行多年。

我们可以从使用-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 在shared 中执行值42 的简单存储。 线程 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 不同,没有副作用或易失性访问的无限循环在 C++ 中是未定义的行为。)

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

解决方案要求我们与编译器沟通shared参与线程间通信。实现这一目标的一种方法可能是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 生成的 32 位程序集(-O3:https://godbolt.org/z/8sPs55nzT):

        // 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++ 标准根本没有谈论寄存器,但它确实说:

实现应该使原子存储在合理的时间内对原子负载可见。

虽然这为解释留下了空间,但缓存 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&lt;T&gt; 带有大的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。例如,用于 x86 的旧版本 clang -m32 将使用 lock cmpxchg8b 而不是 movq 用于 8 字节纯加载或纯存储。

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

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

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

【讨论】:

  • 测试表明样本是正确的 - 我建议将其表述为“测试未发现任何问题”。正如您所说,这并不意味着没有任何代码,但是众所周知,无锁代码很难测试,特别是如果您只有 x86(运行时重新排序是有限的,所以只有编译时重新排序才能破坏某些东西,不像在 ARM 或 POWER 上)。
  • Re:编译器将原子值保存在寄存器中:ISO C++ 标准的“as-if”规则确实适用于原子,标准中的语言只讨论程序可能或必须遵守的可能值一定条件下。但事实证明它比最初意识到的问题更多,因此当前的编译器将 atomic&lt;T&gt; 基本上视为 volatile atomic&lt;T&gt;Why don't compilers merge redundant std::atomic writes? 链接 WG21/P0062R1 和 N4455。
  • 谢谢@PeterCordes,我稍微改了句,强调样本确实不正确。
  • 我关于测试的观点是,这是思考测试的错误方式。没有发现问题的测试并不表明代码是正确的。这是具有未定义行为的语言的基础,尤其是对于多线程代码,当不同的测试平台只能执行某些类型的重新排序或停止/线程休眠/排序时。特别是如果您没有使用 UB 消毒剂和/或种族检测模拟器。 (如果您正在使用这些东西,您可以更加确定测试是否成功 => 没问题。)
  • @PeterCordes 感谢您的编辑。添加的参考资料和关于原子性实现的说明肯定会为答案增加价值!考虑到装配的最小复杂性,添加的 cmets 可能不是必需的,但现在读者肯定更容易遵循思路。再次感谢您抽出宝贵时间查看和编辑答案!
【解决方案2】:

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

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

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

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

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

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

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

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

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

关于您的测试

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

线程调度

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

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

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

优化和重新排序

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

想象一下,您分配给一个在该线程中再也不会读取的变量(就像您在 thread 中所做的那样)。编译器可以根据需要推迟实际分配,因为在编译器可以看到的范围内,该线程中的shared 的值没有任何影响。您的线程中必须有 guaranteed progress 以确保实际分配。在您的示例中,只有在 thread 终止时才能保证此进度:可能在线程函数结束时。再说一遍:您不知道线程何时计划调用您的函数。

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

【讨论】:

  • 但是,即使分配确实发生了,它也可能永远不会到达主内存。 - 但这不会阻止其他线程看到它。我们可以在其上运行多个线程的系统具有一致的缓存。有关 CPU 架构的详细信息,请参阅When to use volatile with multi threading?(基本上从不)和Myths Programmers Believe about CPU Caches。共享变量在共享 L3 缓存中保持热状态是正常的,而不是实际写入/从慢速 DRAM 写入。
  • 是的,执行str reg, (mem) 并不会立即使其可见,所有值得放入 SMP 系统的现代 CPU 都有一个存储缓冲区。但是存储缓冲区不像回写缓存。它总是试图尽快耗尽自己,为以后发生的缓存未命中存储的爆发腾出空间。如果执行 asm 存储指令,则从同一地址进行加载的其他内核将在最坏的情况下在几微秒内看到新值,通常更像是 40ns。 (还有很多时钟周期)。问题是编译器优化,如死存储消除或将值保存在寄存器中。
  • (除了那个误解,不错的答案。一旦改变,我很乐意投票,但关于 CPU 缓存一致性的错误信息是我最讨厌的问题之一。)
  • @PeterCordes 可能由于其他优化,“分配”是错误的词。但我同意并将删除该部分。
  • 实际上鼓励发布一个自我回答的问答,是的,如果问题太宽泛而无法期望其他人回答,只要答案是连贯的,也可以。 (甚至还有一个 UI 选项可以将答案与问题一起发布,因此人们暂时不会看到占位符未回答的问题。我的几个问题是这样发布的,例如 64 位代码上的 int 0x80 和 int- > asm / AVX-512 中的十六进制 ASCII。)是的,听起来完全合理,是尝试写一个大图解释的好主意,我很乐意复习它,也许重写一两段。跨度>
猜你喜欢
  • 2018-01-02
  • 2021-07-10
  • 2016-01-19
  • 2023-04-11
  • 2014-06-12
  • 2021-06-02
  • 2012-03-10
  • 2020-09-15
  • 1970-01-01
相关资源
最近更新 更多