Linux性能杀手: 伪共享

探讨了多线程编程中伪共享问题对性能的影响,分析了CPUCache的读写特性,通过实例展示了如何避免伪共享以提高多线程程序的效率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

通过对互斥量和读写锁的讨论, 我们已经有了这种意识: 对于共享数据的读写, 要加锁保护。 临界区的存在, 导致多个线程不能并行, 造成性能下降。 临界区越大, 多个线程出入临界区越频繁, 对性能的伤害也就越大。


这种情况下对性能的伤害是比较明显的。 多线程情况下, 还有一种情况对性能的损害是比较大的, 却不像临界区这么明显。 这就是有名的伪共享问题。


根据局部性原理, 存储器是分层的, 如图7-21所示。 从距离CPU最近的寄存器到主内存, 依次为CPU寄存器、 L1 Cache、 L2 Cache、 L3 Cache和主存。 从高层往底层走, 存储设备变得更慢, 容量更大, 单位字节也更便宜。 最高层是很少量的寄存器, 通常可以在1个时钟周期内访问它们, 而接下来的L1 Cache通常可以在4个时钟周期内访问到, L2 Cache通常需要10个时钟周期才能访问到, 而到了主存, 通常需要几百个时钟周期才能访问得到, 对这个延迟数据感兴趣的话。


在这种分层的存储结构中, 对于每一个k, 位于k层的更快更小的存储被作为位于k+1层的更大更慢的存储设备的缓存。 换句话说更快更小的存储设备的数据来自更慢更大的低一级存储设备。 访问的数据在高速缓存中, 被称为缓存命中, 这种情况下访问速度比较快。 如果访问的数据d在k级缓存中不存在, 就不得不从k+1级中取出包含d的那个块(block) 。 如果k级缓存已经满了的话, 就可能会覆盖现存的一个块。

由于高一级缓存的性能远远超过低一级的缓存, 所以一旦缓存不命中(Cache miss) , 对性能的损害就会是比较大的。
在典型的多核架构中, 每个CPU都有自己的Cache。 如果一个内存中的变量在多个CPU Cache中都有副本, 则需要保证变量的Cache的一致性。 现在大多数的架构实现Cache一致性都是采用MESI协议。 对缓存一致性协议感兴趣的话, 可以阅读《计算机体系结构: 量化研究方法》 这本经典之作。 此外, Paul E.McKenney的《Is Parallel Programming Hard, And, If so, What Can You Do About It》 一书中也有很详尽的介绍。


需要注意的是, CPU Cache是以缓存线(Cache line) 为单位进行读写的。 通常来说, 一条缓存线的大小为64字节。 换言之, 就是访问1字节的数据, 系统也会将该字节所在的整条缓存线的数据都搬到缓存中。


因为CPU Cache具有以Cache line为单位进行读写的性质, 所以在多线程编程中, 稍有不慎, 就会掉入伪共享的陷阱, 造成性能恶化。
可以考虑下如下代码:
 

int sum1;
int sum2;
void thread1(int v[], int v_count)
{
    sum1 = 0;
    for (int i = 0; i < v_count; i++)
        sum1 += v[i];
}
void thread2(int v[], int v_count)
{
    sum2 = 0;
    for (int i = 0; i < v_count; i++)
        sum2 += v[i];
}

这部分代码定义了两个全局变量sum1和sum2, 两个线程分别将计算结果放入各自的全局变量中, 看起来并行不悖。 但是由于这两个全局变量紧挨着定义, 编译器给这两个变量分配的内存几乎总是紧挨着的, 因此这两个变量很可能在同一条Cache line中。
尽管线程1所在的CPU并不需要sum2的值, 但是由于sum2和sum1在同一条Cache line中, 因此sum2的值也随同sum1一并被加载到了thread1所在CPU的Cache中了。

当thread1修改sum1的值时, 尽管并未更新sum2的值, 但影响的是整条Cache line, 它会将thread2所在CPU对应的Cache line置为Invalidate。 如果thread2尝试更新sum2, 会触发缓存不命中。 反过来, thread2修改sum2时, 也会影响到sum1的缓存命中。


可以想见, 就因为两个值彼此毗邻, 落在同一条Cache line中, 会导致大量的缓存不命中, 从而影响性能。
下面通过一个例子, 来看伪共享给性能带来的影响。
计算圆周率π有一种方法是数值积分法:

可以通过基于中点矩形的数值积分方法来求解上述积分, 如下:
 

static long num_rect = 400000000;
double mid = 0.0;
double height = 0.0;
double width = 1.0/((double)num_rect);
int cur ;
for(cur = 0;cur < num_rect; cur += 1)
{
    mid = (cur+0.5)*width;
    height = 4.0/(1 + mid*mid);
    sum += height;
}
sum *= width;

这是典型的计算密集型程序, 因此我们采用多线程来分工协作, 代码如下:
 

#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>
#define NR_THREAD 1
static long num_rect = 400000000;
typedef struct sum_struct {
double sum ;
//char padding[8];
} sum_struct;
struct sum_struct __sum[NR_THREAD];
void* calc_pi(void* ptr)
{
	int index = (int)ptr;
	double mid = 0.0;
	double height = 0.0;
	double width = 1.0/((double)num_rect);
	int cur = index;
	for(;cur < num_rect; cur += NR_THREAD)
	{
		mid = (cur+0.5)*width;
		height = 4.0/(1 + mid*mid);
		__sum[index].sum += height;
	}
	__sum[index].sum *= width;
}
int main()
{
		int i = 0;
		int ret ;
		double result = 0.0;
		pthread_t tid[NR_THREAD];
		fprintf(stdout,"the size of struct sum_struct = %ld\n",sizeof(struct sum_struct));
		for( i = 0 ; i < NR_THREAD; i++)
		{
			__sum[i].sum = 0.0;
			ret = pthread_create(&tid[i],NULL,calc_pi,(void*) i);
			if(ret != 0)
			{
				/*error handle here*/
				exit(1)
			}
		}
		for( i = 0; i < NR_THREAD ; i++)
		{
			pthread_join(tid[i],NULL);
			result += __sum[i].sum;
		}
		fprintf(stdout,"the PI = %.32f\n",result);
		return 0;
}

因为num_rect等于4亿, 因此要计算4亿次, 可以通过修改NR_THREAD的值, 让8个线程协同计算, 最后将结果累加到一起得到正确的值, 希望这样能将执行时间缩短为单线程的1/8,

因为每个线程都要负责往__sum对应的位置更新结果。 因此这个数组很容易触发前面提到的伪共享陷阱。 当sum_struct结构体没有填充字符时, 该结构体占据8字节, 当8个线程并发时, __sum数组很可能在同一个Cache line中, 这时候性能必然会受到影响。 为了避开false sharing这个陷阱, 测试程序采用了加填充字节的方法。 如果给sum_struct结构体加上56个填充字节, 每个sum_struct占据1条Cache line的大小, 则可以确保它们之间不会互相影响。
 

typedef struct sum_struct {
    double sum ;/*padding 56字节, 占满1条Cache line*/
          //char padding[56];
} sum_struct;
struct sum_struct __sum[NR_THREAD];

在24核的服务器上运行, 结果如表7-14所示。

可以看出, 如果不加56字节的填充, 由于伪共享引起的大量缓存不命中, 8个线程并没有带来8倍的效率提升。 通过填充字节解决了伪共享的问题之后, 效率线性地提升了8倍。
 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值