Ring Allocator用于Lockfree更新成员变量?

时间:2016-08-28 19:59:12

标签: c++ performance data-structures dynamic-memory-allocation lock-free

我有一个类存储一些传入的实时数据的最新值(大约1.5亿个事件/秒)。

假设它看起来像这样:

class DataState 
{
    Event latest_event;

  public:
  //pushes event atomically
  void push_event(const Event __restrict__* e);
  //pulls event atomically
  Event pull_event();
};

我需要能够以原子方式推送事件并使用严格的排序保证来拉动它们。现在,我知道我可以使用自旋锁,但鉴于大量事件率(超过1亿/秒)和高度并发性,我更喜欢使用无锁操作。

问题是Event的大小为64字节。任何当前的X86 CPU都没有CMPXCHG64B指令(截至8月&#39; 16)。因此,如果我使用std::atomic<Event>我必须链接到使用互联网下的互斥量的libatomic(太慢)。

所以我的解决方案是以原子方式交换指向值的指针。问题是动态内存分配成为这些事件率的瓶颈。所以...我定义了一个我称之为&#34; ring allocator&#34;:

的东西
/// @brief Lockfree Static short-lived allocator used for a ringbuffer
/// Elements are guaranteed to persist only for "size" calls to get_next()
template<typename T> class RingAllocator {
  T *arena;
  std::atomic_size_t arena_idx;
  const std::size_t arena_size;
 public:
  /// @brief Creates a new RingAllocator
  /// @param size The number of elements in the underlying arena. Make this large enough to avoid overwriting fresh data
  RingAllocator<T>(std::size_t size) : arena_size(size)
  {
  //allocate pool
  arena = new T[size];
  //zero out pool
  std::memset(arena, 0, sizeof(T) * size);
  arena_idx = 0;
  }

  ~RingAllocator()
  {
  delete[] arena;
  }

  /// @brief Return next element's pointer. Thread-safe
  /// @return pointer to next available element
  T *get_next()
  {
      return &arena[arena_idx.exchange(arena_idx++ % arena_size)];
  }
};

然后我可以让我的DataState类看起来像这样:

class DataState 
{
    std::atomic<Event*> latest_event;
    RingAllocator<Event> event_allocator;
  public:
  //pushes event atomically
  void push_event(const Event __restrict__* e)
  {
      //store event
      Event *new_ptr = event_allocator.get_next()
      *new_ptr = *e;
      //swap event pointers
      latest_event.store(new_ptr, std::memory_order_release);
  }
  //pulls event atomically
  Event pull_event()
  {
      return *(latest_event.load(std::memory_order_acquire));
  }
};

只要将我的ring分配器调整为可以同时调用函数的最大线程数,就不会有覆盖pull_event可能返回的数据的风​​险。除了一切超级本地化,所以间接不会导致错误的缓存性能。这种方法有任何可能的陷阱吗?

3 个答案:

答案 0 :(得分:2)

DataState类:

我认为这将是一个堆栈或队列,但它不是,所以push / pull似乎不是方法的好名字。 (否则实施完全是假的)。

它只是一个锁存器,可以让你读取任何线程存储的最后一个事件。

没有什么可以阻止连续两次写入覆盖一个从未被读过的元素。也没有什么可以阻止你两次阅读相同的元素。

如果你只是需要某个地方来复制小块数据,那么环形缓冲区似乎是一种不错的方法。但如果你不想丢失事件,我不认为你可以这样使用它。相反,只需获取一个环形缓冲区条目,然后复制到它并在那里使用它。所以唯一的原子操作应该是增加环形缓冲区位置索引。

环形缓冲区

您可以提高get_next()效率。该行执行原子后增量(fetch_add)和原子交换:

return &arena[arena_idx.exchange(arena_idx++ % arena_size)];

我甚至不确定它是否安全,因为xchg可以从另一个线程踩到fetch_add。无论如何,即使它安全,它也不理想。

你不需要那个。确保arena_size始终是2的幂,然后您不需要模拟共享计数器。你可以放手,并让每个线程模块化以供自己使用。它最终会换行,但它是一个二进制整数,所以它会以2的幂为单位,这是你竞技场大小的倍数。

我建议存储AND掩码而不是大小,因此%编译除and指令以外的任何内容都没有风险,即使它& #39;不是编译时常量。这可以确保我们避免使用64位整数div指令。

template<typename T> class RingAllocator {
  T *arena;
  std::atomic_size_t arena_idx;
  const std::size_t size_mask;   // maybe even make this a template parameter?
 public:
  RingAllocator<T>(std::size_t size) 
    : arena_idx(0),  size_mask(size-1)
  {
     // verify that size is actually a power of two, so the mask is all-ones in the low bits, and all-zeros in the high bits.
     // so that i % size == i & size_mask for all i
   ...
  }

  ...
  T *get_next() {
      size_t idx = arena_idx.fetch_add(1, std::memory_order_relaxed);  // still atomic, but we don't care which order different threads take blocks in
      idx &= size_mask;   // modulo our local copy of the idx
      return &arena[idx];
  }
};

如果您使用calloc 而不是新的+ memset,则分配竞技场会更有效率。操作系统在将页面提供给用户空间进程之前已经将页面归零(以防止信息泄漏),因此编写它们只是浪费了工作。

  arena = new T[size];
  std::memset(arena, 0, sizeof(T) * size);

  // vs.

  arena = (T*)calloc(size, sizeof(T));

自己编写页面会对它们造成错误,因此它们全部连接到真实的物理页面,而不仅仅是系统范围的共享物理零页面的写时复制映射(就像它们在新的/之后)的malloc /释放calloc)。在NUMA系统上,所选择的物理页面可能取决于实际触及页面的线程,而不是哪个线程进行了分配。但是,由于您重新使用该池,因此编写页面的第一个核心可能不是最终使用它的核心。

可能需要在microbenchmarks / perf计数器中查找。

答案 1 :(得分:1)

  

只要将我的ring分配器调整为可以同时调用函数的最大线程数,就不会有覆盖pull_event可能返回的数据的风​​险。 ....这种方法有任何可能的陷阱吗?

陷入困境的是,IIUC,你的陈述是错误的。

如果我只有2个线程,并且在环形缓冲区中有10个元素,则第一个线程可以调用pull_event一次,然后是#34; mid-pulling&#34;,然后第二个线程可以调用push 10次,覆盖线程1正在拉动的东西。

再次,假设我正确理解您的代码。

另外,如上所述,

return &arena[arena_idx.exchange(arena_idx++ % arena_size)];

同一个变量交换中的arena_idx++只是看起来不对。事实上是错误的。两个线程可以递增它 - ThreadA递增到8并且threadB递增到9,然后threadB将它交换为9,然后threadA将它交换为8. whoops。

atomic(op1)@ atomic(op2)!= atomic(op1 @ op2)

我担心未显示的代码还有什么问题。我并不是说侮辱 - 无锁是不容易的。

答案 2 :(得分:0)

您是否查看了可用的任何C ++ Disruptor(Java)端口?

disruptor--

disruptor

虽然它们不是完整的端口,但它们可能提供您所需要的一切。我目前正在开发一个功能更全面的端口,但它还没有完全准备好。