【问题标题】:boost lockfree spsc_queue cache memory access提升无锁 spsc_queue 缓存内存访问
【发布时间】:2014-10-23 17:57:08
【问题描述】:

在我当前的多线程项目中,我需要非常关注速度/延迟。

缓存访问是我试图更好地理解的东西。而且我不清楚无锁队列(例如 boost::lockfree::spsc_queue)如何在缓存级别访问/使用内存。

我见过使用需要由消费者核心操作的大对象的指针被推入队列的队列。

如果消费者核心从队列中弹出一个元素,我认为这意味着该元素(在本例中为指针)已经加载到消费者核心的 L2 和 L1 缓存中。但是要访问元素,是否不需要通过从 L3 缓存或跨互连(如果另一个线程在不同的 cpu 套接字上)查找和加载元素来访问指针本身?如果是这样,那么简单地发送一个可以由消费者处理的对象的副本会更好吗?

谢谢。

【问题讨论】:

    标签: memory boost cpu-architecture lock-free cpu-cache


    【解决方案1】:

    C++ 主要是一个按需付费生态系统。

    任何常规队列都会让选择存储语义(按值或按引用)。

    但是,这一次您订购了一些特别的东西:您订购了一个无锁队列。 为了无锁,它必须能够将所有可观察到的修改操作作为原子操作执行。这自然限制了这些操作中可以直接使用的类型。

    您可能怀疑是否有可能拥有超过系统本机寄存器大小的值类型(例如,int64_t)。

    好问题。

    输入环形缓冲区

    确实,任何基于节点的容器都只需要指针交换来进行所有修改操作,这在所有现代架构上都是原子操作。 但是,涉及以非原子顺序复制多个不同内存区域的任何事情真的会造成无法解决的问题吗?

    没有。想象一下 POD 数据项的平面数组。现在,如果您将数组视为循环缓冲区,则只需以原子方式维护缓冲区前端和结束位置的索引。容器可以在 internal 'dirty front index' 中随意更新,同时在 external 前端进行复制。 (副本可以使用宽松的内存排序)。仅当知道整个副本已完成时,才会更新外部前端索引。此更新需要按 acq_rel/cst 内存顺序[1]

    只要容器能够保护front 永远不会完全环绕并到达back 的不变量,这是一笔不错的交易。我认为这个想法在 Disruptor Library(以 LMAX 闻名)中得到了普及。你从

    获得机械共振
    • 读/写时的线性内存访问模式
    • 如果您可以使记录大小与(多个)物理缓存行对齐,那就更好了
    • 所有数据都是本地数据,除非 POD 包含该记录之外的原始引用

    Boost 的 spsc_queue 实际上是如何做到这一点的?

    1. 是的,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,但它需要既可以默认构造又可以复制)。

    2. 是的,读写指针是原子整数值。请注意,boost 开发人员已注意应用足够的填充以避免 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_;
      
    3. 事实上,如您所见,在读取或写入端都只有“内部”索引。这是可能的,因为只有一个写线程,也只有一个读线程,这意味着在写操作结束时只能有比预期更多的空间。

    4. 还有其他几个优化:

      • 支持它的平台的分支预测提示 (unlikely())
      • 可以一次推送/弹出一系列元素。如果您需要从一个缓冲区/环形缓冲区虹吸到另一个缓冲区,这应该会提高吞吐量,尤其是在原始元素大小不等于(整数倍)缓存线的情况下
      • 尽可能使用 std::unitialized_copy
      • 将在实例化时优化对普通构造函数/析构函数的调用
      • unitialized_copy 将在所有主要标准库实现中优化为 memcpy(这意味着,如果您的架构支持,将使用 SSE 指令)

    总而言之,我们看到了一个一流的环形缓冲区创意

    使用什么

    Boost 为您提供了所有选项。您可以选择让您的元素类型成为指向您的消息类型的指针。但是,正如您在问题中已经提出的那样,这种间接级别会降低参考的局部性,并且可能不是最佳的。

    另一方面,如果复制成本很高,则将完整的消息类型存储在元素类型中可能会变得很昂贵。至少尝试使元素类型很好地适合高速缓存行(在 Intel 上通常为 64 字节)。

    因此,在实践中,您可能会考虑将经常使用的数据存储在值中,并使用指针引用较少使用的数据(除非遍历,否则指针的成本会很低)。

    如果您需要这种“附件”模型,请考虑为引用的数据使用自定义分配器,这样您也可以在那里实现内存访问模式。

    让您的分析器指导您。


    [1] 我想对于 spsc acq_rel 应该可以工作,但我对细节有点生疏。作为一项规则,我强调不要自己编写无锁代码。我建议其他人效仿我的例子:)

    【讨论】:

    • 感谢您的详尽解释
    猜你喜欢
    • 2011-12-08
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-07-01
    • 2018-04-12
    • 1970-01-01
    • 2018-12-30
    相关资源
    最近更新 更多