我如何证明 volatile 赋值不是原子的?
How can I show that volatile assignment is not atomic?
我知道 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();
}
我正在使用 VS 2017 并在版本 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
我猜这意味着赋值不是原子的?
似乎有 32 位被复制到通用 32 位寄存器 eax 中,而其他 32 位在复制到数据段寄存器中的 sharedValue
之前被复制到另一个通用 32 位寄存器 ecx 中?
我也试过uint32_t
,所有数据都被一次性复制了。
所以我想在 x86 上不需要对 32 位数据类型使用 std::atomic
?
抓取反汇编,然后检查您的体系结构的文档;在某些机器上,您会发现即使是标准 "non-atomic" 操作(就 C++ 而言)在触及硬件(就汇编而言)时实际上也是原子的。
话虽如此,您的编译器会知道什么是安全的,什么是不安全的,因此最好使用 std::atomic
模板来使您的代码在不同体系结构之间更具可移植性。如果您使用的平台不需要任何特殊的东西,它通常会被优化为原始类型(将内存排序放在一边)。
我不记得手头的 x86 操作细节,但我猜如果 64 位整数写成 32 位 "chunks"(或更少),你会有数据竞争;那种情况可能会被撕毁。
还有一些称为线程清理器的工具可以实时捕获它。我不相信它们在 Windows 上受 MSVC 支持,但如果你能让 GCC 或 clang 工作,那么你可能会在那里有些运气。如果您的代码是可移植的(看起来是这样),那么您可以使用这些工具 运行 在 Linux 系统(或虚拟机)上使用它。
首先,在没有任何同步的情况下读取和写入 sharedValue
变量时,您的代码存在数据竞争条件,这在 C++ 中是未定义的行为。这可以通过使 sharedValue
成为原子变量来解决:
std::atomic<uint64_t> sharedValue{1};
您可以在写入 sharedValue
之前通过人为延迟触发逻辑竞争条件("Race condition! Value: ..." 消息将打印在 reader 线程中)。您可以为此使用 std::this_thread::sleep_for
:
void write() {
bool even = true;
for (;;) {
uint64_t value;
if (even)
value = value1;
else
value = value2;
using namespace std::chrono_literals;
std::this_thread::sleep_for(1ms);
sharedValue = value;
even = !even;
}
}
我将代码更改为:
volatile uint64_t sharedValue = 0;
const uint64_t value1 = 0;
const uint64_t value2 = ULLONG_MAX;
现在代码在不到一秒的时间内触发了竞争条件。
问题是 13 和 1414 的 32 MSB = 0。
13=0xd
1414=0x586
0=0x0
ULLONG_MAX=0xffffffffffffffff
一些answers/comments建议在作者那里睡觉。这没有用;尽可能频繁地修改缓存行是您想要的。 (以及通过 volatile
分配和读取得到的结果。)当缓存行的 MESI 共享请求到达写入器核心时,在将存储缓冲区的两半存储提交到 L1d 缓存之间,分配将被撕裂。
如果你睡着了,你会等待很长时间,而不会为这种情况的发生创造 window。在两半 之间休眠 会使它更容易检测,但除非您使用单独的 memcpy
来写 64 位整数的一半或其他东西,否则您不能这样做。
即使写入是原子的,reader 中的读取之间的撕裂也是可能的。这可能不太可能,但在实践中仍然经常发生。现代 x86 CPUs 可以在每个时钟周期执行两次加载(Intel 自 Sandybridge 以来,AMD 自 K8 以来)。我测试了原子 64 位存储,但在 Skylake 上拆分 32 位负载并且撕裂仍然频繁到足以在终端中喷出文本行。所以 CPU 没能 运行 一切都在锁步中,相应的读取对总是在同一个时钟周期中执行。所以有一个 window 用于 reader 使其缓存行在一对加载之间失效。 (但是,当缓存行由写入器核心拥有时,所有未决的缓存未命中加载可能会在缓存行到达时立即全部完成。并且可用的加载缓冲区总数在现有微体系结构中是偶数。)
如您所见,您的测试值都具有相同的 0
的上半部分,因此无法观察到任何撕裂;只有 32 位对齐的低半部分一直在变化,并且是原子变化的,因为你的编译器保证 uint64_t 至少 4 字节对齐,而 x86 保证 4 字节对齐的 loads/stores 是原子的。
0
和 -1ULL
是显而易见的选择。我在 64 位结构的 this GCC C11 _Atomic bug 的测试用例中使用了相同的东西。
对于你的情况,我会这样做。 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';
}
}
}
MSVC 19.24 -O2 将编写器编译为对 = 0 使用 movlpd
64 位存储,但对 = -1
使用两个单独的 -1
32 位存储。 (并且 reader 到两个单独的 32 位加载)。如您所料,GCC 在编写器中总共使用了四个 mov dword ptr [mem], imm32
存储。 (Godbolt compiler explorer)
术语:它总是一个竞争条件(即使有原子性你也不知道你要去的两个值中的哪一个要得到)。使用 std::atomic<>
,您将只有普通的竞争条件,没有未定义的行为。
问题是您是否真的看到 数据竞争未定义行为 对 volatile
对象的撕裂,在特定的 C++ 实现/编译选项集上,对于一个特定的平台。 Data race UB是一个技术术语,比"race condition"更具体。我更改了错误消息以报告我们检查的一种症状。请注意,非 volatile
对象上的数据争用 UB 可能会产生更奇怪的效果,例如在循环外托管加载或存储,甚至发明额外的读取导致代码认为一次读取在同一时间。 (https://lwn.net/Articles/793253/)
我删除了 2 个多余的 cout
刷新 :一个来自 std::endl
,一个来自 std::flush
。 cout 默认情况下是行缓冲的,或者如果写入文件则完全缓冲,这很好。就 DOS 行结尾而言,'\n'
与 table 和 std::endl
一样;文本与二进制流模式可以解决这个问题。 endl 仍然只是 \n
.
我通过检查 high_half == low_half 简化了撕裂检查。然后编译器只需要发出一个 cmp/jcc 而不是两个扩展精度比较来查看值是 0 还是 -1。我们知道像 high = low = 0xff00ff00
这样的假阴性在 x86 上(或任何其他具有任何合理编译器的主流 ISA)上发生是不可能的。
So I guess that on x86 there is no need to use std::atomic for 32 bit data types?
不正确.
带有 volatile int
的手滚原子不能给你原子 RMW 操作(没有内联汇编或特殊函数,如 Windows InterlockedIncrement
或 GNU C 内置 __atomic_fetch_add
), 并且不能给你任何订购保证。其他代码。 (释放/获取语义)
When to use volatile with multi threading? - 几乎没有。
使用 volatile
滚动你自己的原子仍然 可能 许多主流编译器(例如Linux 内核仍然这样做,连同内联 asm)。现实世界的编译器确实有效地定义了 volatile
对象上的数据竞争行为。但是当有 portable 和保证安全的方式时,这通常不是一个好主意。只需将 std::atomic<T>
与 std::memory_order_relaxed
一起使用即可获得与使用 volatile
获得的效果一样高效的 asm(对于 volatile
有效的情况),但可以保证安全和ISO C++ 标准的正确性。
atomic<T>
还可以让您通过 C++17 std::atomic<T>::is_always_lock_free
或较旧的成员函数询问实现是否可以廉价地实现给定类型的原子性。 (在实践中,C++11 实现决定不让任何给定原子的一些但不是所有实例基于对齐或其他东西被锁定;相反,如果有的话,它们只是给原子提供所需的对齐。所以 C++17 做了一个constant per-type constant 而不是 per-object member function 方法来检查锁自由)。
std::atomic
也可以为比普通寄存器 更宽的类型提供廉价的无锁原子性。例如在 ARM 上,使用 ARMv6 strd
/ ldrd
到 store/load 一对寄存器。
在 32 位 x86 上,一个好的编译器可以通过使用 SSE2 movq
执行原子 64 位加载和存储来实现 std::atomic<uint64_t>
,而无需回退到 non-lock_free 机制(一把 table 把锁)。 实际上,GCC 和 clang9 确实将 movq
用于 atomic<uint64_t>
load/store。不幸的是,clang8.0 及更早版本使用 lock cmpxchg8b
。 MSVC 使用 lock cmpxchg8b
的方式更加低效。更改Godbolt link中sharedVariable的定义即可看到。 (或者,如果您在循环中使用默认 seq_cst 和 memory_order_relaxed
存储中的每一个,MSVC 出于某种原因会为其中一个调用 ?store@?$_Atomic_storage@_K@std@@QAEX_KW4memory_order@2@@Z
辅助函数。但是当两个存储相同时排序,它使用比 clang8.0 更笨重的循环内联锁 cmpxchg8b)请注意,这种低效的 MSVC 代码生成是针对 volatile
不是原子的情况;在这种情况下,atomic<T>
和 mo_relaxed
也可以很好地编译。
您通常无法从 volatile
获得宽原子代码生成。尽管 GCC 实际上确实将 movq 用于您的 if() bool write 函数(请参阅早期的 Godbolt 编译器资源管理器 link),因为它无法看穿 alternating 或其他东西。它还取决于您使用的值。对于 0 和 -1,它使用单独的 32 位存储,但是对于 0 和 0x0f0f0f0f0f0f0f0fULL
,您将获得可用模式的 movq。 (我用它来验证您是否仍然可以只从读取端撕裂,而不是手写一些 asm。)我的简单展开版本编译为仅使用带有 GCC 的普通 mov dword [mem], imm32
存储。这是一个很好的例子,说明 volatile
如何在这种详细程度下真正编译。
atomic<uint64_t>
也将保证原子对象的 8 字节对齐,即使普通 uint64_t
可能只是 4 字节对齐。
在 ISO C++ 中,volatile
对象上的数据竞争仍然是未定义行为。(除了 volatile sig_atomic_t
与信号处理程序竞争。)
A "data race" 是任何时候发生两个不同步的访问并且它们不是两个读取。 ISO C++ 允许 运行ning 在具有硬件竞争检测或其他功能的机器上;实际上,没有主流系统这样做,所以如果 volatile 对象不是 "naturally atomic".
,结果只会撕裂
ISO C++ 理论上也允许 运行ning 在没有连贯共享内存并且需要在原子存储后手动刷新的机器上,但这在实践中不太合理。 AFAIK,没有任何现实世界的实现是这样的。具有非一致性共享内存的内核的系统(例如某些带有 DSP 内核 + 微控制器内核的 ARM SoC)不会跨这些内核启动 std::thread。
另见
即使您在实践中没有观察到撕裂,它仍然是 UB, 尽管正如我所说,真正的编译器确实定义了 volatile 的行为。
尝试检测存储缓冲区合并的 Skylake 实验
我想知道存储缓冲区中的存储合并是否可以从两个单独的 32 位存储中创建到 L1d 缓存的原子 64 位提交。 (到目前为止没有有用的结果,将其留在这里以防有人感兴趣或想在此基础上进行构建。)
我为 reader 使用了 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';
}
}
}
这是对微结构进行商店分组的一次尝试。
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();
}
}
我仍然看到这个撕裂。 (mfence
在更新了微码的 Skylake 上就像 lfence
,并且阻止乱序执行以及耗尽存储缓冲区。所以后面的存储甚至不应该在后面的之前进入存储缓冲区离开。这实际上可能是个问题,因为我们需要时间进行合并,而不仅仅是在存储 uops 退休时 "graduates" 提交一个 32 位存储。
也许我应该尝试测量撕裂的 率,看看撕裂的频率是否较低,因为任何撕裂都足以向终端发送垃圾邮件 window在 4GHz 机器上显示文本。
我知道 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();
}
我正在使用 VS 2017 并在版本 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
我猜这意味着赋值不是原子的?
似乎有 32 位被复制到通用 32 位寄存器 eax 中,而其他 32 位在复制到数据段寄存器中的 sharedValue
之前被复制到另一个通用 32 位寄存器 ecx 中?
我也试过uint32_t
,所有数据都被一次性复制了。
所以我想在 x86 上不需要对 32 位数据类型使用 std::atomic
?
抓取反汇编,然后检查您的体系结构的文档;在某些机器上,您会发现即使是标准 "non-atomic" 操作(就 C++ 而言)在触及硬件(就汇编而言)时实际上也是原子的。
话虽如此,您的编译器会知道什么是安全的,什么是不安全的,因此最好使用 std::atomic
模板来使您的代码在不同体系结构之间更具可移植性。如果您使用的平台不需要任何特殊的东西,它通常会被优化为原始类型(将内存排序放在一边)。
我不记得手头的 x86 操作细节,但我猜如果 64 位整数写成 32 位 "chunks"(或更少),你会有数据竞争;那种情况可能会被撕毁。
还有一些称为线程清理器的工具可以实时捕获它。我不相信它们在 Windows 上受 MSVC 支持,但如果你能让 GCC 或 clang 工作,那么你可能会在那里有些运气。如果您的代码是可移植的(看起来是这样),那么您可以使用这些工具 运行 在 Linux 系统(或虚拟机)上使用它。
首先,在没有任何同步的情况下读取和写入 sharedValue
变量时,您的代码存在数据竞争条件,这在 C++ 中是未定义的行为。这可以通过使 sharedValue
成为原子变量来解决:
std::atomic<uint64_t> sharedValue{1};
您可以在写入 sharedValue
之前通过人为延迟触发逻辑竞争条件("Race condition! Value: ..." 消息将打印在 reader 线程中)。您可以为此使用 std::this_thread::sleep_for
:
void write() {
bool even = true;
for (;;) {
uint64_t value;
if (even)
value = value1;
else
value = value2;
using namespace std::chrono_literals;
std::this_thread::sleep_for(1ms);
sharedValue = value;
even = !even;
}
}
我将代码更改为:
volatile uint64_t sharedValue = 0;
const uint64_t value1 = 0;
const uint64_t value2 = ULLONG_MAX;
现在代码在不到一秒的时间内触发了竞争条件。 问题是 13 和 1414 的 32 MSB = 0。
13=0xd
1414=0x586
0=0x0
ULLONG_MAX=0xffffffffffffffff
一些answers/comments建议在作者那里睡觉。这没有用;尽可能频繁地修改缓存行是您想要的。 (以及通过 volatile
分配和读取得到的结果。)当缓存行的 MESI 共享请求到达写入器核心时,在将存储缓冲区的两半存储提交到 L1d 缓存之间,分配将被撕裂。
如果你睡着了,你会等待很长时间,而不会为这种情况的发生创造 window。在两半 之间休眠 会使它更容易检测,但除非您使用单独的 memcpy
来写 64 位整数的一半或其他东西,否则您不能这样做。
即使写入是原子的,reader 中的读取之间的撕裂也是可能的。这可能不太可能,但在实践中仍然经常发生。现代 x86 CPUs 可以在每个时钟周期执行两次加载(Intel 自 Sandybridge 以来,AMD 自 K8 以来)。我测试了原子 64 位存储,但在 Skylake 上拆分 32 位负载并且撕裂仍然频繁到足以在终端中喷出文本行。所以 CPU 没能 运行 一切都在锁步中,相应的读取对总是在同一个时钟周期中执行。所以有一个 window 用于 reader 使其缓存行在一对加载之间失效。 (但是,当缓存行由写入器核心拥有时,所有未决的缓存未命中加载可能会在缓存行到达时立即全部完成。并且可用的加载缓冲区总数在现有微体系结构中是偶数。)
如您所见,您的测试值都具有相同的 0
的上半部分,因此无法观察到任何撕裂;只有 32 位对齐的低半部分一直在变化,并且是原子变化的,因为你的编译器保证 uint64_t 至少 4 字节对齐,而 x86 保证 4 字节对齐的 loads/stores 是原子的。
0
和 -1ULL
是显而易见的选择。我在 64 位结构的 this GCC C11 _Atomic bug 的测试用例中使用了相同的东西。
对于你的情况,我会这样做。 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';
}
}
}
MSVC 19.24 -O2 将编写器编译为对 = 0 使用 movlpd
64 位存储,但对 = -1
使用两个单独的 -1
32 位存储。 (并且 reader 到两个单独的 32 位加载)。如您所料,GCC 在编写器中总共使用了四个 mov dword ptr [mem], imm32
存储。 (Godbolt compiler explorer)
术语:它总是一个竞争条件(即使有原子性你也不知道你要去的两个值中的哪一个要得到)。使用 std::atomic<>
,您将只有普通的竞争条件,没有未定义的行为。
问题是您是否真的看到 数据竞争未定义行为 对 volatile
对象的撕裂,在特定的 C++ 实现/编译选项集上,对于一个特定的平台。 Data race UB是一个技术术语,比"race condition"更具体。我更改了错误消息以报告我们检查的一种症状。请注意,非 volatile
对象上的数据争用 UB 可能会产生更奇怪的效果,例如在循环外托管加载或存储,甚至发明额外的读取导致代码认为一次读取在同一时间。 (https://lwn.net/Articles/793253/)
我删除了 2 个多余的 cout
刷新 :一个来自 std::endl
,一个来自 std::flush
。 cout 默认情况下是行缓冲的,或者如果写入文件则完全缓冲,这很好。就 DOS 行结尾而言,'\n'
与 table 和 std::endl
一样;文本与二进制流模式可以解决这个问题。 endl 仍然只是 \n
.
我通过检查 high_half == low_half 简化了撕裂检查。然后编译器只需要发出一个 cmp/jcc 而不是两个扩展精度比较来查看值是 0 还是 -1。我们知道像 high = low = 0xff00ff00
这样的假阴性在 x86 上(或任何其他具有任何合理编译器的主流 ISA)上发生是不可能的。
So I guess that on x86 there is no need to use std::atomic for 32 bit data types?
不正确.
带有 volatile int
的手滚原子不能给你原子 RMW 操作(没有内联汇编或特殊函数,如 Windows InterlockedIncrement
或 GNU C 内置 __atomic_fetch_add
), 并且不能给你任何订购保证。其他代码。 (释放/获取语义)
When to use volatile with multi threading? - 几乎没有。
使用 volatile
滚动你自己的原子仍然 可能 许多主流编译器(例如Linux 内核仍然这样做,连同内联 asm)。现实世界的编译器确实有效地定义了 volatile
对象上的数据竞争行为。但是当有 portable 和保证安全的方式时,这通常不是一个好主意。只需将 std::atomic<T>
与 std::memory_order_relaxed
一起使用即可获得与使用 volatile
获得的效果一样高效的 asm(对于 volatile
有效的情况),但可以保证安全和ISO C++ 标准的正确性。
atomic<T>
还可以让您通过 C++17 std::atomic<T>::is_always_lock_free
或较旧的成员函数询问实现是否可以廉价地实现给定类型的原子性。 (在实践中,C++11 实现决定不让任何给定原子的一些但不是所有实例基于对齐或其他东西被锁定;相反,如果有的话,它们只是给原子提供所需的对齐。所以 C++17 做了一个constant per-type constant 而不是 per-object member function 方法来检查锁自由)。
std::atomic
也可以为比普通寄存器 更宽的类型提供廉价的无锁原子性。例如在 ARM 上,使用 ARMv6 strd
/ ldrd
到 store/load 一对寄存器。
在 32 位 x86 上,一个好的编译器可以通过使用 SSE2 movq
执行原子 64 位加载和存储来实现 std::atomic<uint64_t>
,而无需回退到 non-lock_free 机制(一把 table 把锁)。 实际上,GCC 和 clang9 确实将 movq
用于 atomic<uint64_t>
load/store。不幸的是,clang8.0 及更早版本使用 lock cmpxchg8b
。 MSVC 使用 lock cmpxchg8b
的方式更加低效。更改Godbolt link中sharedVariable的定义即可看到。 (或者,如果您在循环中使用默认 seq_cst 和 memory_order_relaxed
存储中的每一个,MSVC 出于某种原因会为其中一个调用 ?store@?$_Atomic_storage@_K@std@@QAEX_KW4memory_order@2@@Z
辅助函数。但是当两个存储相同时排序,它使用比 clang8.0 更笨重的循环内联锁 cmpxchg8b)请注意,这种低效的 MSVC 代码生成是针对 volatile
不是原子的情况;在这种情况下,atomic<T>
和 mo_relaxed
也可以很好地编译。
您通常无法从 volatile
获得宽原子代码生成。尽管 GCC 实际上确实将 movq 用于您的 if() bool write 函数(请参阅早期的 Godbolt 编译器资源管理器 link),因为它无法看穿 alternating 或其他东西。它还取决于您使用的值。对于 0 和 -1,它使用单独的 32 位存储,但是对于 0 和 0x0f0f0f0f0f0f0f0fULL
,您将获得可用模式的 movq。 (我用它来验证您是否仍然可以只从读取端撕裂,而不是手写一些 asm。)我的简单展开版本编译为仅使用带有 GCC 的普通 mov dword [mem], imm32
存储。这是一个很好的例子,说明 volatile
如何在这种详细程度下真正编译。
atomic<uint64_t>
也将保证原子对象的 8 字节对齐,即使普通 uint64_t
可能只是 4 字节对齐。
在 ISO C++ 中,volatile
对象上的数据竞争仍然是未定义行为。(除了 volatile sig_atomic_t
与信号处理程序竞争。)
A "data race" 是任何时候发生两个不同步的访问并且它们不是两个读取。 ISO C++ 允许 运行ning 在具有硬件竞争检测或其他功能的机器上;实际上,没有主流系统这样做,所以如果 volatile 对象不是 "naturally atomic".
,结果只会撕裂ISO C++ 理论上也允许 运行ning 在没有连贯共享内存并且需要在原子存储后手动刷新的机器上,但这在实践中不太合理。 AFAIK,没有任何现实世界的实现是这样的。具有非一致性共享内存的内核的系统(例如某些带有 DSP 内核 + 微控制器内核的 ARM SoC)不会跨这些内核启动 std::thread。
另见
即使您在实践中没有观察到撕裂,它仍然是 UB, 尽管正如我所说,真正的编译器确实定义了 volatile 的行为。
尝试检测存储缓冲区合并的 Skylake 实验
我想知道存储缓冲区中的存储合并是否可以从两个单独的 32 位存储中创建到 L1d 缓存的原子 64 位提交。 (到目前为止没有有用的结果,将其留在这里以防有人感兴趣或想在此基础上进行构建。)
我为 reader 使用了 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';
}
}
}
这是对微结构进行商店分组的一次尝试。
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();
}
}
我仍然看到这个撕裂。 (mfence
在更新了微码的 Skylake 上就像 lfence
,并且阻止乱序执行以及耗尽存储缓冲区。所以后面的存储甚至不应该在后面的之前进入存储缓冲区离开。这实际上可能是个问题,因为我们需要时间进行合并,而不仅仅是在存储 uops 退休时 "graduates" 提交一个 32 位存储。
也许我应该尝试测量撕裂的 率,看看撕裂的频率是否较低,因为任何撕裂都足以向终端发送垃圾邮件 window在 4GHz 机器上显示文本。