如何有效地使用std::atomic

How to use std::atomic efficiently

本文关键字:std atomic 有效地      更新时间:2023-10-16

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));

所以我的问题是:

  1. 上面的代码使用了丑陋的reinterpret_cast,这是检索引用位置&buf[索引]
  2. 单个字节上的CAS是否比机器字上的CAS慢得多,因此我应该避免使用它?如果我将代码更改为加载单词、提取字节、计算并在新值中设置字节,然后执行CAS,那么代码看起来会更复杂。这使得代码更加复杂,我还需要自己处理地址对齐问题

编辑:如果这些问题与处理器/体系结构有关,那么x86/x64处理器的结论是什么?

  1. reinterpret_cast将产生未定义的行为。您的变量是std::atomic<uint8_t>或纯uint8_t;你不能在它们之间做选择。例如,尺寸和对准要求可能不同。例如,一些平台只提供对字的原子操作,因此std::atomic<uint8_t>将使用全机器字,而普通uint8_t只能使用一个字节。非原子操作也可以以各种方式进行优化,包括与周围的操作进行显著的重新排序,并与相邻内存位置上的其他操作组合,在这些操作中可以提高性能。

    这意味着,如果您想要对某些数据进行原子操作,那么您必须提前知道这一点,并创建合适的std::atomic<>对象,而不仅仅是分配通用缓冲区。当然,您可以分配一个缓冲区,然后使用放置new在该缓冲区中初始化原子变量,但您必须确保大小和对齐正确,并且不能对该对象使用非原子操作。

    如果您真的不关心对原子对象的约束进行排序,那么就对非原子操作使用memory_order_relaxed。然而,请注意,这是高度专业化的,需要非常小心。例如,对不同变量的写入可以由其他线程以与写入时不同的顺序读取,并且不同的线程可以以不同的顺序相互读取值,即使在相同的程序执行中也是如此。

  2. 如果一个字节的CAS比一个字慢,则使用std::atomic<unsigned>可能会更好,但这会带来空间损失,而且您当然不能仅使用std::atomic<unsigned>访问一系列原始字节——对该数据的所有操作都必须通过同一个std::atomic<unsigned>对象。通常情况下,您最好编写能够满足您需要的代码,并让编译器找出实现这一点的最佳方法。

对于x86/x64,有了std::atomic<unsigned>变量aa.load(std::memory_order_acquire)a.store(new_value,std::memory_order_release)就实际指令而言并不比加载和存储到非原子变量更昂贵,但它们确实限制了编译器的优化。如果使用默认的std::memory_order_seq_cst,那么其中一个或两个操作将产生LOCKed指令或围栏的同步成本(我的实现在存储中定价,但其他实现可能会有不同的选择)。然而,由于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的实际指令时间)。