相比于《不可忽视的cacheline问题》中提到的导致程序错误的 Bug,本文要讨论的是一个更常见但隐蔽性更强的性能陷阱——伪共享。这类问题虽然不会让程序出错,但会显著拖慢执行速度,在多线程程序中尤其值得警惕。
什么是伪共享?
简单来说,在 MESI 和 MOESI 这类缓存一致性协议下,如果多个 CPU 核心反复访问位于同一 Cacheline 中的不同数据,协议就会在总线上频繁地对这条 Cacheline 进行无效化操作,并要求各个核心的缓存进行监听同步。在 CPU 的视角来看,这种操作是低效的,它无谓地消耗了内存带宽,浪费了宝贵的系统资源。
测试代码与分析
以下是一段用于演示伪共享问题的 C++ 测试代码,它清晰地展示了问题是如何产生的。
#include <iostream>
#include <thread>
#include <new>
#include <atomic>
#include <chrono>
#include <latch>
#include <vector>
using namespace std;
using namespace chrono;
#if defined(__cpp_lib_hardware_interference_size)
// default cacheline size from runtime
constexpr size_t CL_SIZE = hardware_constructive_interference_size;
#else
// most common cacheline size otherwise
constexpr size_t CL_SIZE = 64;
#endif
int main()
{
vector<jthread> threads;
int hc = jthread::hardware_concurrency();
hc = hc <= CL_SIZE ? hc : CL_SIZE;
for (int nThreads = 1; nThreads <= hc; ++nThreads)
{
// synchronize beginning of threads coarse on kernel level
latch coarseSync(nThreads);
// fine synch via atomic in userspace
atomic_uint fineSync(nThreads);
// as much chars as would fit into a cacheline
struct alignas(CL_SIZE) { char shareds[CL_SIZE]; } cacheLine;
// sum of all threads execution times
atomic_int64_t nsSum(0);
for (int t = 0; t != nThreads; ++t)
threads.emplace_back(
[&](char volatile &c)
{
coarseSync.arrive_and_wait(); // synch beginning of thread execution on kernel-level
if (fineSync.fetch_sub(1, memory_order::relaxed) != 1) // fine-synch on user-level
while (fineSync.load(memory_order::relaxed));
auto start = high_resolution_clock::now();
for (size_t r = 10‘000’000; r--;)
c = c + 1;
nsSum += duration_cast<nanoseconds>(high_resolution_clock::now() - start).count();
}, ref(cacheLine.shareds[t]));
threads.resize(0); // join all threads
cout << nThreads << ": " << (int)(nsSum / (1.0e7 * nThreads) + 0.5) << endl;
}
}
代码的核心思路很清晰:
- 构造一个大小正好为一个 Cacheline 的数组
char shareds[CL_SIZE];,并确保其内存对齐。
- 在每次循环中,创建数量递增的线程。每个线程会操作该数组中不同的
char 元素(即ref(cacheLine.shareds[t])),进行千万次自增操作。
- 由于这些不同的
char 元素在物理内存上位于同一个 Cacheline 内,当多个线程并发修改时,就会触发上文所述的伪共享问题。
for (int nThreads = 1; nThreads <= hc; ++nThreads)
通过不断增加并发线程数,我们就能直观地看到伪共享带来的负面性能影响。
测试结果:伪共享的代价
编译并运行上述代码:
g++ -std=c++20 falsesharing.cpp -o falsesharing
# ./falsesharing
1: 3
2: 7
3: 12
4: 13
5: 14
6: 19
7: 21
8: 29
9: 30
10: 34
11: 37
12: 39
13: 41
14: 47
15: 49
16: 54
结果非常触目惊心!随着线程数从 1 增加到 16,完成相同计算量的耗时增长了 18 倍(从 3 个单位时间增加到 54 个单位时间)。这正是伪共享导致多线程性能不增反降的典型表现。
如何解决伪共享?
官方或常见的解决方案主要有两种:
- 数据布局调整:重新排序数据结构中的变量,或者在变量之间添加无用的填充字节,使得可能被不同线程频繁访问的变量落在不同的 Cacheline 中。
- 编译时优化:利用编译器提供的特性或指令进行数据转换,以减少伪共享。
对于这个示例,最直接有效的方法就是确保每个线程操作的数据位于独立的 Cacheline 中。修改思路很简单:不再让所有线程共享同一个 cacheLine 数组,而是为每个线程分配独立的、对齐到 Cacheline 大小的数据。
修复后的程序运行结果如下:
# ./fixfalsesharing
1: 3
2: 3
3: 3
4: 3
5: 3
6: 2
7: 4
8: 3
9: 3
10: 3
11: 3
12: 3
13: 3
14: 3
15: 3
16: 3
可以看到,消除了伪共享后,无论线程数如何增加,每个线程的执行时间基本保持稳定,这才是多线程并发应有的理想性能表现。这提醒我们,在开发程序时,尤其在高并发场景下,对数据布局保持敏感至关重要。
总结
在编写多线程或并发程序时,相比于那些会导致程序崩溃的 Cacheline 对齐 Bug,伪共享是一个更容易被忽视、但实际影响可能更大的性能杀手。它不会让程序出错,却会悄悄吞噬掉你的 CPU 性能。幸运的是,只要在编程时具备“避免不同线程竞争同一 Cacheline”的意识,通常可以通过简单的数据隔离或填充来有效规避。
希望这个在C++环境下的具体例子,能帮助你更深刻地理解伪共享的原理与危害。如果你对这类底层性能优化话题感兴趣,欢迎来云栈社区交流讨论,这里聚集了许多热衷于技术深度探索的开发者。
参考
https://en.wikipedia.org/wiki/False_sharing
|