我写了一些多线程但无锁的代码,这些代码在早期支持C11的GCC(7或更老版本)上编译和执行得很好。原子字段是int
s等。据我所知,我使用普通的C/C操作来操作它们(a=1;
等),在这些地方原子性或事件排序不是一个问题。
后来我不得不做一些双宽度的CAS操作,并做了一个带有指针和计数器的小结构体。我试着做同样的普通C/C操作,错误出现了变量没有这样的成员。(这是您对大多数普通模板的期望,但我对atomic
的工作方式有一半的期望,部分原因是它支持普通的to和from赋值,据我所知,为int
s。)。
问题分为两部分:
1.我们是否应该在所有情况下使用原子方法,即使(比如)初始化是由一个线程在没有竞态条件的情况下完成的?1a)因此,一旦声明为原子,就无法非原子地访问?1b)我们还必须使用atomic<>
方法的详细程度来做到这一点?
1.否则,至少对于整数类型,我们可以使用普通的C/C运算,但是在这种情况下,这些运算是与load()
/store()
相同,还是仅仅是普通的赋值运算?
还有一个半元问题:对于atomic<>
变量不支持普通C/C操作的原因,有什么见解吗?我不确定C11语言是否有能力编写这样的代码,但该规范肯定会要求编译器做一些该语言没有强大到足以完成的事情。
1条答案
按热度按时间von4xj4u1#
你可能在寻找C++20
std::atomic_ref<T>
来给予你在对象上做原子操作的能力,这些对象也可以被非原子地访问。确保你的非原子T
对象声明的足够对齐atomic<T>
。但这需要C20,而在C17或更早的版本中没有等效的东西。一旦构造了原子对象,我不认为有任何 * 保证 * 可移植的安全方法来修改它,除了它的原子成员函数。
标准不能保证
memcpy
的内部对象表示,因此即使没有其他线程引用atomic<sixteenbyte>
,标准也不能保证memcpy
从atomic<sixteenbyte>
中有效地获取struct sixteenbyte
对象是安全的。您必须知道特定的实现如何存储它。检查sizeof(atomic<T>) == sizeof(T)
是一个好兆头。并且主流实现实际上仅具有X1 M7 N1 X作为X1 M8 N1 X的对象表示。相关:How can I implement ABA counter with c++11 CAS?,一个讨厌的联合攻击(在GNU C中是“安全的”),它给予了对单个成员的高效访问,因为编译器不会优化
foo.load().ptr
来原子地加载那个成员。相反,GCC和clang将lock cmpxchg16b
加载整个指针+计数器对,然后只加载第一个成员。C20atomic_ref<>
应该可以解决这个问题。正在访问
atomic<struct foo>
的成员:不允许shared.x = tmp;
的一个原因是它是错误的思维模型。如果两个不同的线程存储到同一个结构体的不同成员,语言如何定义其他线程看到的任何顺序?另外,如果允许这样的东西,程序员可能会认为太容易设计他们的无锁算法不正确。还有,你怎么实现这个呢?返回一个左值引用吗?它不可能是底层的非原子对象。如果代码捕获了那个引用,并且在调用了一些不是load或store的函数之后很长时间一直使用它呢?
请记住,ISO C的排序模型是根据同步来工作的,而不是根据本地重新排序和单个缓存一致域来工作的,就像真实的的ISA定义其内存模型的方式一样。ISO C模型总是严格地根据阅读、写入或重新写入整个原子对象来工作。因此,对象的加载总是可以与整个对象的任何存储同步。
在硬件中,如果整个对象在一个缓存行中,在现实世界的ISA上,对于存储到一个成员和从另一个成员加载实际上仍然有效。至少我认为是这样的,尽管在一些SMT系统上可能不是这样。(在大多数ISA上,在一个缓存行中对于整个对象的无锁原子访问是必要的。)
我们还必须使用原子〈〉方法的冗长性来实现这一点。
atomic<T>
的成员函数包含所有运算符的重载,包括operator=
(存储)和强制转换回T
(加载)。对于atomic<int> a;
,a = 1;
等效于a.store(1, std::memory_order_seq_cst)
,并且是设置新值的最慢方式。我们是否应该在所有情况下都使用原子方法,甚至(比方说)初始化由一个线程在没有争用条件的情况下完成?
除了将参数传递给
std::atomic<T>
对象的构造函数之外,您别无选择。**当你的对象仍然是线程私有时,你可以使用
mo_relaxed
加载/存储。**但是,避免使用任何RMW操作符,如+=
。例如,a.store(a.load(relaxed) + 1, relaxed);
的编译方式与寄存器宽度或更小的非原子对象的编译方式大致相同。(除了它不能优化并将值保存在寄存器中,因此使用本地临时变量而不是实际更新原子对象)。
但是对于太大而不能无锁的原子对象,除了首先用正确的值构造它们之外,实际上没有什么可以有效地做的。
原子域是整型的等等。...
而且显然被判了死刑
如果你指的是普通的
int
,而不是atomic<int>
,那么它在便携式上就不安全了。数据竞争UB不保证可见的破坏,未定义行为的讨厌之处在于,碰巧在测试用例中工作是允许发生的事情之一。
在很多纯加载或纯存储的情况下,它不会中断,特别是在强排序的x86上,除非加载或存储可以提升或下沉出循环。Why is integer assignment on a naturally aligned variable atomic on x86?。但是,当编译器设法在编译时进行跨文件内联和重新排序一些操作时,它最终会咬你一口。
为什么普通的C/C操作在原子变量上不受支持?
......但是规范当然可以要求编译器去做规范中的语言没有强大到足以完成的事情。
这实际上是C11至17的一个限制。大多数编译器对此没有问题。例如gcc/clang的
<atomic>
头文件的实现使用__atomic_
builtins which take a plainT*
pointer。C++20对
atomic_ref
的提议是p0019,它引用了以下动机:在应用程序的定义良好的阶段,对象可能会被大量地非原子使用。强制这样的对象成为独占原子会导致不必要的性能损失。
3.2.对超大数组成员的原子操作
高性能计算(HPC)应用程序使用非常大的数组。使用这些数组进行的计算通常具有不同的阶段,包括分配和初始化数组成员、更新数组成员以及读取数组成员。用于初始化的并行算法(例如,零填充)在分配成员值时具有无冲突的访问。更新的并行算法对成员的访问存在冲突,必须通过原子操作来保护成员。只读访问的并行算法需要最佳性能的流式读访问、随机读访问、矢量化或其它保证不冲突的HPC模式。
所有这些都是
std::atomic<>
的问题,这证实了您的怀疑,即这是C++11的问题。他们没有引入一种对
std::atomic<T>
进行非原子访问的方法,而是引入了一种对T
对象进行原子访问的方法,其中一个问题是atomic<T>
可能需要比T
在默认情况下获得的对齐更多的对齐,所以要小心。与对
T
的成员进行 * atomic * 访问不同,您可能会有一个.non_atomic()
成员函数返回一个对底层对象的左值引用。