您可能希望将指针放入队列中,而不是实际将数据复制到共享环本身/从中复制出来。即环形缓冲区有效负载只是一个指针。
释放/获取语义负责确保在取消引用从队列中获得的指针时数据存在。但是你有一个释放问题:生产者如何知道消费者何时使用缓冲区完成以便它可以重用它?
如果可以移交缓冲区的所有权,那就没问题了。也许消费者可以将缓冲区用于其他用途,例如将其添加到本地空闲列表中,或者将其用于生成的东西。
有关以下内容,请参阅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 微指令上......)