我需要非常关注当前多线程项目中的速度/延迟。
缓存访问是我试图更好地理解的东西。而且我还不清楚无锁队列(例如boost :: lockfree :: spsc_queue)如何在缓存级别访问/使用内存。
我已经看过使用过的队列,其中消费者核心需要操作的大对象的指针被推入队列。
如果消费者核心从队列中弹出一个元素,我认为这意味着元素(在这种情况下是一个指针)已经加载到消费者核心的L2和L1缓存中。但是要访问元素,是不是需要通过从L3缓存或互连中查找和加载元素来访问指针本身(如果另一个线程位于不同的cpu套接字上)?如果是这样,简单地发送一个可由消费者处理的对象副本可能会更好吗?
谢谢。
答案 0 :(得分:9)
C ++主要是按需购买生态系统。
任何常规队列都会让你选择存储语义(按值或按引用)。
但是,这次你订购了一些特别的东西:你订购了一个无锁队列。 为了无锁,它必须能够执行所有可观察的修改操作作为原子操作。这自然地限制了可以直接在这些操作中使用的类型。
您可能会怀疑是否有可能超出系统本机寄存器大小的值类型(例如,int64_t
)。
好问题。
实际上,任何基于节点的容器都只需要指针交换用于所有修改操作,这在所有现代架构中都是原子的。 但是,在非原子序列中涉及复制多个不同内存区域的任何事情是否真的会造成无法解决的问题?
没有。想象一下POD数据项的平面数组。现在,如果将数组视为循环缓冲区,则只需要以原子方式维护缓冲区前端和末尾位置的索引。容器可以在内部'脏前端索引'中随时更新,同时在外部前面复制。 (副本可以使用轻松的内存排序)。只有在知道整个副本完成后,才会更新外部前端索引。此更新需要在acq_rel / cst内存顺序 [1] 。
只要容器能够保护front
从未完全包裹并到达back
的不变量,这是一个很好的协议。我认为这个想法在Disruptor Library(LMAX成名)中得到了普及。你可以从
spsc_queue
如何实际执行此操作?是的,spqc_queue将原始元素值存储在连续的对齐内存块中:(例如来自compile_time_sized_ringbuffer
,它是spsc_queue
的基础,具有静态提供的最大容量:)
typedef typename boost::aligned_storage<max_size * sizeof(T),
boost::alignment_of<T>::value
>::type storage_type;
storage_type storage_;
T * data()
{
return static_cast<T*>(storage_.address());
}
(元素类型T
甚至不需要POD,但它必须是默认构造和可复制的。)
是的,读写指针是原子积分值。请注意,boost devs已经注意应用足够的填充来避免读/写索引的缓存行上的 False Sharing :(来自ringbuffer_base
):
static const int padding_size = BOOST_LOCKFREE_CACHELINE_BYTES - sizeof(size_t);
atomic<size_t> write_index_;
char padding1[padding_size]; /* force read_index and write_index to different cache lines */
atomic<size_t> read_index_;
事实上,正如您所看到的,在读取或写入方面只有“内部”索引。这是可能的,因为只有一个写入线程,也只有一个读取线程,这意味着在写入操作结束时只有更多空间而不是预期。
存在其他一些优化:
unlikely()
)总而言之,我们看到了一个关于环形缓冲区的最佳可能想法
Boost为您提供了所有选择。您可以选择使您的元素类型成为指向您的消息类型的指针。但是,正如您在问题中提到的那样,这种间接性会降低参考的局部性,可能不是最佳的。
另一方面,如果复制费用昂贵,则将完整的消息类型存储在元素类型中会变得昂贵。至少尝试使元素类型很好地适应缓存行(通常在Intel上为64字节)。
因此在实践中,您可能会考虑将常用数据存储在值中,并使用指针引用较少使用的数据(指针的成本将低,除非它被遍历)。
如果您需要“附件”模型,请考虑为引用数据使用自定义分配器,以便您也可以在那里实现内存访问模式。
[1] 我想说spsc acq_rel应该可行,但我对细节有点生疏。作为一项规则,我强调不要自己编写无锁代码。我推荐其他人跟随我的例子:)