操作系统缓存全面解析(下)-程序局部性和伪共享问题

183 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第28天,点击查看活动详情

6 程序局部性

分为:

  • 时间局部性

    被访问过一次的内存位置,很可能不远将来会被再次访问

  • 空间局部性

    如果一个内存位置被引用过,那么它邻近位置在不远的将来也有很大概率会被访问。

若程序有很好的局部性,那么在程序运行期间,缓存缺失就很少发生。

利用局部性原理,设计了缓存,把可能会被访问到的少量数据放在缓存中,大大加速CPU访存速度。

虚拟内存的页缓存也同理,未来最有可能会被访问到的页面会被保留在物理内存。所以多级存储结构里,当访问者和被访问者之间的速度不匹配,就是缓存能够发挥作用的场景。同理还有CDN。

修改案例,验证程序局部性对缓存命中率的影响。

#include <stdio.h>
#include <stdlib.h>
 
#define M  10000
#define N  10000
int main( )
{
   printf("%ld",sizeof(long long));
   long long (*a)[N] = (long long(*)[N])calloc(M * N, sizeof(long long));
   
   for(int i = 0; i < M; i++) {
       for(int j = 0; j < N; j++) {
           a[i][j]++;
       }
   }
   return 0;
}
  • 修改迭代次数,方便测试
  • 将之前间隔访问数组中的部分元素修改为顺序访问整个数组,访问方式按二维数组的行逐次访问

测试结果:

# gcc -O0 cache.c
# time ./a.out
8
real 0m1.245s
user 0m0.797s
sys 0m0.449s

按列访问时,即将内层循环条件提到外:

for(int j = 0; j < N; j++) {
    for(int i = 0; i < M; i++) {
        a[i][j]++;
    }
}

运行结果:

# gcc -O0 cache.c
# time ./a.out
8
real 0m2.527s
user 0m1.980s
sys 0m0.548s

性能也2倍劣化,主因当按行访问时地址连续,下次访问的元素和当前大概率在同一cache line(一个元素8字节,而一个cache line容纳8个元素),但按列访问时,由于地址跨度大,下次访问的元素基本不可能还在同一cache line,增加cache line被替换的次数,导致性能劣化。

这次编译项都添加-O0选项,告诉编译器不要进行优化,因为编译器聪明,能识别出这种循环外提的优化,所以要先关掉优化。

因缓存使用不当而引起的性能下降的问题:

7 伪共享

伪共享(false-sharing),当两个线程同时各自修改两个相邻的变量,由于缓存是按缓存块来组织,当一个线程对一个缓存块执行写操作,须使其他线程含有对应数据的缓存块无效。

这样两个线程都会同时使对方的缓存块无效,导致性能下降。

7.1 案例

#include <stdio.h>
#include <pthread.h>
 
struct S{
   long long a;
   long long b;
} s;
 
void *thread1(void *args)
{
    for(int i = 0;i < 100000000; i++){
        s.a++;
    }
    return NULL;
}
 
void *thread2(void *args)
{
    for(int i = 0;i < 100000000; i++){
        s.b++;
    }
    return NULL;
}
 
int main(int argc, char *argv[]) {
    pthread_t t1, t2;
    s.a = 0;
    s.b = 0;
    pthread_create(&t1, NULL, thread1, NULL);
    pthread_create(&t2, NULL, thread2, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("a = %lld, b = %lld\n", s.a, s.b);
    return 0;
}

创建两个线程,分别修改结构体S中的a 、b 变量。a 、b均为long long 类型,都占8字节,所以a 、b 在同一个cache line中,因此会发生为伪共享。

运行结果:

# gcc -Wall false_sharing.c -lpthread
# time ./a.out
a = 100000000, b = 100000000
 
real 0m0.790s
user 0m1.481s
sys 0m0.008s

7.2 解决伪共享

a 、b不要放在同一个cache line,这样两个线程分别操作不同cache line,不会相互影响。

对结构体S修改:

struct S{
   long long a;
   long long nop_0;
   long long nop_1;
   long long nop_2;
   long long nop_3;
   long long nop_4;
   long long nop_5;
   long long nop_6;
   long long nop_7;
   long long b;
} s;

因为a、b中间插入8个long long变量,中间隔64字节,所以a、b肯定会被映射到不同缓存块,程序执行结果:

# gcc -Wall false_sharing.c -lpthread
# time ./a.out
a = 100000000, b = 100000000
 
real 0m0.347s
user 0m0.693s
sys 0m0.001s

性能有一倍的提升。

伪共享是一种缓存缺失问题,并发场景中常见。Java并发库里经常会看到为了解决伪共享而进行的数据填充。

8 总结

缓存是整个存储体系结构的灵魂,它让内存访问的速度接近于寄存器的访问速度。缓存对程序员是透明的,程序员不必使用特定的API接口来操作缓存工作,它是自动工作的。但如果我们的代码写得不好的话,我们就会感受到缓存不能起作用时的性能下降了。

缓存的映射方式包括了直接相连、全相连、组组相连三种。直接相连映射会导致缓存块被频繁替换;而全相连映射可以很大程度上避免冲突,但查询效率低;组组相连映射,与直接相连映射相比,产生冲突的可能性更小,与全相连映射相比,查询效率更高,实现也更简单。

如果要访问的数据不在缓存中,这就是缓存缺失。当发生缓存缺失时,就需要往缓存中加载目标地址的数据。如果缓存空间不足了,就需要对缓存块进行替换,替换的策略多采用LRU策略。

缓存缺失对性能影响非常大。缓存缺失主要包括强制缺失,冲突缺失和容量缺失。为了避免缓存缺失我们一定要注意程序的局部性,虽然编译器会帮我们做很多事情,但编译器还是有很多情况是无法优化的。

伪共享是一类非常典型的缓存缺失问题。它是由于多个线程都反复使对方的缓存块无效,带来的性能下降。为了解决这一类问题,我们可以考虑让多个线程所共同访问的对象,在物理上隔离开,保证它们不会落在同一个缓存块里。