好的,各位观众老爷,欢迎来到“并发性能大作战”现场!今天咱们要聊的是一个听起来玄乎,但实际能让你程序慢到哭的家伙——伪共享(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. 数据重排:调整数据结构中成员的顺序。 |
祝大家编程愉快! 下课!