CPU Cache
CPU Cache可以理解为CPU内部的高速缓存。CPU从内存读取数据时,将要读取的数据及其相邻地址的数据,即至少一个Cache Line,写入Cache,以便后续访问时提高读取速度。
CPU存在多级Cache,级别最高的离CPU最近,访问速度最快容量最小,之后容量逐步增长、速度逐步下降,但它们的访问速度依然要比内存块。每级Cache所存储的全部数据,都是下一级Cache的一部分。
Cache Line称为缓存行,可以理解为CPU Cache中的最小缓存单位。内存与Cache、多级Cache之间的数据传输不是以字节为最小单位,而是以Cache Line为最小单位。目前主流的Cache Line大小都是64字节。
在多核环境下,多个CPU对同一块内存同时读写,就会引起冲突的问题,被称为Cache一致性问题。例如,两个CPU都读取了内存中的某一数据,该数据和相邻数据就会分别写入两个CPU的Cache中,此时CPU1修改了该数据,则会写入自己的Cache,并不会回写内存,CPU2将无法读到新的数据。于是有了MESI协议:当CPU1修改了Cache中的某数据时,其它CPU都会收到通知,它们的相应Cache Line就被置为无效状态,当其它CPU需要访问此数据时,发现自己的Cache Line数据已失效,这是CPU1会立即把数据写到内存中,其它CPU就会立即从内存中读取该数据。
Cache使用LRU作为替换策略,即选择未使用时间最长的替换。
局部性原理
程序局部性原理:是指程序在执行时呈现出局部性规律,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域,具体来说,局部性通常有两种形式:时间局部性和空间局部性。
时间局部性:被CPU访问的数据,短期内还要被继续访问,比如循环、递归、方法反复调用等。
空间局部性:被CPU访问的数据的相邻数据,短期内还要被继续访问,比如顺序执行的代码、连续创建的对象、数组等。
const int row = 1024;
const int col = 1024;
int matrix[row][col];
int sum;
//按行遍历
for (int r=0; r<row; r++) {
for (int c=0; c<col; c++) {
sum += matrix[r][c];
}
}
//按列遍历
for (int c=0; c<col; c++) {
for (int r=0; r<row; r++) {
sum += matrix[r][c];
}
}
根据空间局部性原理,访问内存时会把相邻的数据也加载到Cache中,下次访问相邻数据时Cache的命中率极高,速度自然提升不少。
伪共享False Sharing
Cache系统中是以Cache Line作为存储单位的,当多CPU各自的线程修改相互独立的变量时,如果这些变量恰好在同一个Cache Line中,由于多核间的Cache一致性协议,会导致Cache Line在多核间同步,如此影响了运行效率,这就是伪共享。
struct s {
int a;
int b;
}
比如上面这个结构体,线程1读写a,线程2读写b,那么两个线程就有机会在不同的核,于是产生Cache Line同步行为来回颠簸。但是,如果把a和b之间padding一些区域,让它们处在不同的Cache Line,就可以互不影响了。
struct s {
int a;
char padding[cacheline_size - sizeof(int)];
int b;
}
除此之外,可以在结构体尾部填充padding,以使本结构体数据在一个独立Cache Line。
另外一种技术是使用编译指示,来强制使变量对齐。代码显式声明编译器使用__declspec( align(n) ), 此处 n=64,按照 cache line 边界对齐。
__declspec (align(64)) int thread1_global_variable;
__declspec (align(64)) int thread2_global_variable;
那么,在实际的生产开发过程中,我们一定要通过缓存行填充去解决掉潜在的伪共享问题吗?
其实并不一定。首先,我们暂时无法从系统层面上通过工具来探测伪共享事件。其次,不同类型的计算机具有不同的微架构,如果涉及到跨平台,那就更难以把握了。一个确切的填充方案只适用于一个特定的操作系统。还有,缓存的资源是有限的,如果填充会浪费珍贵的 cache 资源,并不适合大范围应用。最后,目前主流的 Intel 微架构 CPU 的 L1 缓存,已能够达到 80% 以上的命中率。
综上所述,并不是每个系统都适合花大量精力去解决潜在的伪共享问题。