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

7

假设有以下代码示例,它旨在等待另一个线程将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 和默认选项,示例能够正常工作。


1
为什么我投票支持重新开放:在我看来,这个问题和答案的价值在于它们基于一个具体的例子讨论了这个主题,而不是笼统地没有具体细节,这使得回答更加困难,并需要回答比这种情况下更加一般化。此外,因为有样本,答案提供了支持如何处理易变和std::atomic的声明的证据。 - horstr
同意。具体问题应该优先于一般性问题,除非具体问题本质上重复了一般性问题。前者的副本应作为一个好的参考资料存在。并发:C++11内存模型中的原子和易失性 - user4581301
1
只有使用 atomic 是正确的,其他方式即使使用 volatile 也会存在至少竞争访问的 UB(未定义行为)。 - Jarod42
我认为问题的开头段落不是自我辩解的好位置。如果有的话,可以在评论中表达。即使是在问题底部加上一句注释也会有点过了,在我看来可能有些多余。特别是如果你缩短得多,例如“这个问题主要是我回答的占位符和示例”。(其中“我的答案”可以是解释自我回答很好的帮助页面的链接,如果你觉得需要的话。但是我认为现在问题已经有一个正数分并且当前有关闭投票,你不需要进一步的自我辩解了。) - Peter Cordes
2个回答

12

这个测试似乎表明样本是正确的,但实际上并不是。类似的代码可能很容易地进入生产环境,并且甚至可能运行多年而没有问题。

我们可以使用-O3编译样本。现在,该样本无限期挂起。(默认值为-O0,即无优化/调试一致性,有点类似于使每个变量volatile这就是测试未能揭示代码不安全的原因。)

要找到根本原因,我们必须检查生成的汇编代码。首先,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中不能更改——循环等同于死代码。(或者换句话说,竞争数据是未定义的行为,优化器允许假设程序不会遇到未定义的行为。如果你正在读取非原子变量,那么这必须意味着没有人写它。这就允许编译器将负载提升出循环,并类似地下沉存储,这些对于非共享变量的正常情况非常有价值的优化。)
解决方案需要我们向编译器通信,在线程间通信中涉及到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指令(与128位SSE寄存器的底半部分进行交互)。此外,汇编显示尽管已经删除了volatile,但循环仍然保持不变。
通过在示例中使用std::atomic,可以确保:
  • std::atomic加载和存储不受基于寄存器的缓存的影响
  • std::atomic加载和存储不允许观察到部分值
C++标准根本没有谈论寄存器,但它确实说:

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

虽然这留下了解释的余地,但像我们的示例中触发的(没有volatile或atomic)跨迭代缓存std::atomic加载显然是一种违规行为-存储可能永远不会变得可见。当前编译器甚至不会优化同一块内的原子操作,比如同一次迭代中的2个访问。
在x86上,自然对齐的加载/存储(其中地址是加载/存储大小的倍数)在没有特殊指令的情况下高达8字节的原子性。这就是为什么GCC能够使用movq的原因。
具有大型T(例如2个寄存器的大小)的atomic<T>在某些平台上可能不会直接得到支持,此时编译器可以退回使用互斥锁
一些平台上的大型T可能需要原子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 不强制执行任何特定顺序。
互斥原语强制实施特定的内存顺序(获取释放顺序),以便内存操作保留在锁定范围内,并且由前一个锁定所有者执行的存储保证对后续锁定所有者可见。因此,使用锁定,所有这里提出的方面都可以通过简单地使用锁定设施来解决。一旦您退出舒适的锁定,您必须注意影响并影响并发正确性的因素。
尽可能明确地说明线程间通信是一个好的起点,以便编译器了解加载/存储上下文并相应地生成代码。每当可能时,prefer 使用带有 std::memory_order_relaxedstd::atomic<T>(除非场景需要特定的内存顺序)而不是 volatile T(当然,T)。此外,尽可能避免自己编写无锁代码以减少代码复杂性并最大化正确性的概率。

1
测试表明样本是正确的-我建议用“测试没有检测到任何问题”的措辞。正如你所说,这并不意味着没有任何问题,但是无锁代码非常难以测试,尤其是如果您只有x86(其中运行时重新排序受到限制,因此只有编译时重新排序会破坏某些内容,不像在ARM或POWER上)。 - Peter Cordes
1
关于编译器在寄存器中保留原子值的问题:ISO C++标准的“as-if”规则适用于原子操作,标准中的语言仅讨论程序在特定条件下可能或必须观察到的可能值。但事实证明这比最初意识到的更为棘手,因此当前的编译器将atomic<T>基本上视为volatile atomic<T>为什么编译器不合并冗余的std::atomic写入? 链接了WG21/P0062R1和N4455。 - Peter Cordes
1
我的测试观点是,那不是考虑测试的正确方式。一个没有发现问题的测试并不能证明代码是正确的。在具有未定义行为的语言中,这是基本原则,特别是对于多线程代码,不同的测试平台只能执行某些重新排序或停顿/线程睡眠/排序的类型。尤其是如果您没有使用UB-sanitizer和/或race-detection模拟器。(如果您使用了这些工具,您可以更加确定成功的测试=>没有问题。) - Peter Cordes
是的,@PeterCordes,再次感谢。我知道那个问题和相关讨论。标准中的那句话说“实现应该在合理的时间内使原子存储对原子加载可见。”这表明在这种情况下不能应用as-if规则,因为寄存器缓存会违反“在合理的时间内使存储可见”的概念。不过,在冗余存储或著名的“进度条”讨论(将实际存储推迟到达到100%时)方面,你绝对是正确的。 - horstr
1
@PeterCordes 感谢您的编辑。添加的参考文献和有关原子性实现的澄清无疑为答案增加了价值!考虑到汇编语言的最小复杂性,可能并不需要添加注释,但现在读者肯定更容易跟随思路。再次感谢您抽出时间审查和编辑答案! - horstr
显示剩余16条评论

1
如果您没有使用像您提到的那样的显式共享构造,那么当main()看到shared具有值42时,它是未定义的:请参见下面的“优化和重新排序”。即使您的测试没有发现问题:请查看下面的“关于您的测试”!
在多线程中,给出“正确”答案的测试(几乎)从不是正确性的证明。
一个“成功”的测试最多只是个别证据。有太多的因素需要考虑,比如:
  • 内存模型:保证什么,更有可能的是不保证什么!
  • 编译器和CPU的优化
  • 调度。例如,thread可以在while循环之前或thread.join()函数内部的任何位置终止。
  • 运行时的东西,如运行其他线程和程序的数量,内存使用情况等。这既取决于硬件又取决于操作系统。
  • 我忘记了更多的事情...

唯一可以信任的是你的语言所提供的内存模型所保证的内容。

幸运的是,自C++11以来,C++拥有了一个内存模型!

很不幸,该模型并不能提供太多保证。编译器可以生成允许执行任何操作的代码,只要程序的语义在单线程视角下没有改变。这包括省略代码、推迟代码或更改事情发生的顺序。唯一的例外是当您进行保证进展时,或者使用显式共享构造,比如您提到的那些。
调试多线程情况也非常困难。添加“调试代码”以调试程序通常会改变其行为。例如,向标准输出写入某些内容会进行I/O,这确保了进展。这可能导致其他线程能够看到值,而在正常情况下这是不可能的!
确保了解您提到的原子操作、易失性和互斥锁等构造的作用。这样,您就可以构建在多线程环境中表现完全可预测的程序。

关于您的测试

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

线程调度

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

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

因此,即使您的测试没有发现问题,那也只是个人经验的证明。

优化和重新排序

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

假设你将一个变量分配给一个线程,但在该线程中从未再次读取它(就像你在“thread”中所做的那样)。编译器可以将实际赋值推迟多长时间,因为就目前编译器所能看到的情况而言,没有任何依赖于该线程中“shared”的值的东西。你必须在线程中拥有保证进展,以确保实际赋值。在你的示例中,只有当“thread”终止时才能保证此进展:很可能是在线程函数结束时。再一次:你不知道线程何时调用你的函数。
使用类似atomic<>volatile的构造强制编译器生成确保可预测行为的代码。如果你知道如何使用它们,你可以编写在多线程环境下表现正确的程序。

1
然而,即使分配确实发生了,它也可能永远不会到达主内存。但这不会阻止其他线程看到它。我们可以在其中运行多个线程的系统具有一致的缓存。有关CPU架构详细信息,请参见何时在多线程中使用volatile? (基本上从不),以及程序员关于CPU高速缓存的神话。共享变量通常会保留在共享L3缓存中,实际上并没有写入/从慢速DRAM读取。 - Peter Cordes
@PeterCordes 或许“赋值”这个词不太准确,因为还有其他的优化方式。但我同意你的观点,并会删除这一部分。 - Emmef
是的,赋值是一个高级概念,可以通过存储指令来实现,适用于任何没有被优化到寄存器中的赋值,即使跨线程也可以变得可见。不过根据上下文,我认为您的意思是“汇编存储发生了”,所以选择这个词并没有问题。但是,最好的选择可能是删除该部分,而不是尝试重写并描述CPU的工作原理。 - Peter Cordes
我从这里提出的许多问题中感到,应该有一个好问题的好答案,来解释整个排序、发生前/后关系、内存栅栏和缓存一致性的故事。我看到很多答案都有片段,这就导致了很多重复但是部分回答。问一个合成问题来指出这一点是不是有些“淘气”?(合成意味着“提问者知道答案,但仍然提问以帮助他人”)。 - Emmef
1
鼓励发布自问自答的问题,如果问题太宽泛以至于不能期望别人回答,只要答案连贯一致也可以。甚至有一个用户界面选项可以在发布问题时同时发布答案,这样人们就不会暂时看到未回答的占位符问题了。我的几个问题就是这样发布的,比如64位代码中的int 0x80和asm / AVX-512中的int->hex ASCII。是的,尝试撰写一个总体解释是完全合理的好主意,我很乐意审查并重写一两段。 - Peter Cordes
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接