【问题标题】:Multiple Producer Multiple Consumer Lockfree Non Blocking Ring Buffer With Variable Length Write具有可变长度写入的多个生产者多个消费者无锁非阻塞环形缓冲区
【发布时间】:2018-08-17 04:41:43
【问题描述】:

我想将可变长度的消息从多个生产者传递给多个消费者,在多插槽 Xeon E5 系统上使用低延迟队列。 (例如,延迟 300 ns 的 400 字节会很好。)

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

当然,可以争辩说节点类型可以设置为uint8_t或类似的,但是这样写会很笨拙,性能会很糟糕。

我还希望算法能够在读取器端提供覆盖检测,以便读取器检测到被覆盖的数据。

我怎样才能实现一个队列(或其他东西)来做到这一点?

【问题讨论】:

  • @close voters:我将这个问题改写为不是图书馆请求。以前是这样写的,但是底层的算法设计问题很有趣。

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


【解决方案1】:

抱歉,回复晚了,但请查看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 数据包。它不符合您的预算,但您需要做的工作要少得多...

【讨论】:

    【解决方案2】:

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

    释放/获取语义负责确保在取消引用从队列中获得的指针时数据存在。但是你有一个释放问题:生产者如何知道消费者何时使用缓冲区完成以便它可以重用它?

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


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

    它有一个读索引和一个写索引,每个环形缓冲区节点都有一个序列计数器,可以检测写者赶上读者(队列已满)与读者赶上写者(队列空),不会引起读者和作者之间的争论。 (IIRC,读取器读取写入索引,反之亦然,但没有共享数据被读取器和写入器修改。)


    如果缓冲区大小有合理的上限,您可以共享与环形缓冲区中的每个节点关联的固定大小缓冲区。可能是 1kiB 或 4kiB。那么你就不需要环形缓冲区中的有效负载了;索引会很有趣。

    如果内存分配占用量不是很大(仅缓存占用量),即使您通常只使用每个缓冲区的低 400 字节,即使 64k 或 1M 缓冲区也基本没问题。未使用的缓冲区部分将在缓存中保持冷态。如果您正在使用 2MiB 的大页面,那么小于该值的缓冲区是降低 TLB 压力的好主意:您希望多个缓冲区被同一个 TLB 条目覆盖。

    但是您需要在写入之前声明一个缓冲区,并在完成向队列添加条目的第二步之前完成写入。您可能不想做的不仅仅是memcpy,因为如果它在完成之前成为队列中最旧的条目,则部分完成的写入会阻塞读取器。也许您可以写入预取缓冲区(在 Broadwell 或更新版本上使用 prefetchw) 在尝试声明它之前,以减少您(可能)阻塞队列之间的时间。但是,如果对作家的竞争不高,那可能并不重要。如果存在高争用,因此您(几乎)不总是成功地声明您尝试的第一个缓冲区,那么对错误缓冲区的写入预取将减慢拥有它的读取器或写入器的速度。也许普通的预取会很好。

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

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

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


    读取器中途声明缓冲区不会阻塞其他读取器它只会阻塞队列,如果它环绕并且生产者被卡住等待它.所以你绝对可以让你的读者在队列中就地使用数据,如果它足够大并且读者很快的话。但是在部分完成读取期间执行的操作越多,您睡眠并最终阻塞队列的可能性就越大。

    这对生产者来说意义重大,尤其是在队列通常(几乎)为空的情况下:消费者几乎在新写入的条目生成后就会出现。这就是为什么您可能要确保在运行生产者之前预取要复制的数据和/或共享缓冲区本身。


    400 字节只有 12.5 个周期,每个时钟将 32 字节提交到 L1d 缓存(例如 Intel Haswell / Skylake),因此与内核间延迟相比真的短或者您必须在缓存写入未命中时等待 RFO 的时间。因此,生产者声明节点的声明全局可见到您完成声明以便读者可以阅读它(以及以后的条目)之间的最短时间仍然很短。长时间阻塞队列希望可以避免。

    这么多数据甚至适合 YMM 13 寄存器,因此编译器理论上可以在声明缓冲区条目之前将数据实际加载到寄存器中,然后进行存储。您可以使用内部函数手动执行此操作,并使用完全展开的循环。 (你不能索引寄存器文件,所以它必须完全展开,或者总是存储 408 个字节,或者其他什么。)

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

    【讨论】:

    • 感谢您的回复。很多信息要消化。所以我首先回应你的最后一部分——是的,在不到 300 纳米的时间内传递 400 个字节非常短/快。因此,我认为阻塞队列甚至不应该发生(假设 x86 中 CAS 指令的锁定前缀不算阻塞。但是,它确实阻塞了)。我的客户有一个实现这种延迟的现有代码,但由于 IP,我不能在这里发布。但是代码中有一个“错误”,读者无法真正检测到缓冲区被覆盖。这就是我提出这个问题的原因。让我读一下你剩下的答案。谢谢!
    • 只是对我客户代码的延迟声明的澄清——当有更多的读者时,整体的读写延迟会上升(例如,当有 6 个读者时上升 100%但是有 1 个作者在 linux 中使用 SHM;这对我来说仍然是个谜,因为读者没有使用基于 objdump 的带有栅栏或锁定前缀的指令)。只是为了公平。
    • @HCSF:300ns 是 3GHz CPU 上的 900 个时钟周期。对于非核心而言,这相当快,但在单个核心内它已经很长时间了。 Bandwidth 博士在旧的 Sandybridge-Xeon 系统上测量了 240 ns 的插槽间延迟,用于简单地对单个缓存线进行乒乓操作。 (software.intel.com/en-us/forums/…)。对于您的小型固定大小缓冲区,延迟可能相似,因为多个缓存行可以同时运行。 使用绑定到节点的缓冲区而不是在节点中放置 指针 意味着这些负载不必等待地址。
    • 在单芯片内,四核桌面的内核间延迟可以低至 50ns,或单个物理内核的超线程之间的 16ns。多核 Xeon 更高,因为 the ring bus has more hops。 SiSoft 展示了一些内核间延迟基准 (sisoftware.co.uk/2017/06/24/…),包括 80ns 的 10 核 Skylake i9-7900X。 (不过,这不是多套接字系统的一部分,这可能很重要,因为它可能必须窥探另一个套接字。)
    • 您是否有示例代码来说明“使用绑定到节点的缓冲区而不是在节点中放置指针意味着这些负载不必等待地址”?也许我误解了——我假设每个节点都会有类似 uint8_t data[NODE_SIZE] 的东西,然后当阅读器加载时,它仍然需要执行类似 data[0] 的操作,它本质上是尊重并获取地址?我一定是误会了。
    猜你喜欢
    • 2017-04-07
    • 2019-01-17
    • 1970-01-01
    • 1970-01-01
    • 2019-06-13
    • 1970-01-01
    • 1970-01-01
    • 2014-02-20
    • 1970-01-01
    相关资源
    最近更新 更多