这个测试似乎表明样本是正确的,但实际上并不是。类似的代码可能很容易地进入生产环境,并且甚至可能运行多年而没有问题。
我们可以使用-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_relaxed
的
std::atomic<T>
(除非场景需要特定的内存顺序)而不是
volatile T
(当然,
T
)。此外,尽可能避免自己编写无锁代码以减少代码复杂性并最大化正确性的概率。
volatile
一样被处理。这部分基本上是Multithreading program stuck in optimized mode but runs normally in -O0和When to use volatile with multi threading?的重复。 - Peter Cordesatomic
是正确的,其他方式即使使用volatile
也会存在至少竞争访问的 UB(未定义行为)。 - Jarod42