cpu cache一致性和内存屏障机制

在这里插入图片描述

1、cache 局部性原理:引入 Cache 的理论基础是程序局部性原理,包括时间局部性和空间局部性。即最近被CPU访问的数据,短期内CPU 还要访问(时间);被 CPU 访问的数据附近的数据,CPU 短期内还要访问(空间)。因此如果将刚刚访问过的数据缓存在Cache中,那下次访问时,可以直接从Cache中取,其速度可以得到数量级的提高。比如对一维数组来说,访问了地址x上的元素,那么以后访问地址x+1、x+2上元素的可能性就比较高;现在访问的数据,在不久之后再次被访问的可能性也比较高。局部性分为“时间局部性”和“空间局部性”,时间局部性是指当前被访问的数据随后有可能访问到;空间局部性是指当前访问地址附近的地址可能随后被访问。处理器通过在内存和核心之间增加缓存以利用局部性增强程序性能,这样可以用远低于缓存的价格换取接近缓存的速度。CPU接收到指令后,它会最先向CPU中的一级缓存(L1 Cache)去寻找相关的数据,然一级缓存是与CPU同频运行的,但是由于容量较小,所以不可能每次都命中。这时CPU会继续向下一级的二级缓存(L2 Cache)寻找,同样的道理,当所需要的数据在二级缓存中也没有的话,会继续转向L3 Cache、内存(主存)和硬盘.

2、CacheLine 是 CPU 和主存之间数据传输的最小单位。Cache Line 常见大小为64字节,会在传输数据的时候,从内存中连续取64字节的数据。

3、cache写机制: Write-through(直写模式)在数据更新时,同时写入缓存Cache和后端存储。此模式的优点是操作简单;缺点是因为数据修改需要同时写入存储,数据写入速度较慢。Write-back(回写模式)在数据更新时只写入缓存Cache。只在数据被替换出缓存时,被修改的缓存数据才会被写到后端存储。此模式的优点是数据写入速度快,因为不需要写存储;缺点是一旦更新后的数据未被写入存储时出现系统掉电的情况,数据将无法找回。

4、cache 一致性: 多个处理器对某个内存块同时读写,会引起冲突的问题,这也被称为Cache一致性问题。Cache一致性问题出现的原因是在一个多处理器系统中,多个处理器核心都能够独立地执行计算机指令,从而有可能同时对某个内存块进行读写操作,并且由于我们之前提到的回写和直写的Cache策略,导致一个内存块同时可能有多个备份,有的已经写回到内存中,有的在不同的处理器核心的一级、二级Cache中。由于Cache缓存的原因,我们不知道数据写入的时序性,因而也不知道哪个备份是最新的。还有另外一个一种可能,假设有两个线程A和B共享一个变量,当线程A处理完一个数据之后,通过这个变量通知线程B,然后线程B对这个数据接着进行处理,如果两个线程运行在不同的处理器核心上,那么运行线程B的处理器就会不停地检查这个变量,而这个变量存储在本地的Cache中,因此就会发现这个值总也不会发生变化。

5、cache MESI Protocol: 为了正确性,一旦一个核心更新了内存中的内容,硬件就必须要保证其他的核心能够读到更新后的数据。目前大多数硬件采用的策略或协议是MESI或基于MESI的变种。MESI是现在一种使用广泛的协议,用来维护多核Cache一致性。我们可以将MESI看做是状态机。将每一个cacheline标记状态,并且维护状态的切换。
M代表更改(modified),该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
E代表排除(exclusive),表示缓存的数据只被当前的核心所缓存;
S代表共享(shared),表示缓存的数据还被其他核心缓存;
I代表无效(invalid),表示缓存中的数据已经失效,即其他核心更改了数据。

​ 当内核需要访问的数据不在本Cache中,而其它Cache有这份数据的备份时,本Cache既可以从内存中导入数据,也可以从其它Cache中导入数据,不同的处理器会有不同的选择。MESI协议为了使自己更加通用,没有定义这些细节,只定义了状态之间的迁移。

5、cache miss: CPU要访问的数据在Cache中有缓存,称为“命中” (Hit),反之则称为“缺失” (Miss)。

6、cache 竞争:没有任何竞争或只被一个线程访问的原子操作是比较快的,“竞争”指的是多个线程同时访问同一个cacheline。现代CPU为了以低价格获得高性能,大量使用了cache,并把cache分了多级。其中L1和L2 cache为每个核心独有,L3则所有核心共享。一个核心写入自己的L1 cache是极快的(4 cycles, ~2ns),但当另一个核心读或写同一处内存时,它得确认看到其他核心中对应的cacheline。对于软件来说,这个过程是原子的,不能在中间穿插其他代码,只能等待CPU完成一致性同步,这个复杂的硬件算法使得原子操作会变得很慢,在E5-2620上竞争激烈时fetch_add会耗费700纳秒左右。访问被多个线程频繁共享的内存往往是比较慢的。比如像一些场景临界区看着很小,但保护它的spinlock性能不佳,因为spinlock使用的exchange, fetch_add等指令必须等待最新的cacheline,看上去只有几条指令,花费若干微秒并不奇怪。要提高性能,就要避免让CPU频繁同步cacheline。这不单和原子指令本身的性能有关,还会影响到程序的整体性能。最有效的解决方法很直白:尽量避免共享

  • 一个依赖全局多生产者多消费者队列(MPMC)的程序难有很好的多核扩展性,因为这个队列的极限吞吐取决于同步cache的延时,而不是核心的个数。最好是用多个SPMC或多个MPSC队列,甚至多个SPSC队列代替,在源头就规避掉竞争。
  • 另一个例子是计数器,如果所有线程都频繁修改一个计数器,性能就会很差,原因同样在于不同的核心在不停地同步同一个cacheline。如果这个计数器只是用作打打日志之类的,那我们完全可以让每个线程修改thread-local变量,在需要时再合并所有线程中的值,性能可能有几十倍的差别

7、false sharing:一个相关的编程陷阱是false sharing:对那些不怎么被修改甚至只读变量的访问,由于同一个cacheline中的其他变量被频繁修改,而不得不经常等待cacheline同步而显著变慢了。多线程中的变量尽量按访问规律排列,频繁被其他线程修改的变量要放在独立的cacheline中。要让一个变量或结构体按cacheline对齐。

8、cache对齐: 计算机将数据从主存读入Cache时,是把要读取数据附近的一部分数据都读取进来
这样一次读取的一组数据就叫做CacheLine,每一级缓存中都能放很多的CacheLine。cache line 对齐主要是为了避免false share。简单来说:内存读写的最小单位有大小限制的,一般为64字节,也就是说如果某条指令读写了一个字节内存,那么在内存操作的时候都会把这1字节所在附近的64字节读写一次,这个问题单线程没问题,多线程的话如果同时读写64字节附近的内存会导致读写线程缓存的频繁刷新,影响性能;多并发编程中的临界区变量属于写频率高的变量,如锁中的lockword,会由于上锁和释放锁等操作不停的修改状态,每次修改都会导致其他core中的CACHE_LINE失效。避免此类数据结构与其他变量间的干扰,会在应用中对该CACHELINE做对齐操作,使得该变量独占一个CACHELINE。
9、数据结构对齐调整: 在设计数据结构的时候,应该尽量将只读数据与读写数据分开,并具尽量将同一时间访问的数据组合在一起。这样 CPU 能一次将需要的数据读入。数据跨越两个cache line,就意味着两次load或者两次store。如果数据结构是cache line对齐的, 就有可能减少一次读写。数据结构的首地址cache line对齐,意味着可能有内存浪费(特别是 数组这样连续分配的数据结构),所以需要在空间和时间两方面权衡。

//这样的数据结构就很不利。
Struct __a
{
    
    
   Int id; // 不易变
   Int factor;// 易变
   Char name[64];// 不易变
   Int value;// 易变
}//在 X86 下,可以试着修改和调整它

Struct __a
{
    
    
   Int id; // 不易变
Char name[64];// 不易变
Char __Align[32sizeof(int)+sizeof(name)*sizeof(name[0])%32]
   Int factor;// 易变
   Int value;// 易变
Char __Align2[322* sizeof(int)%32]
} 

10、内存屏障: 每个CPU都会有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。 用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样。内存屏障有两个作用:阻止屏障两侧的指令重排序;强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。

  • 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;

  • 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。


11、volatile:

  • volatile的内存屏障策略非常严格保守:

在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

  • 由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。

12、brpc中内存屏障的解读:
仅靠原子技术实现不了对资源的访问控制,即使简单如spinlock引用计数,看上去正确的代码也可能会crash。这里的关键在于重排指令导致了读写顺序的变化。只要没有依赖,代码中在后面的指令就可能跑到前面去,编译器CPU都会这么做。

这么做的动机非常自然,CPU要尽量塞满每个cycle,在单位时间内运行尽量多的指令。如上节中提到的,访存指令在等待cacheline同步时要花费数百纳秒,最高效地自然是同时同步多个cacheline,而不是一个个做。一个线程在代码中对多个变量的依次修改,可能会以不同的次序同步到另一个线程所在的核心上。不同线程对数据的需求不同,按需同步也会导致cacheline的读序和写序不同。

如果其中第一个变量扮演了开关的作用,控制对后续变量的访问。那么当这些变量被一起同步到其他核心时,更新顺序可能变了,第一个变量未必是第一个更新的,然而其他线程还认为它代表着其他变量有效,去访问了实际已被删除的变量,从而导致未定义的行为。比如下面的代码片段:

// Thread 1
// ready was initialized to false
p.init();
ready = true;
// Thread2
if (ready) {
    p.bar();
}

从人的角度,这是对的,因为线程2在ready为true时才会访问p,按线程1的逻辑,此时p应该初始化好了。但对多核机器而言,这段代码可能难以正常运行:

  • 线程1中的ready = true可能会被编译器或cpu重排到p.init()之前,从而使线程2看到ready为true时,p仍然未初始化。这种情况同样也会在线程2中发生,p.bar()中的一些代码可能被重排到检查ready之前。
  • 即使没有重排,ready和p的值也会独立地同步到线程2所在核心的cache,线程2仍然可能在看到ready为true时看到未初始化的p。

注:x86/x64的load带acquire语意,store带release语意,上面的代码刨除编译器和CPU因素可以正确运行。

通过这个简单例子,你可以窥见原子指令编程的复杂性了吧。为了解决这个问题,CPU和编译器提供了memory fence,让用户可以声明访存指令间的可见性(visibility)关系,boost和C++11对memory fence做了抽象,总结为如下几种memory order.

memory order 作用
memory_order_relaxed 没有fencing作用
memory_order_consume 后面依赖此原子变量的访存指令勿重排至此条指令之前
memory_order_acquire 后面访存指令勿重排至此条指令之前
memory_order_release 前面访存指令勿重排至此条指令之后。当此条指令的结果对其他线程可见后,之前的所有指令都可见
memory_order_acq_rel acquire + release语意
memory_order_seq_cst acq_rel语意外加所有使用seq_cst的指令有严格地全序关系

有了memory order,上面的例子可以这么更正:

// Thread1
// ready was initialized to false
p.init();
ready.store(true, std::memory_order_release);
// Thread2
if (ready.load(std::memory_order_acquire)) {
    p.bar();
}

线程2中的acquire和线程1的release配对,确保线程2在看到ready==true时能看到线程1 release之前所有的访存操作。

注意,memory fence不等于可见性,即使线程2恰好在线程1在把ready设置为true后读取了ready也不意味着它能看到true,因为同步cache是有延时的。memory fence保证的是可见性的顺序:“假如我看到了a的最新值,那么我一定也得看到b的最新值”。

一个相关问题是:如何知道看到的值是新还是旧?一般分两种情况:

  • 值是特殊的。比如在上面的例子中,ready=true是个特殊值,只要线程2看到ready为true就意味着更新了。只要设定了特殊值,读到或没有读到特殊值都代表了一种含义。
  • 总是累加。一些场景下没有特殊值,那我们就用fetch_add之类的指令累加一个变量,只要变量的值域足够大,在很长一段时间内,新值和之前所有的旧值都会不相同,我们就能区分彼此了。

猜你喜欢

转载自blog.csdn.net/u014618114/article/details/109744799