false-sharing原理浅析和测试
绪论
SMP(对称多处理)架构简单的说就是多个CPU核,共享同一个内存和总线。L1 cache也叫芯片缓存,一般是CPU Core私有的,即每个CPU核一个,L2 cache可能是私有的也可能是部分共享的,L3 cache则多数是共享的。false-sharing是在SMP的架构下常见的问题。
false-sharing产生背景及原因
CPU利用cache和内存之间交换数据的最小粒度不是字节,而是称为cache line的一块固定大小的区域,缓存行是内存交换的实际单位。缓存行是2的整数幂个连续字节,一般为32-256个字节,最常见的缓存行大小是64个字节。
在写多线程代码时,为了避免使用锁,通常会采用这样的数据结构:根据线程的数目,安排一个数组, 每个线程一个项,互相不冲突。从逻辑上看这样的设计无懈可击,但是实践的过程可能会发现有些场景下非但没提高执行速度,反而会性能很差,而且年轻司机通常很难定位问题所在。
问题在于cpu的cache line,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享,即false-sharing。
实际案例
在多处理器,多线程情况下,如果两个线程分别运行在不同的CPU上,而其中某个线程修改了cache line中的元素,由于cache一致性的原因,另一个线程的cache line被宣告无效,在下一次访问时会出现一次cache line miss,大量的cache line miss会导致性能的显著下降。究其原因,cache line miss是由于两个线程的Cache line有重合(非共享的变量实际上却共享的使用了同一个cacheline,导致竞争)引起的。
如在Intel Core 2 Duo处理器平台上,L2 cache是由两个core共享的,而L1 data cache是分开的,由两个core分别存取。cache line的大小是64 Bytes。假设有个全局共享结构体变量f由2个线程A和B共享读写,该结构体一共8个字节同时位于同一条cache line上。
struct foo { int x; int y; };
若此时两个线程一个读取f.x另一个读取f.y,即便两个线程的执行是在独立的cpu core上的,实际上结构体对象f被分別读入到两个CPUs的cache line中且该cache line 处于shared状态。此时在核心1上运行的线程A想更新变量X,同时核心2上的线程B想要更新变量Y。
如果核心1上线程A优先获得了所有权,线程A修改f.x会使该CPU core 1 上的这条cache line将变为modified状态,另一个CPU core 2上对应的cache line将变成invalid状态;此时若线程B马上读取f.y,为了确保cache一致性,B所在CPU核上的相应cache line的数据必须被更新;当核心2上线程B优先获得了所有权然后执行更新操作,核心1就要使自己对应的缓存行失效。这会来来回回的经过L3缓存,大大影响了性能。如果互相竞争的核心位于不同的插槽,就要额外横跨插槽连接,若读写的次数频繁,将增大cache miss的次数,严重影响系统性能。
虽然在memory的角度这两种的访问时隔离的,但是由于错误的紧凑地放在了一起,是的两个变量处于同一个缓存行中。每个线程都要去竞争缓存行的所有权来更新变量。可见,false sharing会导致多核处理器上对于缓存行cache line的写竞争,造成严重的系统性能下降,有人将伪共享描述成无声的性能杀手,因为从代码中很难看清楚是否会出现伪共享。
false-sharing避免方法
把每个项凑齐cache line的长度,即可实现隔离,虽然这不可避免的会浪费一些内存。
- 对于共享数组而言,增大数组元素的间隔使得由不同线程存取的数组元素位于不同的cache line上,使一个核上的Cache line修改不会影响其它核;或者在每个线程中创建全局数组的本地拷贝,然后执行结束后再写回全局数组,此方法比较粗暴不优雅。
- 对于共享结构体而言,使每个结构体成员变量按照Cache Line大小(一般64B)对齐。可能需要使用#pragma宏。
注意事项
单线程或单核多线程都不存在这个问题,因为只有一个CPU核也即只有一个L1 Cache,不存在缓存一致性的问题。
示例程序
注意程序中的LEVEL1_DCACHE_LINESIZE宏来自g++编译命令传入的,使用Shell命令getconf LEVEL1_DCACHE_LINESIZE能获取cpu cache line的大小。(有关getconf命令的使用可以自行google)
#include <stdio.h> #include <sys/time.h> #include <time.h> #include <pthread.h> #define PACK __attribute__ ((packed)) typedef int cache_line_int __attribute__((aligned(LEVEL1_DCACHE_LINESIZE))); #ifdef FS struct data { cache_line_int a; cache_line_int b; }; #endif #ifdef NONFS struct data { int a; int b; }; #endif #define MAX_NUM 500000000 void* thread_func_1(void* param) { timeval start, end; gettimeofday(&start, NULL); data* d = (data*)param; for (int i=0; i<MAX_NUM; ++i) { ++d->a; } gettimeofday(&end, NULL); printf("thread 1, time=%d\n", (int)(end.tv_sec-start.tv_sec)*1000000+(int)(end.tv_usec-start.tv_usec)); return NULL; } void* thread_func_2(void* param) { timeval start, end; gettimeofday(&start, NULL); data* d = (data*)param; for (int i=0; i<MAX_NUM; ++i) { ++d->b; } gettimeofday(&end, NULL); printf("thread 2, time=%d\n", (int)(end.tv_sec-start.tv_sec)*1000000+(int)(end.tv_usec-start.tv_usec)); return NULL; } int main() { data d = {a:0, b:0}; printf("sizeof(data) : %d\n", sizeof(data)); pthread_t t1, t2; pthread_create(&t1, NULL, thread_func_1, &d); pthread_create(&t2, NULL, thread_func_2, &d); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("end, a=%d,b=%d\n", d.a, d.b); return 0; }
编译、运行可以看到结果对比:
/*编译指令*/ g++ -o 1 1.cpp -g -Wall -lpthread -DLEVEL1_DCACHE_LINESIZE=`getconf LEVEL1_DCACHE_LINESIZE` -DFS g++ -o 1 1.cpp -g -Wall -lpthread -DLEVEL1_DCACHE_LINESIZE=`getconf LEVEL1_DCACHE_LINESIZE` -DNONFS /*输出结果:*/ thread 1, time=1607430 thread 2, time=1629508
我的腾讯云主机只有一个CPU核,所以运行的结果并没有差异,但是在多核CPU上执行大约相差2~3倍。
注:本文整理自多篇文章,参考文章列表后续补充。