首页 > 解决方案 > C++11 atomic<>:只能用提供的方法读/写?

问题描述

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

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

所以两部分问题:

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

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

atomic<>还有一个半元问题:对于为什么变量不支持正常的 C/C++ 操作有什么见解吗?我不确定规范的 C++11 语言是否有能力编写执行此操作的代码,但规范当然可以要求编译器执行规范的语言还不够强大的事情.

标签: c++c++11stdatomic

解决方案


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

alignas(atomic<long long>)  long long  sometimes_shared_var;

但这需要 C++20,并且在 C++17 或更早版本中没有任何等价物。一旦构造了一个原子对象,我认为除了它的原子成员函数之外,没有任何有保证的安全方法来修改它。

标准不保证其内部对象表示,因此即使没有其他线程引用它,标准也不能保证有效地memcpystruct sixteenbyte对象中取出对象是安全的。atomic<sixteenbyte>您必须知道特定实现如何存储它。不过,检查sizeof(atomic<T>) == sizeof(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++ 模型始终严格按照读取、写入或 RMWing 整个原子对象。因此,对象的加载始终可以与整个对象的任何存储同步。

在实际 ISA 上,如果整个对象都在一个高速缓存行中,那么实际上仍然可以存储到一个成员并从不同成员加载的硬件中。至少我是这么认为的,尽管可能不在某些 SMT 系统上。(在大多数 ISA 上,要使对整个对象的无锁原子访问成为可能,必须位于一个缓存行中。)


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

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

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

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

mo_relaxed不过,您可以在对象仍然是 thread-private 时使用加载/存储。避免使用任何 RMW 运算符,例如+=. ega.store(a.load(relaxed) + 1, relaxed);将与寄存器宽度或更小的非原子对象编译大致相同。

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

但是对于太大而不能无锁的原子对象,除了首先用正确的值构造它们之外,实际上没有什么可以有效地做的。


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

如果你的意思是 plain intatomic<int>那么它就不便携了。

Data-race UB 不保证可见的损坏,未定义行为的讨厌的事情是发生在您的测试用例中工作是允许发生的事情之一

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


为什么 atomic<> 变量不支持正常的 C/C++ 操作?
...但是规范当然可以要求编译器按照规范执行的语言还不够强大。

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

C++20 的提议atomic_refp0019,它引用为动机:

在应用程序的定义明确的阶段,可以大量使用非原子对象。强制这些对象完全是原子的会导致不必要的性能损失。

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

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

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

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

与对 的成员进行原子访问不同T,您可能有一个.non_atomic()返回对底层对象的左值引用的成员函数。


推荐阅读