一个线程写入和另一个非原子读取保证写入
Is write guaranteed with one thread writing and another reading non-atomic
说我有
bool unsafeBool = false;
int main()
{
std::thread reader = std::thread([](){
std::this_thread::sleep_for(1ns);
if(unsafeBool)
std::cout << "unsafe bool is true" << std::endl;
});
std::thread writer = std::thread([](){
unsafeBool = true;
});
reader.join();
writer.join();
}
是否保证写完后unsafeBool
变成true
。我知道 reader 输出的是未定义的行为,但据我所知,写入应该没问题。
在writer.join()
之后保证了unsafeBool == true
。但是在 reader 线程中,对它的访问是一场数据竞争。
UB 是并且仍然是 UB,可以推理为什么事情会以它们发生的方式发生,但是,你不能依赖它。
您有竞争条件,通过以下任一方法解决它:添加锁或将类型更改为原子。
由于您的代码中有 UB,因此允许您的编译器假设这不会发生。如果它可以检测到这一点,它可以将您的完整功能更改为 noop,因为它永远不会在有效程序中调用。
如果不这样做,行为将取决于您的处理器和与其链接的缓存。在那里,如果连接之后的代码使用与读取布尔值的线程相同的核心(连接之前),您甚至可能仍然有 false 而无需使缓存无效。
实际上,使用 Intel X86 处理器时,您不会看到竞争条件带来的很多副作用,因为它已使写入缓存无效。
一些实现保证任何试图读取 word-size-or-smaller 对象的值的尝试在它改变的时候不合格 volatile
将产生一个旧值或新值,任意选择.在这种保证有用的情况下,编译器始终坚持它的成本通常会低于解决它缺失的成本(除其他外,因为程序员可以解决它缺失的任何方法都会限制编译器的在旧值或新值之间自由选择)。
然而,在其他一些实现中,即使是看起来应该涉及对一个值的单次读取的操作也可能会产生结合了多次读取结果的代码。当使用 command-line 个参数 -xc -O2 -mcpu=cortex-m0
调用 ARM gcc 9.2.1 并给出:
#include <stdint.h>
#include <string.h>
#if 1
uint16_t test(uint16_t *p)
{
uint16_t q = *p;
return q - (q >> 15);
}
它生成的代码从 *p
读取,然后从 *(int16_t*)p
读取,将后者右移 15,并将其添加到前者。如果 *p
的值在两次读取之间发生变化,这可能会导致函数 return 0xFFFF,这个值应该是不可能的。
不幸的是,许多设计编译器的人总是避免以这种方式“拆分”读取,他们认为这种行为是足够自然和明显的,没有特别的理由明确记录他们从不做任何其他事情的事实.同时,其他一些编译器作者认为,因为标准允许编译器拆分读取,即使没有理由(在上面的代码中拆分读取会使它比只读取一次值时更大和更慢)任何代码依赖编译器避免这种“优化”是“坏的”。
说我有
bool unsafeBool = false;
int main()
{
std::thread reader = std::thread([](){
std::this_thread::sleep_for(1ns);
if(unsafeBool)
std::cout << "unsafe bool is true" << std::endl;
});
std::thread writer = std::thread([](){
unsafeBool = true;
});
reader.join();
writer.join();
}
是否保证写完后unsafeBool
变成true
。我知道 reader 输出的是未定义的行为,但据我所知,写入应该没问题。
在writer.join()
之后保证了unsafeBool == true
。但是在 reader 线程中,对它的访问是一场数据竞争。
UB 是并且仍然是 UB,可以推理为什么事情会以它们发生的方式发生,但是,你不能依赖它。
您有竞争条件,通过以下任一方法解决它:添加锁或将类型更改为原子。
由于您的代码中有 UB,因此允许您的编译器假设这不会发生。如果它可以检测到这一点,它可以将您的完整功能更改为 noop,因为它永远不会在有效程序中调用。
如果不这样做,行为将取决于您的处理器和与其链接的缓存。在那里,如果连接之后的代码使用与读取布尔值的线程相同的核心(连接之前),您甚至可能仍然有 false 而无需使缓存无效。
实际上,使用 Intel X86 处理器时,您不会看到竞争条件带来的很多副作用,因为它已使写入缓存无效。
一些实现保证任何试图读取 word-size-or-smaller 对象的值的尝试在它改变的时候不合格 volatile
将产生一个旧值或新值,任意选择.在这种保证有用的情况下,编译器始终坚持它的成本通常会低于解决它缺失的成本(除其他外,因为程序员可以解决它缺失的任何方法都会限制编译器的在旧值或新值之间自由选择)。
然而,在其他一些实现中,即使是看起来应该涉及对一个值的单次读取的操作也可能会产生结合了多次读取结果的代码。当使用 command-line 个参数 -xc -O2 -mcpu=cortex-m0
调用 ARM gcc 9.2.1 并给出:
#include <stdint.h>
#include <string.h>
#if 1
uint16_t test(uint16_t *p)
{
uint16_t q = *p;
return q - (q >> 15);
}
它生成的代码从 *p
读取,然后从 *(int16_t*)p
读取,将后者右移 15,并将其添加到前者。如果 *p
的值在两次读取之间发生变化,这可能会导致函数 return 0xFFFF,这个值应该是不可能的。
不幸的是,许多设计编译器的人总是避免以这种方式“拆分”读取,他们认为这种行为是足够自然和明显的,没有特别的理由明确记录他们从不做任何其他事情的事实.同时,其他一些编译器作者认为,因为标准允许编译器拆分读取,即使没有理由(在上面的代码中拆分读取会使它比只读取一次值时更大和更慢)任何代码依赖编译器避免这种“优化”是“坏的”。