C++11 原子<>:只能使用提供的方法读取/写入?

C++11 atomic<>: only to be read/written with provided methods?

本文关键字:方法 读取 写入 gt lt 原子 C++11      更新时间:2023-10-16

我写了一些多线程但无锁的代码,这些代码在早期支持 C++11 的 GCC(7 或更早版本)上编译并显然执行得很好。 原子场是ints等等。 据我所知,我使用正常的C/C++操作在原子性或事件排序不成问题的地方对它们进行操作(a=1;等)。

后来我不得不做一些双宽的CAS操作,并像往常一样用指针和计数器做了一个小结构。 我尝试执行相同的正常 C/C++ 操作,但错误表明变量没有这样的成员。 (这是您对大多数普通模板的期望,但我半期望atomic以不同的方式工作,部分原因是根据我的记忆,支持正常的往返分配,ints)。

所以两部分问题:

  1. 我们是否应该在所有情况下都使用原子方法,甚至(比如)由一个没有竞争条件的线程完成初始化? 1a)所以一旦声明为原子,就没有办法非原子地访问?1b) 我们还必须使用atomic<>方法的详细程度来做到这一点?

  2. 否则,如果至少对于整数类型,我们可以使用正常的 C/C++ 操作。 但在这种情况下,这些操作是与load()/store()相同还是只是正常的分配?

还有一个半元问题:关于为什么atomic<>变量不支持正常的C/C++操作,是否有任何见解? 我不确定 C++11 语言作为规范是否有能力编写这样做的代码,但规范肯定可以要求编译器做规范语言不够强大的事情。

您可能正在寻找 C++20std::atomic_ref<T>,以便使您能够对也可以非原子访问的对象进行原子操作。 确保声明的非原子T对象具有足够的对齐方式,以便atomic<T>. 例如

alignas(std::atomic_ref<long long>::required_alignment)
long long  sometimes_shared_var;

但这需要 C++20,而 C++17 或更早版本中没有等效的东西。 一旦构建了一个原子对象,我认为除了它的原子成员函数之外,没有任何保证的可移植安全方法来修改它。

标准不保证其内部对象表示形式,因此memcpy有效地将struct sixteenbyte对象从atomic<sixteenbyte>中取出,即使没有其他线程引用它,标准也不能保证它是安全的。 您必须知道特定实现如何存储它。 不过,检查sizeof(atomic<T>) == sizeof(T)是一个好兆头,主流实现在实践中确实有一个T作为atomic<T>的对象表示。

相关:如何使用 c++11 CAS 实现 ABA 计数器? 对于一个讨厌的联合黑客(GNU C++中的"安全")为单个成员提供有效的访问,因为编译器不会优化foo.load().ptr只是原子加载该成员。 相反,GCC 和 clang 将lock cmpxchg16b加载整个指针+计数器对,然后只加载第一个成员。 C++20atomic_ref<>应该解决这个问题。


访问atomic<struct foo>成员:不允许shared.x = tmp;的一个原因是这是错误的心智模型。 如果两个不同的线程存储到同一结构的不同成员,语言如何定义其他线程看到的内容的任何排序? 另外,如果允许这样的事情,程序员可能被认为很容易错误地设计他们的无锁算法。

另外,您甚至将如何实现它? 返回左值引用?它不能是底层非原子对象。 如果代码捕获了该引用,并在调用某些未加载或存储的函数后很长时间内继续使用它怎么办?

请记住,ISO C++ 的排序模型在同步方面工作,而不是像真正的 ISA 定义其内存模型的方式那样在本地重新排序和单个缓存一致性域方面工作。 ISO C++模型始终严格按照读取、写入或RMW处理整个原子对象。 因此,对象的加载始终可以与整个对象的任何存储同步。

在实际 ISA 上,如果整个对象位于一个缓存行中,则实际上仍然适用于一个成员的存储和来自另一个成员的负载的硬件。 至少我是这么认为的,尽管可能不是在某些 SMT 系统上。 (位于一个缓存行中对于在大多数 ISA 上可以对整个对象进行无锁原子访问是必需的。


我们还必须使用原子<>方法的详细程度来做到这一点?

atomic<T>的成员函数包括所有运算符的重载,包括operator=(存储)和转换回T(加载)。a = 1;等效于atomic<int> a;a.store(1, std::memory_order_seq_cst),是设置新值的最慢方法。

我们是否应该在所有情况下都使用原子方法,甚至(比如)由一个没有竞争条件的线程完成初始化?

除了将 args 传递给std::atomic<T>对象的构造函数之外,您别无选择。

不过,您可以在对象仍为线程私有时使用mo_relaxed加载/存储。 避免使用任何像+=这样的 RMW 运算符。 例如a.store(a.load(relaxed) + 1, relaxed);的编译方式与寄存器宽度或更小的非原子对象的编译大致相同。

(除了它不能优化并将值保留在寄存器中,因此请使用本地临时文件而不是实际更新原子对象)。

但是对于太大而无法无锁的原子对象,除了首先使用正确的值构建它们之外,您实际上无法有效地做任何事情。


原子场是整数等等。 ...
显然执行得很好

如果你的意思是普通int,而不是atomic<int>那么它就不便携安全。

数据竞争 UB 不能保证可见的损坏,未定义行为的令人讨厌的事情是,在您的测试用例中发生工作是允许发生的事情之一

在许多情况下,对于纯负载或纯存储,它不会中断,尤其是在强序 x86 上,除非负载或存储可以提升或下沉出循环。 为什么在 x86 上自然对齐的变量上的整数赋值是原子的? 但是,当编译器设法在编译时执行跨文件内联并重新排序某些操作时,它最终会咬你。


为什么原子<>变量不支持正常的 C/C++ 操作?
...但是规范肯定可以要求编译器做规范语言不够强大的事情。

这实际上是 C++11 到 17 的限制。 大多数编译器对此没有问题。 例如,gcc/clang 的<atomic>标头的实现使用__atomic_内置,这些内置函数采用普通的T*指针。

C++20 对atomic_ref的提议是 p0019,它引用了作为动机:

一个对象可以在明确定义的阶段中以非原子方式大量使用 的应用程序。强制这些对象完全是原子的,将 产生不必要的性能损失。

3.2. 对超大数组成员的原子操作

高性能计算 (HPC) 应用程序使用非常大的阵列。使用这些数组的计算通常具有不同的阶段,这些阶段分配和初始化数组的成员、更新数组的成员以及读取数组的成员。用于初始化的并行算法(例如,零填充)在分配成员值时具有非冲突的访问权限。用于更新的并行算法对成员具有冲突的访问权限,必须由原子操作保护。具有只读访问权限的并行算法需要性能最佳的流式读取访问、随机读取访问、矢量化或其他有保证的无冲突 HPC 模式。

所有这些都是std::atomic<>的问题,证实了您的怀疑,这是C++11的问题。

他们没有引入一种对std::atomic<T>进行非原子访问的方法,而是引入了一种对T对象进行原子访问的方法。 这样做的一个问题是,atomic<T>可能需要比默认T更多的对齐方式,因此要小心。

与给T成员原子访问权不同,你可以合理地拥有一个返回对底层对象的左值引用的.non_atomic()成员函数。