假设一个原子指针永远不会为 nullptr ,那么在没有同步的情况下读取它是否安全?就像下面的代码一样,假设有两个线程writer
同时运行reader
。
std::atomic<int>* g_atomic = new std::atomic<int>{};
void writer()
{
for (int i = 0; i < 101; i++)
{
auto* new_atomic = new std::atomic<int>{i};
std::atomic_thread_fence(std::memory_order_seq_cst); // memory barrier.
g_atomic = new_atomic; // ignore the memory leak
}
}
void reader()
{
auto value = g_atomic->load();
while (value < 100)
{
assert(value >= 0 && value <= 100);
value = g_atomic->load();
}
}
我所说的安全是指,我将始终读取从 0 到 100 的值,我不会读取无效指针或在初始化之前读取指向的对象。
我的直觉告诉我这是安全的,因为
- 在所有架构上,指针都是以原子方式读取或写入的。
- 指向的值是原子读取的,必须从 RAM 中获取,并且写入之前的内存屏障保证 RAM 始终正确。
那么,这安全吗?也许只适用于所有常见的架构?
是的,用汇编语言。但你用的是 C++ 语言,重要的是语言的内存模型及其抽象机。 你的代码存在由并发非同步写入+读取引起的数据竞争 UB
g_atomic
。实际上,读取循环会将 的负载提升
g_atomic
出循环之外。由于它不是原子的,因此另一个线程修改它就是不合理的,编译器可以假设这种情况不会发生。换句话说,对非原子变量进行数据竞争是 UB,这使得编译器能够继续对单线程代码进行重要的优化。(它们不知道哪些全局变量是共享的,哪些不是。)在 C++ 中,如果您想要获得所希望的行为,则必须使用 来请求它
std::atomic< T* >
。如果不需要任何排序,则使用store 和 load 。
relaxed
但实际上你确实需要排序,这样你就不会在指向的对象完全构造之前发布指针。你使用了一个慢速指令
thread_fence(seq_cst)
来实现这一点;实际上你只需要release
,最好是.store(val, release)
使用单独的栅栏指令,这样它就可以编译为AArch64,stlr
而不是单独的屏障指令。在你的情况下,也
T
就是。所以你的代码应该像这样写,才能编译成你认为从原始代码中得到的汇编代码。我将其重命名为 ,因为它和它指向的对象都必须是原子的。std::atomic<int>
std::atomic_int
g_atomic
g_ptr
(实际上,您不需要指向的对象是原子的,这样就可以保证安全。 因此
std::atomic< int* > g_ptr;
,假设您不会有线程写入这些指向的对象。由于写入器和读取器之间的同步,初始化发生在读取之前。这里并不正式,因为我relaxed
在读取器中使用了 because ,因为consume
它已被弃用,并且在实践中可以编译为正确的 asm。)(戈德博尔特)
是的,因为它是
std::atomic<int>
。嗯,来自共享内存,所以实际上可能来自缓存。在所有实际的 C++ 实现中,std::thread 运行的内核之间的缓存都是一致的。(在内核共享内存但不具备缓存一致性的主板上,例如 ARM DSP + 微控制器,您不会在这些内核上运行同一程序的线程。)
在 C++ 中,正式情况下至少需要
std::memory_order_consume
,但由于原始定义过于复杂,难以实现,它已被弃用并被移除。编译器只是通过将其提升为 来实现它acquire
,而实际上并没有利用除 DEC Alpha 之外所有 ISA 都提供的内存依赖排序。在这种情况下,编译器没有合理的方法来确定指针加载必须产生的值,因此必须编写一个汇编程序来加载并解引用该指针。因此,第一个
pointer.load(relaxed)
和之间存在数据依赖关系tmp->load(relaxed)
,而真正的ISA保证在这种情况下不会发生LoadLoad重排序。因此它在实践中是有效的;例如,Linux内核将此用于RCU,以避免读取路径中的任何内存屏障。参见C++11:memory_order_relaxed 和 memory_order_consume 之间的区别
consume
以及有关内存依赖排序的类似答案。不,这不安全。
g_atomic
应该是std::atomic<std::atomic<int>*>
。指向原子对象的原子指针。即使底层硬件不会撕裂指针大小的读/写,由于指针既不是原子的也不是易失性的,因此编译器可以自由地优化指针值的所有加载/存储,除了最后一个循环中的最后一次写入和读取器中的第一次加载。
您的读取器可能会无限循环,因为它不断检查相同的旧分配指针而不是选择下一个分配。
至于存储在的值
atomic<int>
,标准说:因此,原子指针应该通过内存获取/释放的顺序来访问。你的栅栏加上一个放松操作也应该可以工作(如果与读取端的栅栏配对),但其开销比实际需要的要大。