【问题标题】:Are memory orders for each atomic correct in this lock-free SPSC ring buffer queue?在这个无锁 SPSC 环形缓冲区队列中,每个原子的内存顺序是否正确?
【发布时间】:2021-12-28 20:51:51
【问题描述】:

我有一个看起来像这样的环形缓冲区:

template<class T>
class RingBuffer {
public:
  bool Publish();
 
  bool Consume(T& value);

  bool IsEmpty(std::size_t head, std::size_t tail);

  bool IsFull(std::size_t head, std::size_t tail);

private:
  std::size_t Next(std::size_t slot);

  std::vector<T> buffer_;
  std::atomic<std::size_t> tail_{0};
  std::atomic<std::size_t> head_{0};
  static constexpr std::size_t kBufferSize{8};
};

此数据结构旨在与两个线程一起使用:发布者线程和消费者线程。下面列出了没有将内存顺序传递给原子的两个主要函数:

bool Publish(T value) {
    const size_t curr_head = head_.load(/* memory order */);
    const size_t curr_tail = tail_.load(/* memory_order */);

    if (IsFull(curr_head, curr_tail)) {
      return false;
    }

    buffer_[curr_tail] = std::move(value);
    tail_.store(Next(curr_tail) /*, memory order */);
    return true;
  }

bool Consume(T& value) {
    const size_t curr_head = head_.load(/* memory order */);
    const size_t curr_tail = tail_.load(/* memory order */);

    if (IsEmpty(curr_head, curr_tail)) {
      return false;
    }

    value = std::move(buffer_[curr_head]);
    head_.store(Next(curr_head) /*, memory order */);
    return true;
  }

我知道,至少,我必须在Publish() 函数中有tail_.store()std::memory_order::releasetail_.load()std::memory_order::acquireConsume() 函数中创建发生之前 写入buffer_ 和读取buffer_ 之间的连接。另外,我可以将std::memory_order::relaxed 传递给Publish() 中的tail_.load()Consume() 中的head_.load(),因为同一个线程将看到最后一次写入原子。现在函数是这样的:

 bool Publish(T value) {
     const size_t curr_head = head_.load(/* memory order */);
     const size_t curr_tail = tail_.load(std::memory_order::relaxed);
    
     if (IsFull(curr_head, curr_tail)) {
         return false;
     }
    
     buffer_[curr_tail] = std::move(value);
     tail_.store(Next(curr_tail), std::memory_order::release);
     return true;
 }

 bool Consume(T& value) {
     const size_t curr_head = head_.load(std::memory_order::relaxed);
     const size_t curr_tail = tail_.load(std::memory_order::acquire);
    
     if (IsEmpty(curr_head, curr_tail)) {
         return false;
     }
    
     value = std::move(buffer_[curr_head]);
     head_.store(Next(curr_head) /*, memory order */);
     return true;
 }

最后一步是将内存订单放入剩余的对中:head_.load() 放入 Publish()head_.store() 放入 Consume()。我必须在Consume() 中的head_.store() 之前执行value = std::move(buffer_[curr_head]); 行,否则在缓冲区已满的情况下我将发生数据竞争,因此,至少,我必须将std::memory_order::release 传递给该存储操作以避免重新排序.但是我必须将std::memory_order::acquire 放入head_.load() 中的Publish() 函数中吗?我知道这将有助于head_.load() 在合理的时间内看到head_.store() 不像std::memory_order::relaxed,但是如果我不需要这种更短的时间来保证看到存储操作的副作用,我可以有一个轻松的记忆命令?如果我不能,那为什么?完成代码:

bool Publish(T value) {
    const size_t curr_head = head_.load(std::memory_order::relaxed); // or acquire?
    const size_t curr_tail = tail_.load(std::memory_order::relaxed);
        
    if (IsFull(curr_head, curr_tail)) {
        return false;
    }
        
    buffer_[curr_tail] = std::move(value);
    tail_.store(Next(curr_tail), std::memory_order::release);
    return true;
}
    
bool Consume(T& value) {
    const size_t curr_head = head_.load(std::memory_order::relaxed);
    const size_t curr_tail = tail_.load(std::memory_order::acquire);
        
    if (IsEmpty(curr_head, curr_tail)) {
        return false;
    }
        
    value = std::move(buffer_[curr_head]);
    head_.store(Next(curr_head), std::memory_order::release);
    return true;
}

每个原子的内存顺序是否正确?我对每个原子变量中每个内存顺序的使用的解释是对的吗?

【问题讨论】:

  • 我不认为没有获取的发布有任何影响。
  • @ShamilMukhetdinov 这比对原子的释放操作要强。见here
  • @ShamilMukhetdinov 但这只能在同一原子上执行获取操作的线程中观察到(至少在 C++ 内存模型中)。
  • @ShamilMukhetdinov 线程不需要同意操作发生的顺序,据我所知,只有发布/获取连接建立这样的协议。是的,这就是标准中的 synchronizes-with 属性。除了发布/获取之间的 synchronizes-with 排序之外,我找不到 release 订单对原子存储的任何其他影响。
  • @ShamilMukhetdinov 我说的是std::atomic 释放/获取,即与内存位置相关联的那个。我不是在谈论释放围栏,即与内存位置无关的释放围栏。如果您的意思是 OP 可以使用发布围栏而不是 head store-release,那么这可能是正确的(我没有考虑过细节。)

标签: c++ multithreading memory-barriers lock-free stdatomic


【解决方案1】:

以前的答案可能会有所帮助:
c++, std::atomic, what is std::memory_order and how to use them?
https://bartoszmilewski.com/2008/12/01/c-atomics-and-memory-ordering/

首先,您描述的系统称为单一生产者 - 单一消费者队列。您可以随时查看此container 的增强版本进行比较。我经常会检查 boost 代码,即使我在不​​允许 boost 的情况下工作。这是因为检查和理解一个稳定的解决方案会让您深入了解您可能遇到的问题(他们为什么这样做?哦,我明白了 - 等等)。 鉴于您的设计,并编写了许多类似的容器,我会说您的设计必须小心区分空与满。如果您使用经典的 {begin,end} 对,您会遇到由于包装引起的问题

{开始,开始+大小} == {开始,开始} == 空

好的,那么回到同步问题。

鉴于顺序仅影响重新排序,在 Publish 中使用 release 似乎是教科书式的标志使用。在容器的大小增加之前,什么都不会读取该值,因此您不关心值本身的写入顺序是否以随机顺序发生,您只关心在计数增加之前必须完全写入该值.所以我同意,您在发布功能中正确使用了标志。
我确实质疑消费中是否需要“释放”,但如果您要退出队列,并且这些移动具有副作用,则可能需要。我想说,如果你追求原始速度,那么可能值得制作第二个版本,专门用于 trivial objects,它使用宽松的顺序来增加头部。

您还可以考虑在推送/弹出时就地新建/删除。虽然大多数移动会使对象处于空状态,但标准只要求它在移动后保持有效状态。移动后显式删除对象可能会避免您以后出现晦涩的错误。

您可能会争辩说,consume 中的两个原子负载可能是 memory_order_consume。这放宽了约束,说“我不在乎它们的加载顺序,只要它们在使用时都被加载”。尽管我怀疑在实践中它会产生任何收益。我也对这个建议感到紧张,因为当我查看增强版本时,它与您所拥有的非常接近。 https://www.boost.org/doc/libs/1_66_0/boost/lockfree/spsc_queue.hpp

 template <typename Functor>
    bool consume_one(Functor & functor, T * buffer, size_t max_size)
    {
        const size_t write_index = write_index_.load(memory_order_acquire);
        const size_t read_index  = read_index_.load(memory_order_relaxed);
        if ( empty(write_index, read_index) )
            return false;

        T & object_to_consume = buffer[read_index];
        functor( object_to_consume );
        object_to_consume.~T();

        size_t next = next_index(read_index, max_size);
        read_index_.store(next, memory_order_release);
        return true;
    }

一般来说,尽管您的方法看起来不错并且与 boost 版本非常相似。但是......如果我是你,我可能会逐行浏览 boost 版本,看看它有什么不同。很容易出错。

编辑: 抱歉,我刚刚注意到您的代码中的 memory_order_acquire/memory_order_relaxed 标志的顺序错误。 你应该让最后一个写一个“发布”,第一个读“获取”。这不会显着影响行为,但它更容易阅读。 (开始同步,结束同步)

回复 cmets: 正如@user17732522所暗示的,复制操作也是写,所以对琐碎对象的优化不会同步,对琐碎对象的优化会引入U/B(妈的容易出错)

【讨论】:

  • 我认为移动的对象是否微不足道并不重要。如果写/写操作对竞争,那么写/读操作对也会竞争并且都是UB。
  • 发布顺序标志只影响对内存的写入。一个平凡的对象在被移动(复制)时不会改变,因此在写入计数之前没有写操作可以确保完成。也就是说,这将是一个脆弱的变化,因此需要编译器强制执行它(恕我直言)
  • 没有再三考虑我很厚。副本本身就是一个写操作。多哈。我会将其添加到答案中
  • Consume 中的存储必须是release,因为它就像解锁:一旦作者看到新值,它就有可能将内容存储到该条目中。在阅读条目完成之前,我们不能允许这种情况发生,因此release,无论副作用如何。
  • memory_order_consume 不是“我不在乎它们的加载顺序,只要它们在使用时都已加载”。这意味着依赖于所加载值的进一步加载必须在消耗加载之后发生,例如 buffer_[curr_head]curr_head = head_.load(consume) 之后排序,但是像 tail_.load(relaxed) 这样的其他加载/存储不需要之前完成来自缓冲区的负载。 (推测性执行可以使这种情况发生,即使有一个空的涉及尾部的检查。)
【解决方案2】:

为了理解这个问题的正确std::memory_order,让我们考虑一下是否只有一个线程而不是生产者和消费者线程。
在单线程场景中,bool Publish(T value) 将看到前一个 bool Consume(T&amp; value) 以相同顺序执行的所有操作,同样bool Consume(T&amp; value) 将看到前一个 bool Publish(T value) 以相同顺序执行的所有操作。
所以在多线程场景中,发布者和消费者线程必须以类似的方式同步。可以通过memory barriers实现同步。

消费函数release原子存储head_.store(Next(curr_head), std::memory_order::release);保证在store之前发出的操作将在它之前执行并且不会越过它,并且发布可以通过head_变量的acquire原子加载来同步,并且保证如果发布可以看到head_它会看到它之前执行的所有操作,

bool Publish(T value) {
    const size_t curr_head = head_.load(std::memory_order::acquire);
    const size_t curr_tail = tail_.load(std::memory_order::relaxed);
        
    if (IsFull(curr_head, curr_tail)) {
        return false;
    }
        
    buffer_[curr_tail] = std::move(value);
    tail_.store(Next(curr_tail), std::memory_order::release);
    return true;
}

类似地,Consume 可以与 Publish release atomic store tail_.store(Next(curr_tail), std::memory_order::release); by acquire atomic load of tail_ 变量同步,如果consume 可以看到tail_,则可以保证看到在它之前执行的所有操作。

bool Consume(T& value) {
    const size_t curr_tail = tail_.load(std::memory_order::acquire);
    const size_t curr_head = head_.load(std::memory_order::relaxed);
        
    if (IsEmpty(curr_head, curr_tail)) {
        return false;
    }
        
    value = std::move(buffer_[curr_head]);
    head_.store(Next(curr_head), std::memory_order::release);
    return true;
}

【讨论】:

  • 我明白你的意思,但问题是读取不像写入那样有副作用。所以,我认为没有必要让一个写可以看到一个读,因为第二个没有做任何事情让第一个看到它。我们必须考虑的主要方面是由于存储操作和缓冲区读取操作的重新排序,Consume 函数中可能发生的数据竞争。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-07-19
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多