And*_*ndy 6 c++ x86 atomic race-condition stdatomic
我知道在 C++ 中赋值可能不是原子的。我试图触发竞争条件来显示这一点。
但是我下面的代码似乎没有触发任何这样的。如何更改它以使其最终触发竞争条件?
#include <iostream>
#include <thread>
volatile uint64_t sharedValue = 1;
const uint64_t value1 = 13;
const uint64_t value2 = 1414;
void write() {
bool even = true;
for (;;) {
uint64_t value;
if (even)
value = value1;
else
value = value2;
sharedValue = value;
even = !even;
}
}
void read() {
for (;;) {
uint64_t value = sharedValue;
if (value != value1 && value != value2) {
std::cout << "Race condition! Value: " << value << std::endl << std::flush;
}
}
}
int main()
{
std::thread t1(write);
std::thread t2(read);
t1.join();
}
Run Code Online (Sandbox Code Playgroud)
我正在使用 VS 2017 并在 Release x86 中编译。
这是作业的反汇编:
sharedValue = value;
00D54AF2 mov eax,dword ptr [ebp-18h]
00D54AF5 mov dword ptr [sharedValue (0D5F000h)],eax
00D54AFA mov ecx,dword ptr [ebp-14h]
00D54AFD mov dword ptr ds:[0D5F004h],ecx
Run Code Online (Sandbox Code Playgroud)
我想这意味着分配不是原子的?似乎其中 32 位被复制到通用 32 位寄存器 eax 中,其他 32 位被复制到另一个通用 32 位寄存器 ecx 中,然后再复制到sharedValue数据段寄存器中的哪个?
我也试过,uint32_t所有数据都一次性复制了。所以我猜在 x86 上不需要使用std::atomic32 位数据类型?
一些答案/评论建议在作家中睡觉。这没有用;尽可能频繁地更改缓存行是您想要的。(以及您通过volatile分配和读取获得的内容。)当缓存行的 MESI 共享请求在将存储的两半从存储缓冲区提交到 L1d 缓存之间到达写入器核心时,分配将被破坏。
如果你睡着了,你会等待很长时间而没有创建一个窗口来让它发生。在两半之间休眠会使它更容易被检测到,但是除非您使用单独的方法memcpy来编写 64 位整数的一半或其他东西,否则您不能这样做。
即使写入是原子的,在读取器中读取之间的撕裂也是可能的。这可能不太可能,但在实践中仍然经常发生。现代 x86 CPU 每个时钟周期可以执行两个负载(从 Sandybridge 开始是 Intel,从 K8 开始是 AMD)。我使用原子 64 位存储进行了测试,但在 Skylake 上拆分 32 位负载并且撕裂仍然足够频繁,足以在终端中喷出文本行。因此,CPU 无法以锁步方式运行所有内容,而相应的读取对总是在同一时钟周期内执行。所以有一个窗口让阅读器在一对加载之间使其缓存行无效。(但是,当缓存线到达时,所有挂起的缓存未命中加载,而缓存线由写入器核心拥有时,可能会立即完成。
正如您所发现的,您的测试值都具有相同的上半部分0,因此无法观察到任何撕裂;只有 32 位对齐的低半部分一直在变化,并且是原子性的,因为您的编译器保证 uint64_t 至少有 4 字节对齐,而 x86 保证 4 字节对齐的加载/存储是原子的。
0并且-1ULL是显而易见的选择。我在这个 GCC C11 _Atomic 错误的测试用例中使用了相同的东西,用于 64 位结构。
对于你的情况,我会这样做。 read()并且write()是 POSIX 系统调用名称,因此我选择了其他名称。
#include <cstdint>
volatile uint64_t sharedValue = 0; // initializer = one of the 2 values!
void writer() {
for (;;) {
sharedValue = 0;
sharedValue = -1ULL; // unrolling is vastly simpler than an if
}
}
void reader() {
for (;;) {
uint64_t val = sharedValue;
uint32_t low = val, high = val>>32;
if (low != high) {
std::cout << "Tearing! Value: " << std::hex << val << '\n';
}
}
}
Run Code Online (Sandbox Code Playgroud)
MSVC 19.24 -O2 将编写器编译movlpd为对 = 0使用64 位存储,但-1对= -1. (和两个独立的 32 位读取器加载)。mov dword ptr [mem], imm32正如您所期望的那样,GCC在 writer 中总共使用了四个存储区。(Godbolt 编译器资源管理器)
术语:它总是一个竞争条件(即使有原子性,你也不知道你会得到两个值中的哪一个)。有了std::atomic<>你,你只会有那种花园式的比赛条件,没有未定义的行为。
问题是你是否真正看到从撕裂数据竞争未定义行为上的volatile对象,在一个特定的C ++实现/设置的编译选项,针对特定平台。 数据竞争 UB 是一个技术术语,其含义比“竞争条件”更具体。我更改了错误消息以报告我们检查的一种症状。请注意,非volatile对象上的数据竞争 UB可能会产生更奇怪的效果,例如托管加载或存储循环,甚至发明额外的读取导致代码认为一次读取同时是真和假。( https://lwn.net/Articles/793253/ )
我删除了 2 个多余的cout冲洗:一个来自std::endl和一个来自std::flush. cout 默认是行缓冲的,如果写入文件则是全缓冲,这很好。而且'\n'是一样的便携std::endl至于DOS行结束关注; 文本与二进制流模式处理。endl 仍然只是\n.
我通过检查 high_half == low_half 来简化您的撕裂检查。然后编译器只需要发出一个 cmp/jcc 而不是两个扩展精度的比较来查看值是 0 还是 -1 。我们知道high = low = 0xff00ff00在 x86(或任何其他具有任何健全编译器的主流 ISA)上发生假阴性是不可能的。
所以我猜在 x86 上不需要对 32 位数据类型使用 std::atomic 吗?
不正确。
手卷volatile int原子无法为您提供原子 RMW 操作(没有内联 asm 或特殊功能,如 WindowsInterlockedIncrement或 GNU C 内置__atomic_fetch_add),并且无法为您提供任何订购保证。其他代码。(释放/获取语义)
什么时候在多线程中使用 volatile?- 几乎从来没有。
滚动你自己的原子仍然volatile 是可能的,并且事实上得到了许多主流编译器的支持(例如,Linux 内核仍然这样做,以及内联汇编)。现实世界的编译器确实有效地定义了volatile对象上的数据竞争行为。但是,当有一种便携且安全的方式时,这通常是一个坏主意。只需使用std::atomic<T>withstd::memory_order_relaxed即可获得与您所能获得的一样有效的 asm volatile(对于有效的情况volatile),但可以保证 ISO C++ 标准的安全性和正确性。
atomic<T>还允许您使用 C++17std::atomic<T>::is_always_lock_free或更旧的成员函数询问实现给定的类型是否可以廉价地原子化。(在实践中,C++11 实现决定不让任何给定原子的一些但不是所有实例基于对齐或其他东西被锁定;相反,如果有的话,他们只是给原子所需的对齐方式。所以 C++17 做了一个常量 per-type 常量而不是 per-object 成员函数检查锁自由的方法)。
std::atomic还可以为比普通 register 更宽的类型提供廉价的无锁原子性。例如在 ARM 上,使用 ARMv6 strd/ldrd来存储/加载一对寄存器。
在 32 位 x86 上,一个好的编译器可以std::atomic<uint64_t>通过使用 SSE2movq来实现原子的 64 位加载和存储,而不会退回到 non-lock_free 机制(锁表)。 实际上,GCC 和 clang9 确实movq用于atomic<uint64_t>load/store。lock cmpxchg8b不幸的是,clang8.0 和更早的版本使用了。MSVC 的使用lock cmpxchg8b效率更低。更改 Godbolt 链接中 sharedVariable 的定义以查看它。(或者,如果您使用每个默认的 seq_cst 并memory_order_relaxed存储在循环中,MSVC 出于某种原因会?store@?$_Atomic_storage@_K$07@std@@QAEX_KW4memory_order@2@@Z为其中一个调用辅助函数。但是当两个存储的顺序相同时,它会使用比 clang8.0 更笨拙的循环内联锁 cmpxchg8b ) 请注意,这种低效的 MSVC 代码生成适用于以下情况volatile不是原子的;在它在哪里,情况atomic<T>与mo_relaxed编译好听,太。
您通常无法从volatile. 尽管 GCC 确实将 movq 用于 if() bool write 函数(请参阅较早的 Godbolt 编译器资源管理器链接),因为它无法看穿交替或其他东西。它还取决于您使用的值。对于 0 和 -1,它使用单独的 32 位存储,但是对于 0,0x0f0f0f0f0f0f0f0fULL您将获得 movq 以获得可用模式。(我用它来验证您仍然可以从读取端撕裂,而不是手写一些 asm。)我的简单展开版本编译为仅使用mov dword [mem], imm32带有 GCC 的普通存储。这是一个很好的例子,说明volatile在这种细节级别上如何真正编译为零保证。
atomic<uint64_t>还将保证原子对象的 8 字节对齐,即使普通对象uint64_t可能只有 4 字节对齐。
在 ISO C++ 中,volatile对象上的数据竞争仍然是未定义行为。 (除了volatile sig_atomic_t与信号处理程序进行比赛。)
“数据竞争”是任何时候发生两个不同步的访问并且它们不是都读取。ISO C++ 允许在具有硬件竞争检测或其他功能的机器上运行;实际上,没有主流系统会这样做,因此如果 volatile 对象不是“自然原子的”,结果只会是撕裂。
ISO C++ 理论上也允许在没有一致共享内存并需要在原子存储后手动刷新的机器上运行,但这在实践中并不真实。没有真实世界的实现是这样的,AFAIK。具有非相干共享内存的内核的系统(如一些带有 DSP 内核 + 微控制器内核的 ARM SoC)不会跨这些内核启动 std::thread。
另请参阅为什么在 x86 上自然对齐的变量原子上的整数赋值是原子的?
即使您在实践中没有观察到撕裂,它仍然是 UB,尽管正如我所说,真正的编译器确实定义了 volatile 的行为。
我想知道存储缓冲区中的存储合并是否可以从两个单独的 32 位存储中创建对 L1d 缓存的原子 64 位提交。(目前没有有用的结果,留在这里以防任何人感兴趣或想以此为基础。)
我为阅读器使用了 GNU C __atomic 内置函数,所以如果存储最终也是原子的,我们就不会看到撕裂。
void reader() {
for (;;) {
uint64_t val = __atomic_load_n(&sharedValue, __ATOMIC_ACQUIRE);
uint32_t low = val, high = val>>32;
if (low != high) {
std::cout << "Tearing! Value: " << std::hex << val << '\n';
}
}
}
Run Code Online (Sandbox Code Playgroud)
这是让微架构对商店进行分组的一种尝试。
void writer() {
volatile int separator; // in a different cache line, has to commit separately
for (;;) {
sharedValue = 0;
_mm_mfence();
separator = 1234;
_mm_mfence();
sharedValue = -1ULL; // unrolling is vastly simpler than an if
_mm_mfence();
separator = 1234;
_mm_mfence();
}
}
Run Code Online (Sandbox Code Playgroud)
我仍然看到撕裂。(mfence在 Skylake 上使用更新的微码就像lfence, 并阻止乱序执行以及排空存储缓冲区。所以后来的商店甚至不应该在后来的商店离开之前进入存储缓冲区。这实际上可能是一个问题,因为我们需要时间进行合并,而不仅仅是在商店 uops 退休时“毕业”后立即提交 32 位商店)。
可能我应该尝试测量撕裂率,看看它是否不那么频繁地使用任何东西,因为任何撕裂都足以在 4GHz 机器上向终端窗口发送带有文本的垃圾邮件。
| 归档时间: |
|
| 查看次数: |
275 次 |
| 最近记录: |