C++ 伪共享(False Sharing)与缓存行对齐:并发性能陷阱与优化

好的,各位观众老爷,欢迎来到“并发性能大作战”现场!今天咱们要聊的是一个听起来玄乎,但实际能让你程序慢到哭的家伙——伪共享(False Sharing)。

开场白:并发的理想与现实

咱们写并发程序,图的是啥?当然是更快!理想很丰满,觉得多线程一开,CPU核心火力全开,程序速度噌噌往上涨。但现实往往很骨感,有时候线程越多,速度反而越慢,甚至不如单线程!

罪魁祸首之一,就是咱们今天要说的伪共享。

第一幕:缓存的故事

要理解伪共享,得先了解CPU缓存的工作原理。CPU速度太快了,内存速度跟不上,所以CPU里搞了几层缓存,L1、L2、L3,越靠近CPU速度越快,容量越小。

缓存可不是按字节存储的,它按缓存行(Cache Line)存储。缓存行通常是64字节,也有的是128字节。你可以把缓存想象成一排一排的抽屉,每个抽屉就是一个缓存行。

CPU从内存读取数据时,不是一个字节一个字节读,而是一次读一个缓存行。同样,CPU写数据时,也是写一个缓存行。

第二幕:伪共享的真面目

好了,缓存行的概念有了,现在隆重推出咱们的“伪共享”主角。

想象一下,你有两个线程,线程A修改变量A,线程B修改变量B。变量A和变量B在内存中挨得很近,近到什么程度?近到它们俩位于同一个缓存行

线程A修改变量A时,CPU会把包含变量A的整个缓存行加载到线程A所在的CPU核心的缓存中。并且,为了保证数据一致性,其他CPU核心中包含该缓存行的缓存也会失效(invalidate)。

现在,线程B修改变量B了,同样,包含变量B的整个缓存行会被加载到线程B所在的CPU核心的缓存中,线程A的缓存又失效了!

线程A和线程B不断地修改自己的变量,结果就是两个CPU核心的缓存不断地失效和重新加载,这就像两个熊孩子抢玩具,谁也玩不好,白白浪费了CPU的时间。

这就是伪共享:多个线程修改不同的变量,但这些变量共享同一个缓存行,导致缓存频繁失效,降低性能。

第三幕:代码示例(反面教材)

光说不练假把式,咱们来一段代码,看看伪共享是怎么折磨人的。

#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
#include <numeric>

using namespace std;

const int NUM_THREADS = 2; // 线程数
const int ARRAY_SIZE = 1024 * 1024; // 数组大小

struct Data {
    int value;
};

Data data[ARRAY_SIZE];

void worker(int threadId) {
    for (int i = threadId; i < ARRAY_SIZE; i += NUM_THREADS) {
        data[i].value++; // 疯狂修改
    }
}

int main() {
    vector<thread> threads;

    // 初始化数据
    for (int i = 0; i < ARRAY_SIZE; ++i) {
        data[i].value = 0;
    }

    // 启动线程
    auto start = chrono::high_resolution_clock::now();
    for (int i = 0; i < NUM_THREADS; ++i) {
        threads.emplace_back(worker, i);
    }

    // 等待线程结束
    for (auto& thread : threads) {
        thread.join();
    }
    auto end = chrono::high_resolution_clock::now();

    // 计算耗时
    auto duration = chrono::duration_cast<chrono::milliseconds>(end - start);
    cout << "Time taken: " << duration.count() << " milliseconds" << endl;

    // 验证结果 (可选)
    long long sum = 0;
    for(int i = 0; i < ARRAY_SIZE; ++i){
        sum += data[i].value;
    }

    cout << "Sum: " << sum << endl;
    cout << "Expected Sum: " << (long long)ARRAY_SIZE << endl;

    return 0;
}

这段代码很简单,创建了一个Data结构体数组,然后启动多个线程,每个线程负责修改数组的一部分元素。

问题在于,Data结构体只有一个int类型的value成员,很可能多个线程修改的Data结构体实例位于同一个缓存行。这样就造成了严重的伪共享。

运行一下这段代码,你会发现,线程数越多,速度反而越慢。

第四幕:缓存行对齐——拯救性能的英雄

既然伪共享是因为多个变量共享同一个缓存行导致的,那解决办法也很简单:让每个变量独占一个缓存行。这就是缓存行对齐

如何实现缓存行对齐呢?

  • 方法一:手动填充

Data结构体中添加一些无用的成员,把结构体的大小填充到缓存行的大小。

struct Data {
    int value;
    char padding[60]; // 填充60字节,使结构体大小为64字节 (假设缓存行大小为64字节)
};

这种方法简单粗暴,但缺点是可读性差,而且需要手动计算填充的大小。

  • 方法二:使用编译器指令(推荐)

更优雅的方法是使用编译器指令,例如alignas(C++11)。

struct alignas(64) Data { // 强制结构体按64字节对齐
    int value;
};

alignas(64)告诉编译器,Data结构体的起始地址必须是64的倍数。这样就能保证每个Data结构体独占一个缓存行。

第五幕:代码示例(正面教材)

咱们来修改一下上面的代码,使用缓存行对齐。

#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
#include <numeric>

using namespace std;

const int NUM_THREADS = 2; // 线程数
const int ARRAY_SIZE = 1024 * 1024; // 数组大小

struct alignas(64) Data { // 强制结构体按64字节对齐
    int value;
};

Data data[ARRAY_SIZE];

void worker(int threadId) {
    for (int i = threadId; i < ARRAY_SIZE; i += NUM_THREADS) {
        data[i].value++; // 疯狂修改
    }
}

int main() {
    vector<thread> threads;

    // 初始化数据
    for (int i = 0; i < ARRAY_SIZE; ++i) {
        data[i].value = 0;
    }

    // 启动线程
    auto start = chrono::high_resolution_clock::now();
    for (int i = 0; i < NUM_THREADS; ++i) {
        threads.emplace_back(worker, i);
    }

    // 等待线程结束
    for (auto& thread : threads) {
        thread.join();
    }
    auto end = chrono::high_resolution_clock::now();

    // 计算耗时
    auto duration = chrono::duration_cast<chrono::milliseconds>(end - start);
    cout << "Time taken: " << duration.count() << " milliseconds" << endl;

    // 验证结果 (可选)
    long long sum = 0;
    for(int i = 0; i < ARRAY_SIZE; ++i){
        sum += data[i].value;
    }

    cout << "Sum: " << sum << endl;
    cout << "Expected Sum: " << (long long)ARRAY_SIZE << endl;

    return 0;
}

只修改了一行代码,添加了alignas(64),重新运行这段代码,你会发现,速度明显提升了!

第六幕:缓存行大小的获取

不同的CPU架构,缓存行的大小可能不同。如何获取缓存行的大小呢?

  • 方法一:编译时常量

一些编译器会提供预定义的常量,例如__CACHELINE_SIZE

#ifdef __CACHELINE_SIZE
    constexpr size_t cache_line_size = __CACHELINE_SIZE;
#else
    constexpr size_t cache_line_size = 64; // 默认值
#endif
  • 方法二:运行时获取

在Linux系统上,可以使用sysconf(_SC_LEVEL1_DCACHE_LINESIZE)函数获取L1数据缓存的缓存行大小。

#include <unistd.h>

size_t getCacheLineSize() {
    long size = sysconf(_SC_LEVEL1_DCACHE_LINESIZE);
    if (size <= 0) {
        return 64; // 默认值
    }
    return (size_t)size;
}

第七幕:伪共享的常见场景

伪共享不仅仅发生在数组中,还可能发生在其他数据结构中。

  • 全局变量

多个线程访问不同的全局变量,但这些变量在内存中挨得很近,也可能造成伪共享。

  • 对象成员

多个线程访问同一个对象的不同成员,但这些成员位于同一个缓存行,也可能造成伪共享。

  • 任务队列

多个线程从任务队列中取出任务,每个任务包含一些数据,如果这些数据位于同一个缓存行,也可能造成伪共享。

第八幕:避免伪共享的策略总结

  • 缓存行对齐:让每个线程独占一个缓存行。
  • 填充:在数据结构中添加填充,使不同线程访问的数据位于不同的缓存行。
  • 线程局部存储(Thread Local Storage, TLS):为每个线程分配一份独立的数据副本,避免共享。
  • 数据重排:调整数据结构中成员的顺序,使经常被同一个线程访问的成员相邻,避免不同线程访问的数据位于同一个缓存行。

第九幕:实战案例

假设你正在开发一个高性能的计数器,多个线程需要并发地增加计数器的值。

#include <iostream>
#include <thread>
#include <vector>
#include <chrono>

using namespace std;

const int NUM_THREADS = 4;
const int ITERATIONS = 1000000;

struct Counter {
    long long value;

    void increment() {
        value++;
    }
};

Counter counter;

void worker() {
    for (int i = 0; i < ITERATIONS; ++i) {
        counter.increment();
    }
}

int main() {
    vector<thread> threads;

    auto start = chrono::high_resolution_clock::now();
    for (int i = 0; i < NUM_THREADS; ++i) {
        threads.emplace_back(worker);
    }

    for (auto& thread : threads) {
        thread.join();
    }
    auto end = chrono::high_resolution_clock::now();

    auto duration = chrono::duration_cast<chrono::milliseconds>(end - start);
    cout << "Time taken: " << duration.count() << " milliseconds" << endl;

    cout << "Counter value: " << counter.value << endl;
    cout << "Expected value: " << (long long)NUM_THREADS * ITERATIONS << endl;

    return 0;
}

这段代码存在严重的伪共享问题,多个线程并发地修改同一个Counter对象,导致缓存频繁失效。

如何解决呢?可以使用线程局部存储

#include <iostream>
#include <thread>
#include <vector>
#include <chrono>

using namespace std;

const int NUM_THREADS = 4;
const int ITERATIONS = 1000000;

struct alignas(64) Counter { // 缓存行对齐
    long long value;

    void increment() {
        value++;
    }
};

thread_local Counter counter; // 线程局部存储

void worker() {
    for (int i = 0; i < ITERATIONS; ++i) {
        counter.increment();
    }
}

int main() {
    vector<thread> threads;
    vector<long long> results(NUM_THREADS);

    auto start = chrono::high_resolution_clock::now();
    for (int i = 0; i < NUM_THREADS; ++i) {
        threads.emplace_back(worker);
    }

    for (int i = 0; i < NUM_THREADS; ++i) {
        threads[i].join();
        // 获取每个线程的计数器值
        results[i] = counter.value;
    }
    auto end = chrono::high_resolution_clock::now();

    auto duration = chrono::duration_cast<chrono::milliseconds>(end - start);
    cout << "Time taken: " << duration.count() << " milliseconds" << endl;

    long long total = 0;
    for(auto r : results){
        total += r;
    }

    cout << "Counter value: " << total << endl;
    cout << "Expected value: " << (long long)NUM_THREADS * ITERATIONS << endl;

    return 0;
}

每个线程都有自己的Counter对象,避免了伪共享。

第十幕:总结与展望

伪共享是一个隐蔽但威力强大的性能杀手。理解缓存的工作原理,掌握缓存行对齐等技巧,可以有效地避免伪共享,提升并发程序的性能。

希望今天的讲座能帮助大家在并发编程的道路上少踩坑,多提速!

表格总结:

问题 原因 解决方案
伪共享 多个线程修改不同变量,但这些变量共享同一个缓存行 1. 缓存行对齐:让每个线程独占一个缓存行。 2. 填充:在数据结构中添加填充,使不同线程访问的数据位于不同的缓存行。 3. 线程局部存储(Thread Local Storage, TLS):为每个线程分配一份独立的数据副本。 4. 数据重排:调整数据结构中成员的顺序。

祝大家编程愉快! 下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注