如何有效地使用std::atomic
How to use std::atomic efficiently
std::atomic是c++11引入的新功能,但我找不到太多关于如何正确使用它的教程。那么,以下做法是否常见且有效?
我使用的一种做法是我们有一个缓冲区,我想在一些字节上使用CAS,所以我做的是:
uint8_t *buf = ....
auto ptr = reinterpret_cast<std::atomic<uint8_t>*>(&buf[index]);
uint8_t oldValue, newValue;
do {
oldValue = ptr->load();
// Do some computation and calculate the newValue;
newValue = f(oldValue);
} while (!ptr->compare_exchange_strong(oldValue, newValue));
所以我的问题是:
- 上面的代码使用了丑陋的reinterpret_cast,这是检索引用位置&buf[索引]
- 单个字节上的CAS是否比机器字上的CAS慢得多,因此我应该避免使用它?如果我将代码更改为加载单词、提取字节、计算并在新值中设置字节,然后执行CAS,那么代码看起来会更复杂。这使得代码更加复杂,我还需要自己处理地址对齐问题
编辑:如果这些问题与处理器/体系结构有关,那么x86/x64处理器的结论是什么?
-
reinterpret_cast
将产生未定义的行为。您的变量是std::atomic<uint8_t>
或纯uint8_t
;你不能在它们之间做选择。例如,尺寸和对准要求可能不同。例如,一些平台只提供对字的原子操作,因此std::atomic<uint8_t>
将使用全机器字,而普通uint8_t
只能使用一个字节。非原子操作也可以以各种方式进行优化,包括与周围的操作进行显著的重新排序,并与相邻内存位置上的其他操作组合,在这些操作中可以提高性能。这意味着,如果您想要对某些数据进行原子操作,那么您必须提前知道这一点,并创建合适的
std::atomic<>
对象,而不仅仅是分配通用缓冲区。当然,您可以分配一个缓冲区,然后使用放置new
在该缓冲区中初始化原子变量,但您必须确保大小和对齐正确,并且不能对该对象使用非原子操作。如果您真的不关心对原子对象的约束进行排序,那么就对非原子操作使用
memory_order_relaxed
。然而,请注意,这是高度专业化的,需要非常小心。例如,对不同变量的写入可以由其他线程以与写入时不同的顺序读取,并且不同的线程可以以不同的顺序相互读取值,即使在相同的程序执行中也是如此。 -
如果一个字节的CAS比一个字慢,则使用
std::atomic<unsigned>
可能会更好,但这会带来空间损失,而且您当然不能仅使用std::atomic<unsigned>
访问一系列原始字节——对该数据的所有操作都必须通过同一个std::atomic<unsigned>
对象。通常情况下,您最好编写能够满足您需要的代码,并让编译器找出实现这一点的最佳方法。
对于x86/x64,有了std::atomic<unsigned>
变量a
,a.load(std::memory_order_acquire)
和a.store(new_value,std::memory_order_release)
就实际指令而言并不比加载和存储到非原子变量更昂贵,但它们确实限制了编译器的优化。如果使用默认的std::memory_order_seq_cst
,那么其中一个或两个操作将产生LOCK
ed指令或围栏的同步成本(我的实现在存储中定价,但其他实现可能会有不同的选择)。然而,由于memory_order_seq_cst
运算施加了"单一总排序"约束,因此它们更容易推理。
在许多情况下,使用锁而不是原子操作同样快速,而且不容易出错。如果互斥锁的开销由于争用而非常大,那么您可能需要重新考虑您的数据访问模式——缓存乒乓球很可能会用原子来攻击您。
您的代码肯定是错误的,一定会做一些有趣的事情。如果情况真的很糟糕,它可能会做你认为应该做的事情。我不会去理解如何正确使用例如CAS,但你会使用std::atomic<T>
这样的东西:
std::atomic<uint8_t> value(0);
uint8_t oldvalue, newvalue;
do
{
oldvalue = value.load();
newvalue = f(oldvalue);
}
while (!value.compare_exchange_strong(oldvalue, newvalue));
到目前为止,我的个人政策是远离这些无锁的东西,把它留给知道自己在做什么的人。我会使用atomic_flag和可能的计数器,这是我所能做到的。从概念上讲,我理解这种免锁的东西是如何工作的,但我也明白,如果你不特别小心,可能会出现太多问题。
您的reinterpret_cast<std::atomic<uint8_t>*>(...)
显然不是检索原子的正确方法,甚至不能保证工作。这是因为CCD_ 21不能保证具有与CCD_ 22相同的大小。
关于CAS字节比机器慢的第二个问题,请回答:这实际上取决于机器,它可能更快,可能更慢,或者在您的目标体系结构上甚至可能不存在字节的CAS。在后一种情况下,实现很可能需要为原子使用锁定实现,或者在内部使用不同(更大)的类型(这是原子与底层类型大小不同的一个例子)。
据我所见,确实没有办法在现有值上获得std::atomic
,特别是因为它们不能保证大小相同。因此,您确实应该直接将buf
设置为std::atomic<uint8_t>*
。此外,我相对确信,即使这样的强制转换可以工作,通过非原子访问同一地址也不能保证按预期工作(因为即使是字节,这种访问也不能保证是原子访问)。因此,使用非原子方法来访问您想要进行原子操作的内存位置是没有意义的。
请注意,对于常见的体系结构,字节的存储和加载无论如何都是原子的,因此只要对这些操作使用宽松的内存顺序,在那里使用原子几乎没有性能开销。因此,如果您在某一点上并不真正关心执行顺序(例如,因为程序还不是多线程的),只需使用a.store(0, std::memory_order_relaxed)
而不是a.store(0)
。
当然,如果您只谈论x86,那么reinterpret_cast
可能会工作,但您的性能问题可能仍然取决于处理器(我认为,我还没有查找cmpxchg
的实际指令时间)。
- std::atomic和std::condition_variable wait,notify_*方法之间的区别
- 在 lambda 表达式中使用 std::atomic
- C++std::atomic在程序员级别保证了什么
- 如果在 2 个线程中使用,是否值得将size_t声明为 std::atomic?
- 在 C++20 之前和之后初始化 std::atomic
- std::atomic 和 std::mutex 的相对性能
- 简单使用 std::atomic 在两个线程之间共享数据
- Port pthread_cond_broadcast to std::atomic
- std::atomic中的任何内容都是免费等待的
- 为什么 std::atomic 构造函数在 C++14 和 C++17 中的行为不同
- std::atomic是如何实现的
- 使用 std::atomic 标志和 std::condition_variable 在工作线程上等待
- 为什么std::atomic的默认构造函数不默认初始化底层存储值
- 读取互斥对象范围之外的volatile变量,而不是std::atomic
- 为什么std::atomic中的所有成员函数都同时出现在有volatile和没有volatile的情况下
- 访问共享内存而不使用易失性、std::atomic、信号量、互斥锁和自旋锁
- 两个不同的进程,在同一地址上有 2 个 std::atomic 变量?
- 原子读取,然后使用 std::atomic 写入
- std::atomic::fetch_add是x86-64上的串行化操作
- 实际上,C++11 中 std::atomic 的内存占用量是多少?