【转载】走进 C++11(四十三)memory order 番外篇一 为什么实现同样逻辑,别人的程序比我快 - 从 SPSC queue 谈起

572 阅读6分钟

原文地址:走进 C++11(四十三)memory order 番外篇一 为什么实现同样逻辑,别人的程序比我快 - 从 SPSC queue 谈起

我对文章的格式进行了调整,并对关键部分进行了标注

前言

最近也写了很多关于内存模型的文章,会有人有灵魂一问 —— 内存模型到底有啥用?什么时候能用到内存模型?这个问题我也思考了很久,接下来我会举个在现实中的应用 -—— SPSC queue。

有些人会说,SPSC queue?我分分钟就能给你写出一个。很简单,就是普通的 queue 加上锁就行了。但是既然我们都说过了内存模型,我们就要忘记锁这个东西。的确,锁是解决很多线程问题的”万金油“,可是有没有想过,锁的应用,给你的系统带来了多大的额外开销。之前曾经维护过公司的一个软件,这个软件就在 worker thread 和逻辑 thread 之间共享了一个 SPSC queue —— 就是那种你想象中的,push pop 都要加锁的那种。也不可避免的,对这个 queue 的操作成了系统的瓶颈。

闲话少说,书归正传。要想实现一个 SPSC queue,最关键的两个API就是 enqueue 和 dequeue 。面临的最大问题就是确保两个 API 是 thread safe 的。还有一个重要的问题就是 —— False sharing。这个聊起来就会没完没了。为了讲明白来龙去脉,这个文章可能不止一篇。

在并发编程过程中,我们大部分的焦点都放在如何控制共享变量的访问控制上(代码层面),但是很少人会关注系统硬件底层相关的影响因素,由难到易, 我们会首先从硬件系统层面上说起。

这里首先要科普几个概念

  1. cache line
  2. false sharing
  3. padding

一. cache line

CPU 不是按单个 bytes 来读取内存数据的,而是以 “块数据” 的形式,每块的大小通常为 64 bytes,这些“块”被称为 “Cache Line” 。举个例子,一个 long 是 8 个 byte,那么 8 个元素的 long[8] 数组会在同一个 cache line 中被一次读入到 CPU 的 cache 中。多线程处理时,同一个 long[8] 会被分别读到每个 CPU 自己的 cache 里面(为了简化概念,不考虑存在共享 cache 的情况)。那么某个 CPU 改变了其中的某一个元素的值时,整个 cache line 的数据都被污染了。由于多个 CPU 在 cache line 层面共享这个数组,因此需要将这个 cache line 的数据都写回内存;然后其他 CPU 要对这个数组的其他元素进行操作,又要重新将这个数组的全部内容加载进自己的 cache 中。如此不断在 累加一个元素 -> 写回内存 -> 其他 cpu cache 失效 -> 读取内存 循环。相当于所有 CPU 都在竞争这一小块内存的使用,由于大量的数据要在内存和 CPU 缓存间不断传输,比单线程串行处理还糟糕

image.png

二. false sharing

有多个线程操作不同的成员变量,但是处于相同的缓存行,这个时候会发生什么?。没错,伪共享(False Sharing)问题就发生了!有张 Disruptor 项目的经典示例图,如下:

image.png

上图中,一个运行在处理器 core1 上的线程想要更新变量 X 的值,同时另外一个运行在处理器 core2 上的线程想要更新变量 Y 的值。但是,这两个频繁改动的变量都处于同一条缓存行。两个线程就会轮番发送 RFO 消息,占得此缓存行的拥有权。当 core1 取得了拥有权开始更新 X,则 core2 对应的缓存行需要设为 I 状态(失效态)。当 core2 取得了拥有权开始更新 Y,则 core1 对应的缓存行需要设为 I 状态(失效态)。轮番夺取拥有权不但带来大量的 RFO 消息,而且如果某个线程需要读此行数据时,L1 和 L2 缓存上都是失效数据,只有 L3 缓存上是同步好的数据。从前一篇我们知道,读 L3 的数据非常影响性能。更坏的情况是跨槽读取,L3 都要 miss,只能从内存上加载。

表面上 X 和 Y 都是被独立线程操作的,而且两操作之间也没有任何关系。只不过它们共享了一个缓存行,但所有竞争冲突都是来源于共享。

三. padding

padding 就是为了解决 false sharing的 技术之一。思路就是: 让不同线程操作的对象处于不同的缓存行即可。

这里我们首先把整个代码贴出来,作为例子:

#pragma once

#include <atomic>
#include <assert.h>
#include <cstddef>

inline size_t next_pow_2(size_t num)
{
    size_t next = 2;
    size_t i = 0;

    while (next < num)

    {
        next = static_cast<size_t>(1) << i++;
    }

    return next;
}

template <typename T>
class SPSCQueue
{
public:
    typedef T EntryType;

    SPSCQueue(size_t size)
        : size_(next_pow_2(size)), mask_(size_ - 1), buffer_(new T[size_]), tail_(0), head_(0)
    {
    }

    ~SPSCQueue() { delete[] buffer_; }

    bool enqueue(const T &input)
    {
        const size_t pos = tail_.load(std::memory_order_relaxed);

        const size_t next_pos = (pos + 1) & mask_;

        if (next_pos == head_.load(std::memory_order_acquire))
        {
            return false;
        }

        buffer_[pos] = input;

        tail_.store(next_pos, std::memory_order_release);

        return true;
    }

    bool dequeue(T &output)
    {
        const size_t pos = head_.load(std::memory_order_relaxed);

        if (pos == tail_.load(std::memory_order_acquire))
        {
            return false;
        }

        output = buffer_[pos];

        head_.store((pos + 1) & mask_, std::memory_order_release);

        return true;
    }

    bool is_empty()
    {
        return head_.load(std::memory_order_acquire) ==

               tail_.load(std::memory_order_acquire);
    }

private:
    typedef char cache_line_pad_t[64];

    cache_line_pad_t pad0_;

    const size_t size_;
    const size_t mask_;

    T *const buffer_;

    cache_line_pad_t pad1_;
    std::atomic<size_t> tail_;

    cache_line_pad_t pad2_;
    std::atomic<size_t> head_;
};

如果你作为一个 code reviewer ,看到 cache_line_pad_t 这个变量,你会不会让程序员删掉这些用不到的变量?

没错,这个变量就是一个 padding 变量。

如何避免 false sharing? 如何知道系统有没有 false sharing 问题?

通过上面大篇幅的介绍,我们已经知道伪共享的对程序的影响。那么,在实际的生产开发过程中,我们一定要通过缓存行填充去解决掉潜在的伪共享问题吗?

其实并不一定。

首先就是多次强调的,伪共享是很隐蔽的,不同类型的计算机具有不同的微架构,如果设计到跨平台的设计,那就更难以把握了,一个确切的填充方案只适用于一个特定的操作系统。还有,缓存的资源是有限的,如果填充会浪费珍贵的 cache 资源,并不适合大范围应用。最后,目前主流的 Intel 微架构 CPU 的 L1 缓存,已能够达到 80% 以上的命中率。

综上所述,并不是每个系统都适合花大量精力去解决潜在的伪共享问题

如果真的想知道 false sharing 在现有的系统中的情况,可以通过 Intel® VTune™ Performance Analyzer 或者 Intel® Performance Tuning Utility、Visual Studio Profiler 以及 性能计数器 的表现来发现潜在的 false sharing。

spsc_queue github 地址