具有可变长度写的多生产者多消费者无锁无阻塞环形缓冲区

时间:2018-08-17 04:41:43

标签: c++ linux x86 lock-free circular-buffer

我想将可变长度消息从多个生产者传递到多个消费者,并且在多路至强E5系统上具有低延迟队列。 (例如,延迟为300 ns的400字节会很好。)

我一直在寻找使用无阻塞环形缓冲区的无锁多生产者多消费者(MPMC)队列的现有实现。但是大多数在线的实现/算法都是基于节点的(即,节点是固定长度的),例如boost::lockfree::queuemidishare等。

当然,可以说节点类型可以设置为uint8_t之类,但是这样写就很笨拙,性能也很糟糕。

我还希望该算法在读者方面提供覆盖检测,以使读者能够检测到被覆盖的数据。

我该如何实现执行此操作的队列(或其他方法)?

2 个答案:

答案 0 :(得分:2)

很抱歉,答案很晚,但是请查看DPDK's Ring library。它是免费的(BSD许可证),速度非常快(毫无疑问,您会免费找到更快的解决方案),并且支持所有主要架构。还有很多例子。

  

传递可变长度消息

解决方案是将指针传递给消息,而不是整个消息。 DPDK还提供memory pools library来在多个线程或进程之间分配/取消分配缓冲区。内存池还快速,无锁,并支持许多体系结构。

因此整体解决方案是:

  1. 创建内存池以在线程/进程之间共享缓冲区。每个内存池仅支持固定大小的缓冲区,因此您可能需要创建几个内存池来满足您的需求。

  2. 在您的线程/进程之间创建一个MPMC环或一组SPSC环对。 SPSC解决方案可能更快,但可能不适合您的设计。

  3. 生产者分配一个缓冲区,将其填充并通过环将指针传递到该缓冲区。

  4. 消费者接收指针,读取消息并释放缓冲区。

听起来很麻烦,但是DPDK内存池和铃声中有很多优化。但是适合300ns吗?

看看官方的DPDK performance reports。虽然没有有关振铃性能的官方报告,但有一个vhost / vistio测试结果。基本上,数据包是这样传输的:

Traffic gen. -- Host -- Virtual Machine -- Host -- Traffic gen.

主机作为一个进程运行,虚拟机作为另一个进程。

对于512字节数据包,测试结果为每秒约4M数据包。它不适合您的预算,但是您需要做的工作少得多……

答案 1 :(得分:1)

您可能希望将指针放入队列中,而不是实际将数据复制到共享环本身中或从共享环本身中复制数据。即环形缓冲区的有效负载只是一个指针。

释放/获取语义会确保在取消引用从队列中获取的指针时确保数据在那里。但是然后您会遇到一个释放问题:生产者如何知道何时使用缓冲区完成使用方的消费,以便它可以重用它?

如果可以移交缓冲区的所有权,那就可以了。消费者也许可以将缓冲区用于其他用途,例如将其添加到本地空闲列表中,或者将其用于产生的缓冲区。


有关以下内容,请参阅Lock-free Progress Guarantees中分析的基于环形缓冲区的无锁MPMC队列。我正在想对它进行修改以使其适合您的目的。

它具有一个读索引和一个写索引,并且每个环形缓冲区节点都有一个序列计数器,通过它可以检测作家追赶读者(队列已满)和读者追赶作家(队列为空),而不会引起读者和作家之间的争执。 (IIRC,读者可以 read 写入索引,反之亦然,但是没有共享数据会被读写器修改。)


如果缓冲区大小有合理的上限,则可以与环形缓冲区中的每个节点关联共享固定大小的缓冲区。就像1kiB或4kiB。这样一来,环形缓冲区中就不需要有效负载了。索引会很有趣。

如果内存分配占用空间不大(仅缓存占用空间),那么即使您通常只使用每个缓冲区的低400字节,即使64k或1M缓冲区也可以。缓冲区中未被使用的部分只会在缓存中保持冷态。如果您使用的是2MiB大页面,那么减小缓冲区大小是减少TLB压力的一个好主意:您希望多个缓冲区被同一个TLB条目覆盖。

但是您需要在写入缓冲区之前声明一个缓冲区,并在完成向队列添加条目的第二步之前完成写入。您可能不希望只做memcpy,因为部分完成的写操作会在读完之前成为队列中最老的条目,从而阻止读者。预取缓冲区(在Broadwell或更高版本上使用prefetchw)  在尝试声明它之前,可以减少(可能)阻塞队列之间的时间。但是,如果作家争执程度低,那可能没关系。而且,如果争用程度很高,那么您(几乎)总是无法成功声明您尝试的第一个缓冲区,那么在错误的缓冲区上进行写预取将减慢拥有该缓冲区的读取器或写入器的速度。正常的预取可能会很好。

如果将缓冲区直接绑定到队列条目,则只要MPMC库允许您使用自定义的读取器代码,就可以将它们放入队列。读取一个长度并复制出那么多字节,而不是总是复制整个巨型数组。

然后,生产者/使用者查看的每个队列控制条目都将位于单独的缓存行中,因此在声明相邻条目的两个生产者之间没有竞争。

如果由于上限(例如1MiB或类似的东西)而需要非常大的缓冲区,则由于争用而重试将导致接触更多的TLB条目,因此将大缓冲区分开的更紧凑的环形缓冲区可能是一个更好的主意。 >


读者在声明缓冲区的过程中不会阻止其他 readers 。它仅在队列环绕并且生产者被困在等待队列时才阻塞队列。 。因此,如果数据足够大且读者很快,那么您绝对可以让您的读者在队列中就地使用数据。但是,在部分完成读取期间执行的操作越多,您入睡并最终阻塞队列的机会就越大。

对于生产者而言,这是一笔大得多的交易,尤其是如果队列通常(几乎)是空的:消费者几乎在生产时就提出新编写的条目。这就是为什么在运行生产程序之前,您可能要确保预取要复制到的数据和/或共享缓冲区本身的原因。


400字节仅是12.5个周期,即每个时钟向L1d缓存提交32个字节(例如Intel Haswell / Skylake),因此与内核间延迟相比,它确实确实短或您必须在缓存写未命中等待RFO的时间。因此,从生产者使节点的声明对全局可见到您完成该声明以使读者可以读取它(以及以后的条目)之间的最短时间仍然很短。希望是可以避免的。

足够多的数据甚至容纳在YMM 13寄存器中,因此理论上编译器可以在声明缓冲区条目之前将数据加载到寄存器中,然后进行存储。您也许可以使用内部函数通过完全展开的循环手动执行此操作。 (您无法索引寄存器文件,因此必须完全展开该寄存器文件,或始终存储408字节或其他内容。)

或者7个ZMM寄存器使用AVX512,但是如果不使用其他512位指令,则可能不希望使用512位加载/存储,因为这会影响最大涡轮时钟速度和关闭矢量ALU主机的端口1。 (我认为矢量加载/存储仍然会发生这种情况,但是如果幸运的话,其中一些影响只会在512位ALU微指令下发生...)