关注公众号获取更多信息:
最近也写了很多关于内存模型的文章,会有人有灵魂一问 -- 内存模型到底有啥用?什么时候能用到内存模型?这个问题我也思考了很久,接下来我会举个在现实中的应用 -- 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来读取内存数据的,而是以“块数据”的形式,每块的大小通常为64bytes,这些“块”被称为“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 缓存间不断传输,比单线程串行处理还糟糕。
二. False sharing
有多个线程操作不同的成员变量,但是相同的缓存行,这个时候会发生什么?。没错,伪共享(False Sharing)问题就发生了!有张 Disruptor 项目的经典示例图,如下:
上图中,一个运行在处理器 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。
https://github.com/maxcong001/spsc_queue