【问题标题】:How to use std::atomic efficiently如何有效地使用 std::atomic
【发布时间】:2012-01-05 20:08:24
【问题描述】:

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[index] 的原子指针的正确方法吗?
  2. 单个字节上的 CAS 是否比机器字上的 CAS 慢得多,所以我应该避免使用它?如果我将代码更改为加载一个单词、提取字节、计算并将字节设置为新值,然后执行 CAS,我的代码看起来会更复杂。这使得代码更加复杂,我还需要自己处理地址对齐问题。

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

【问题讨论】:

  • C++ Concurrency in Action (early access), (amazon) 可能是目前最好的书,或者更确切地说,将是。
  • 关于原子的教程并不多,因为除了像原子标志这样的一些简单案例之外,它是一个雷区。观看“The Hurt Locker”应该是使用原子的先决条件。使用锁!

标签: c++ c++11


【解决方案1】:
  1. reinterpret_cast 将产生未定义的行为。你的变量要么是std::atomic&lt;uint8_t&gt;,要么是普通的uint8_t;你不能在它们之间投射。例如,尺寸和对齐要求可能不同。例如一些平台只提供对字的原子操作,所以std::atomic&lt;uint8_t&gt; 将使用一个完整的机器字,而普通的uint8_t 可以只使用一个字节。非原子操作也可以通过各种方式进行优化,包括与周围操作显着重新排序,以及与相邻内存位置上的其他操作相结合,从而提高性能。

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

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

  2. 如果一个字节的 CAS 比一个字慢,你可能最好使用 std::atomic&lt;unsigned&gt;,但这会有空间损失,你当然不能只使用std::atomic&lt;unsigned&gt; 访问原始字节序列 --- 对该数据的所有操作都必须通过相同的 std::atomic&lt;unsigned&gt; 对象。通常,您最好编写满足您需要的代码并让编译器找出最佳方法。

对于 x86/x64,就实际指令而言,使用 std::atomic&lt;unsigned&gt; 变量 aa.load(std::memory_order_acquire)a.store(new_value,std::memory_order_release) 并不比加载和存储到非原子变量更昂贵,但它们确实有限制编译器优化。如果您使用默认的std::memory_order_seq_cst,则这些操作中的一个或两个将产生LOCKed 指令或栅栏的同步成本(my implementation 将价格放在商店中,但其他实现可能会选择不同的方式)。但是,memory_order_seq_cst 操作由于它们施加的“单一总排序”约束而更容易推理。

在许多情况下,使用锁而不是原子操作同样快,而且更不容易出错。如果由于争用而导致互斥锁的开销很大,那么您可能需要重新考虑您的数据访问模式 --- 缓存 ping pong 很可能会用原子来打击您。

【讨论】:

    【解决方案2】:

    您的代码肯定是错误的,并且一定会做一些有趣的事情。如果事情真的很糟糕,它可能会做你认为它打算做的事情。我不会去了解如何正确使用例如CAS,但你会使用 std::atomic&lt;T&gt; 这样的东西:

    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 和可能的计数器,这就是我所能做到的。从概念上讲,我理解这种无锁的东西是如何工作的,但我也明白,如果你不非常小心,就会有太多事情发生。

    【讨论】:

    • 我会说这是一个来自现实世界用例的问题,而不是一些学术界的作业。我个人会尽可能地遵循标准,但在现实生活中,有时我就是做不到。
    【解决方案3】:

    您的reinterpret_cast&lt;std::atomic&lt;uint8_t&gt;*&gt;(...) 绝对不是检索原子的正确方法,甚至不能保证正常工作。这是因为std::atomic&lt;T&gt; 不能保证与T 具有相同的大小。

    关于 CAS 比机器字慢的第二个问题:这真的取决于机器,它可能更快,可能更慢,或者您的目标架构上甚至可能不存在字节的 CAS。在后一种情况下,实现很可能需要对 atomic 使用锁定实现,或者在内部使用不同(更大)的类型(这是 atomic 与底层类型大小不同的一个示例)。

    据我所知,确实没有办法在现有值上获得std::atomic,特别是因为不能保证它们的大小相同。因此,您真的应该直接将buf 设为std::atomic&lt;uint8_t&gt;*。此外,我相对确定,即使这样的强制转换有效,通过非原子访问同一地址也不能保证按预期工作(因为即使对于字节,这种访问也不能保证是原子的)。因此,拥有非原子意味着访问您想要对其进行原子操作的内存位置并没有真正意义。

    请注意,对于常见的架构,字节的存储和加载无论如何都是原子的,因此只要您对这些操作使用宽松的内存顺序,就可以在其中使用原子,几乎没有性能开销。因此,如果您在某一时刻并不真正关心执行顺序(例如,因为程序还不是多线程的),只需使用 a.store(0, std::memory_order_relaxed) 而不是 a.store(0)

    当然,如果您只谈论 x86,您的 reinterpret_cast 可能会起作用,但您的性能问题可能仍然取决于处理器(我想,我还没有查看 cmpxchg 的实际指令时序)。

    【讨论】:

    • 我 90% 确定字节上的原子会比一个字慢,因为它需要进行一些按位运算。我想知道它看起来会慢多少。另一件事是,我不同意您的观点,即读/写单个字节是原子的,至少在 x86 上是这样。感谢您使用原子数组而不是字节数组的建议,它可以工作,但也会导致从字节加载速度变慢,这不是我想要的。实际上,在 99% 的时间里,我可以判断没有其他线程正在存储到数组中,因此不需要额外的屏障。只有很短的时间我需要做以上的事情。
    • @icando:正如我所说,它依赖于平台。但是,既然您在谈论 x86:为什么原子操作在字节上比在字上要慢?你是什​​么意思它需要做一些按位运算? X86 可以本地存储字节并具有 8 位 cmpxchg,所以这无关紧要(这不完全是,但它不应该比使用字节而不是机器字产生更大的影响)。关于额外的障碍:这就是我建议 memory_order_relaxed 的原因,它应该消除大部分额外成本,因为加载/存储无论如何都是原子的(至少在 x86 上)。
    猜你喜欢
    • 2015-08-25
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-03-22
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-01-02
    相关资源
    最近更新 更多