无锁定的MPMC环形缓冲器故障

时间:2014-07-29 13:03:28

标签: multithreading c++11 atomic lockless

我一直在一个无锁的多个生产者多个消费者环形缓冲区上反对(我的尝试)。这个想法的基础是使用unsigned char和unsigned short类型的固有溢出,将元素缓冲区修复为这些类型之一,然后你有一个自由循环回到环形缓冲区的开头。

问题是 - 我的解决方案并不适用于多个生产者(虽然它适用于N个消费者,也适用于单个生产者单个消费者)。

#include <atomic>

template<typename Element, typename Index = unsigned char> struct RingBuffer
{
  std::atomic<Index> readIndex;
  std::atomic<Index> writeIndex;
  std::atomic<Index> scratchIndex;
  Element elements[1 << (sizeof(Index) * 8)];

  RingBuffer() :
    readIndex(0),
    writeIndex(0),
    scratchIndex(0)
  {
    ;
  }

  bool push(const Element & element)
  {
    while(true)
    {
      const Index currentReadIndex = readIndex.load();
      Index currentWriteIndex = writeIndex.load();
      const Index nextWriteIndex = currentWriteIndex + 1;
      if(nextWriteIndex == currentReadIndex)
      {
        return false;
      }

      if(scratchIndex.compare_exchange_strong(
        currentWriteIndex, nextWriteIndex))
      {
        elements[currentWriteIndex] = element;
        writeIndex = nextWriteIndex;
        return true;
      }
    }
  }

  bool pop(Element & element)
  {
    Index currentReadIndex = readIndex.load();

    while(true)
    {
      const Index currentWriteIndex = writeIndex.load();
      const Index nextReadIndex = currentReadIndex + 1;
      if(currentReadIndex == currentWriteIndex)
      {
        return false;
      }

      element = elements[currentReadIndex];

      if(readIndex.compare_exchange_strong(
        currentReadIndex, nextReadIndex))
      {
        return true;
      }
    }
  }
};

写作的主要思想是使用临时索引&#39; scratchIndex&#39;在更新writeIndex并允许任何其他生产者取得进展之前,它执行伪锁定以允许任何一次只有一个生产者复制构造到元素缓冲区。在我被称为异教徒之前暗示我的方法是无锁定的#39;我意识到这种方法并非完全无锁,但在实践中(如果它可以工作!)它明显快于拥有普通的互斥锁!

我知道一个(更复杂的)MPMC环形缓冲解决方案http://www.1024cores.net/home/lock-free-algorithms/queues/bounded-mpmc-queue,但我正在尝试我的想法,然后与这种方法进行比较,找出每个方法的优势(或者实际上我的方法是否平坦失败!)。

我尝试过的事情;

  • 使用compare_exchange_weak
  • 使用与我想要的行为匹配的更精确的std :: memory_order
  • 在我拥有的各种索引之间添加缓存行垫
  • 使元素std :: atomic而不仅仅是Element数组

我确信这可以归结为我脑子里的一个基本的断句,即如何使用原子访问来使用互斥量,我会完全感谢能指出哪些神经元严重失误的人在我的脑子里! :)

2 个答案:

答案 0 :(得分:2)

这是A-B-A problem的一种形式。一个成功的制作人看起来像这样:

  1. 加载currentReadIndex
  2. 加载currentWriteIndex
  3. cmpxchg store scratchIndex = nextWriteIndex
  4. 商店element
  5. 商店writeIndex = nextWriteIndex
  6. 如果生产者在步骤2和3之间由于某种原因拖延了足够长的时间,那么其他生产者可能会生成一个完整队列的数据并回绕到完全相同的索引,以便步骤3中的compare-exchange成功(因为scratchIndex恰好等于currentWriteIndex)。

    就其本身而言,这不是一个问题。停滞不前的生产者完全有权增加scratchIndex来锁定队列 - 即使一个神奇的ABA检测cmpxchg拒绝了商店,生产者只会再次尝试,重新加载完全相同的currentWriteIndex,并且正常进行。

    实际问题是步骤2和3之间的nextWriteIndex == currentReadIndex检查。如果currentReadIndex == currentWriteIndex,队列在逻辑上是空的,所以这个检查是存在的,以确保没有生产者如此先于它覆盖元素没有消费者已经弹出。在顶部进行一次检查似乎是安全的,因为所有的消费者都应该被困在#34;在观察到的currentReadIndex和观察到的currentWriteIndex之间。

    除了另一个制作人可以出现并提升writeIndex,这将使消费者摆脱陷阱。如果制作人在第2步和第3步之间停顿,当它被唤醒时,readIndex的存储值绝对可能是任何东西。

    这是一个以空队列开头的示例,它显示了发生的问题:

    1. 生产者A 运行步骤1和2.两个加载的索引都为0.队列为空。
    2. 制作人B 会中断并生成元素。
    3. 消费者会弹出一个元素。两个指数都是1。
    4. 制作人B 会产生255个元素。写索引回绕到0,读索引仍为1.
    5. 制片人A 从沉睡中醒来。它先前已将读取和写入索引都加载为0(空队列!),因此它尝试执行步骤3.因为另一个生成器在索引0上偶然暂停,所以比较交换成功,并且存储进行。在完成时,生产者允许writeIndex = 1,现在两个存储的索引都是1,并且队列在逻辑上是空的。现在将完全忽略完整队列的元素。
    6. (我应该提一下,我可以谈论&#34;拖延&#34;以及&#34;醒来&#34;唯一的原因是所使用的所有原子都是顺序一致的,所以我可以假装我们处于单线程环境中。)


      请注意,使用scratchIndex来保护并发写入的方式本质上是一个锁;无论谁成功完成cmpxchg,都会获得对队列的总写入权限,直到它释放锁定为止。解决此失败的最简单方法是用螺旋锁替换scratchIndex - 它不会受到A-B-A的影响,而且它实际上发生了什么。

答案 1 :(得分:1)

 bool push(const Element & element)
  {
    while(true)
    {
      const Index currentReadIndex = readIndex.load();
      Index currentWriteIndex = writeIndex.load();
      const Index nextWriteIndex = currentWriteIndex + 1;
      if(nextWriteIndex == currentReadIndex)
      {
        return false;
      }

      if(scratchIndex.compare_exchange_strong(
        currentWriteIndex, nextWriteIndex))
      {
        elements[currentWriteIndex] = element;
        // Problem here!
        writeIndex = nextWriteIndex;
        return true;
      }
    }
  }

我已经标出了有问题的地方。多个线程可以同时到达writeIndex = nextWriteIndex。数据将以任何顺序写入,尽管每次写入都是原子的。

这是一个问题,因为您尝试使用相同的原子条件更新两个值,这通常是不可能的。假设你的方法的其余部分没问题,那么解决这个问题的方法是将scratchIndex和writeIndex合并为一个double-size值。例如,将两个uint32_t值作为单个uint64_t值处理并在其上进行原子操作。