#define ORANGE_MAX_VALUE 1000000 #define APPLE_MAX_VALUE 100000000 #define MSECOND 1000000 struct apple { unsigned long long a; unsigned long long b; }; struct orange { int a[ORANGE_MAX_VALUE]; int b[ORANGE_MAX_VALUE]; }; int main (int argc, const char * argv[]) { // insert code here... struct apple test; struct orange test1; for(sum=0;sum<APPLE_MAX_VALUE;sum++) { test.a += sum; test.b += sum; } sum=0; for(index=0;index<ORANGE_MAX_VALUE;index++) { sum += test1.a[index]+test1.b[index]; } return 0; }
K: 要求在某个接近最快值范围内的测量值数量。按照升序的方式维护一个 K 个最快时间的数组,对于每一个新的测量值,如果比当前 K 处的值更快,则用最新的值替换数组中的元素 K ,然后再进行升序排序,持续不断的进行该过程,并满足误差标准,此时就称测量值已经收敛。如果 M 次后,不能满足误差标准,则称为不能收敛。
ε 测量值必须多大程度的接近,即测量值按照升序标号 V1, V2, V3, … , Vi, … ,同时必须满足(1+ ε)Vi >= Vk
M: 在结束测试之前,测量值的最大数量。
void* add(void* x) { for(sum=0;sum<APPLE_MAX_VALUE;sum++) { ((struct apple *)x)->a += sum; ((struct apple *)x)->b += sum; } return NULL; } int main (int argc, const char * argv[]) { // insert code here... struct apple test; struct orange test1={{0},{0}}; pthread_t ThreadA; pthread_create(&ThreadA,NULL,add,&test); for(index=0;index<ORANGE_MAX_VALUE;index++) { sum += test1.a[index]+test1.b[index]; } pthread_join(ThreadA,NULL); return 0; }
struct apple { unsigned long long a; unsigned long long b; pthread_rwlock_t rwLock; }; void* addx(void* x) { pthread_rwlock_wrlock(&((struct apple *)x)->rwLock); for(sum=0;sum<APPLE_MAX_VALUE;sum++) { ((struct apple *)x)->a += sum; } pthread_rwlock_unlock(&((struct apple *)x)->rwLock); return NULL; } void* addy(void* y) { pthread_rwlock_wrlock(&((struct apple *)y)->rwLock); for(sum=0;sum<APPLE_MAX_VALUE;sum++) { ((struct apple *)y)->b += sum; } pthread_rwlock_unlock(&((struct apple *)y)->rwLock); return NULL; }
int main (int argc, const char * argv[]) { // insert code here... struct apple test; struct orange test1={{0},{0}}; pthread_t ThreadA,ThreadB; pthread_create(&ThreadA,NULL,addx,&test); pthread_create(&ThreadB,NULL,addy,&test); for(index=0;index<ORANGE_MAX_VALUE;index++) { sum+=test1.a[index]+test1.b[index]; } pthread_join(ThreadA,NULL); pthread_join(ThreadB,NULL); return 0; }
这样改造后,真的能达到我们想要的效果吗?通过 K-Best 测量方法,其结果让我们大失所望,如下图:
图 1. 单线程与多线程耗时对比图
为什么多线程会比单线程更耗时呢?其原因就在于,线程启停以及线程上下文切换都会引起额外的开销,所以消耗的时间比单线程多。
为什么加锁后的三线程比两线程还慢呢?其原因也很简单,那把读写锁就是罪魁祸首。通过 Thread Viewer 也可以印证刚才的结果,实际情况并不是并行执行,反而成了串行执行,如图2:
其中最下面那个线程是主线程,一个是 addx
线程,另外一个是 addy
线程,从图中不难看出,其他两个线程为串行执行。
通过数据分解来划分多线程,还存在另外一种方式,一个线程计算从1到 APPLE_MAX_VALUE/2
的值,另外一个线程计算从 APPLE_MAX_VALUE/2+1
到 APPLE_MAX_VALUE
的值,但本文会弃用这种模型,有兴趣的读者可以试一试。
在采用多线程方法设计程序时,如果产生的额外开销大于线程的工作任务,就没有并行的必要。线程并不是越多越好,软件线程的数量尽量能与硬件线程的数量相匹配。最好根据实际的需要,通过不断的调优,来确定线程数量的最佳值。
其结果再一次大跌眼镜,可能有些人就会越来越糊涂了,怎么不加锁的效率反而更低呢?将在针对 Cache 的优化一节中细细分析其具体原因。
在实际测试过程中,不加锁的三线程方案非常不稳定,有时所花费的时间相差4倍多。
要提高并行程序的性能,在设计时就需要在较少同步和较多同步之间寻求折中。同步太少会导致错误的结果,同步太多又会导致效率过低。尽量使用私有锁,降低锁的粒度。无锁设计既有优点也有缺点,无锁方案能充分提高效率,但使得设计更加复杂,维护操作困难,不得不借助其他机制来保证程序的正确性。
struct apple { unsigned long long a; char c[128]; /*32,64,128*/ unsigned long long b; };
小小的一行代码,尽然带来了如此高的收益,不难看出,我们是用空间来换时间。当然读者也可以采用更简便的方法: __attribute__((__aligned__(L1_CACHE_BYTES))) 来确定 cache 的大小。
如果对加锁三线程方案中的 apple 数据结构也增加一行类似功能的代码,效率也是否会提升呢?性能不会有所提升,其原因是加锁的三线程方案效率低下的原因不是 Cache 失效造成的,而是那把锁。
在多核和多线程程序设计过程中,要全盘考虑多个线程的访存需求,不要单独考虑一个线程的需求。在选择并行任务分解方法时,要综合考虑访存带宽和竞争问题,将不同处理器和不同线程使用的数据放在不同的 Cache 行中,将只读数据和可写数据分离开。
struct apple { unsigned long long a; unsigned long long b; }; struct orange { int a[ORANGE_MAX_VALUE]; int b[ORANGE_MAX_VALUE]; }; inline int set_cpu(int i) { CPU_ZERO(&mask); if(2 <= cpu_nums) { CPU_SET(i,&mask); if(-1 == sched_setaffinity(gettid(),sizeof(&mask),&mask)) { return -1; } } return 0; } void* add(void* x) { if(-1 == set_cpu(1)) { return NULL; } for(sum=0;sum<APPLE_MAX_VALUE;sum++) { ((struct apple *)x)->a += sum; ((struct apple *)x)->b += sum; } return NULL; } int main (int argc, const char * argv[]) { // insert code here... struct apple test; struct orange test1; cpu_nums = sysconf(_SC_NPROCESSORS_CONF); if(-1 == set_cpu(0)) { return -1; } pthread_create(&ThreadA,NULL,add,&test); for(index=0;index<ORANGE_MAX_VALUE;index++) { sum+=test1.a[index]+test1.b[index]; } pthread_join(ThreadA,NULL); return 0; }
测量结果为:
其测量结果正是我们所希望的,但花费的时间还是比单线程的多,其原因与上面分析的类似。
进一步分析不难发现,样例程序大部分时间都消耗在计算 apple 上,如果将计算 a
和 b
的值,分布到不同的 CPU 上进行计算,同时考虑 Cache 的影响,效率是否也会有所提升呢?
从时间上观察,设置亲和力的程序所花费的时间略高于采用 Cache 的三线程方案。由于考虑了 Cache 的影响,排除了一级缓存造成的瓶颈,多出的时间主要消耗在系统调用及内核上,可以通过 time 命令来验证:
#time ./unlockcachemultiprocess real 0m0.834s user 0m1.644s sys 0m0.004s #time ./affinityunlockcacheprocess real 0m0.875s user 0m1.716s sys 0m0.008s |
通过设置 CPU 亲和力来利用多核特性,为提高应用程序性能提供了捷径。同时也是一把双刃剑,如果忽略负载均衡、数据竞争等因素,效率将大打折扣,甚至带来事倍功半的结果。
在进行具体的设计过程中,需要设计良好的数据结构和算法,使其适合于应用的数据移动和处理器的性能特性。
根据以上分析及实验,对所有改进方案的测试时间做一个综合对比,如下图所示:
单线程原始程序平均耗时:1.049046s,最慢的不加锁三线程方案平均耗时:2.217413s,最快的三线程( Cache 为128)平均耗时:0.826674s,效率提升约26%。当然,还可以进一步优化,让效率得到更高的提升。
从上图不难得出结论:采用多核多线程并行设计方案,能有效提高性能,但如果考虑不全面,如忽略带宽、数据竞争及数据同步不当等因素,效率反而降低,程序执行越来越慢。
如果抛开本文开篇时的限制,采用上文曾提到的另外一种数据分解模型,同时结合硬亲和力对样例程序进行优化,测试时间为0.54s,效率提升了92%。
软件优化是一个贯穿整个软件开发周期,从开始设计到最终完成一直进行的连续过程。在优化前,需要找出瓶颈和热点所在。正如最伟大的 C 语言大师 Rob Pike 所说:
如果你无法断定程序会在什么地方耗费运行时间,瓶颈经常出现在意想不到的地方,所以别急于胡乱找个地方改代码,除非你已经证实那儿就是瓶颈所在。
将这句话送给所有的优化人员,和大家共勉。