多核Cache一致性 && 伪共享 && atomic的实现和cache相关的部分

经过这么多篇文章的介绍,我们应该已经对Cache有一个比较清晰的认识。Cache会面临哪些问题,我们该怎么处理这些问题。现在我们讨论多核Cache一致性问题。在摩尔定律不太适用的今天,人们试图增加CPU核数以提升系统整体性能。这类系统称之为多核系统(简称MP,Multi-Processor)。我们知道每个CPU都有一个私有的L1 Cache(不细分iCache和dCache)。假设一个2核的系统,我们将会有2个L1 Cache。这就引入了一个问题,不同CPU之间的L1 Cache如何保证一致性呢?首先看下什么是多核Cache一致性问题。

问题背景

首先我们假设2个CPU的系统,并且L1 Cache的cache line大小是64 Bytes。两个CPU都读取0x40地址数据,导致0x40开始的64 Bytes内容分别加载到CPU0和CPU1的私有的cache line。

CPU0执行写操作,写入值0x01。CPU0私有的L1 Cache更新cache line的值。然后,CPU1读取0x40数据,CPU1发现命中cache,然后返回0x00值,并不是CPU0写入的0x01。这就造成了CPU0和CPU1私有L1 Cache数据不一致现象。

按照正确的处理流程,我们应该需要以下方法保证多核Cache一致性:

  • CPU0修改0x40的时候,除了更新CPU0的Cache之外,还应该通知CPU1的Cache更新0x40的数据。
  • CPU0修改0x40的时候,除了更新CPU0的Cache之外,还可以通知CPU1的Cache将0x40地址所在cache line置成invalid。保证CPU1读取数据时不会命中自己的Cache。不命中自己的cache之后,我们有两种选择保证读取到最新的数据。a) 从CPU0的私有cache中返回0x40的数据给CPU1;b) CPU0发出invalid信号后,将写入0x40的数据写回主存,CPU1从主存读取最新的数据。

以上问题就是一个简单的不一致性现象。我们需要保证多核一致性,就需要办法维护一致性。可以有2种方法维护一致性,分别是软件和硬件。软件维护一致性的方法,现在基本没有采用。因为软件维护成本太高,由于维护一致性带来的性能损失抵消一部分cache带来的性能提升。所以现在的硬件会帮我们维护多核Cache一致性,并且对软件是透明的。感兴趣的朋友可以继续往下了解硬件是如何维护多核Cache一致性。

Bus Snooping Protocol

继续以上面的例子说明bus snooping的工作机制。当CPU0修改自己私有的Cache时,硬件就会广播通知到总线上其他所有的CPU。对于每个CPU来说会有特殊的硬件监听广播事件,并检查是否有相同的数据被缓存在自己的CPU,这里是指CPU1。如果CPU1私有Cache已经缓存即将修改的数据,那么CPU1的私有Cache也需要更新对应的cache line。这个过程就称作bus snooping。如下图所示,我们只考虑L1 dCache之间的一致性。

这种bus snooping方法简单,但要需要每时每刻监听总线上的一切活动。我们需要明白的一个问题是不管别的CPU私有Cache是否缓存相同的数据,都需要发出一次广播事件。这在一定程度上加重了总线负载,也增加了读写延迟。针对该问题,提出了一种状态机机制降低带宽压力。这就是MESI protocol(协议)。

MESI Protocol

MESI是现在一种使用广泛的协议,用来维护多核Cache一致性。我们可以将MESI看做是状态机。我们将每一个cache line标记状态,并且维护状态的切换。cache line的状态可以像tag,modify等类似存储。继续以上面的例子说明问题。

  1. 当CPU0读取0x40数据,数据被缓存到CPU0私有Cache,此时CPU1没有缓存0x40数据,所以我们标记cache line状态为Exclusive。Exclusive代表cache line对应的数据仅在数据只在一个CPU的私有Cache中缓存,并且其在缓存中的内容与主存的内容一致。
  2. 然后CPU1读取0x40数据,发送消息给其他CPU,发现数据被缓存到CPU0私有Cache,数据从CPU0 Cache返回给CPU1。此时CPU0和CPU1同时缓存0x40数据,此时cache line状态从Exclusive切换到Shared状态。Shared代表cache line对应的数据在"多"个CPU私有Cache中被缓存,并且其在缓存中的内容与主存的内容一致。
  3. 继续CPU0修改0x40地址数据,发现0x40内容所在cache line状态是Shared。CPU0发出invalid消息传递到其他CPU,这里是CPU1。CPU1接收到invalid消息。将0x40所在的cache line置为Invalid状态。Invalid状态表示表明当前cache line无效。然后CPU0收到CPU1已经invalid的消息,修改0x40所在的cache line中数据。并更新cache line状态为Modified。Modified表明cache line对应的数据仅在一个CPU私有Cache中被缓存,并且其在缓存中的内容与主存的内容不一致,代表数据被修改。
  4. 如果CPU0继续修改0x40数据,此时发现其对应的cache line的状态是Modified。因此CPU0不需要向其他CPU发送消息,直接更新数据即可。
  5. 如果0x40所在的cache line需要替换,发现cache line状态是Modified。所以数据应该先写回主存。

以上是cache line状态改变的举例。我们可以知道cache line具有4中状态,分别是Modified、Exclusive、Shared和Invalid。取其首字母简称MESI。当cache line状态是Modified或者Exclusive状态时,修改其数据不需要发送消息给其他CPU,这在一定程度上减轻了带宽压力。

MESI Protocol Messages

Cache之间数据和状态同步沟通,是通过发送message同步和沟通。MESI主要涉及一下几种message。

  • Read: 如果CPU需要读取某个地址的数据。
  • Read Response: 答复一个读消息,并且返回需要读取的数据。
  • Invalidate: 请求其他CPU invalid地址对应的cache line。
  • Invalidate Acknowledge: 回复invalidate消息,表明对应的cache line已经被invalidate。
  • Read Invalidate: Read + Invalidate消息的组合。
  • Writeback: 该消息包含要回写到内存的地址和数据。

继续以上的例子,我们有5个步骤。现在加上这些message,看看消息是怎么传递的。

  1. CPU0发出Read消息。主存返回Read Response消息,消息包含地址0x40的数据。
  2. CPU1发出Read消息,CPU0返回Read Response消息,消息包含地址0x40数据。
  3. CPU0发出Invalidate消息,CPU1接到消息后,返回Invalidate Acknowledge消息。
  4. 不需要发送任何消息。
  5. 发送Writeback消息。

总结

多核Cache一致性由硬件保证,对软件来说是透明的。因此我们不用再考虑多核Cache一致性问题。另外,现在CPU硬件采用的一致性协议一般是MESI的变种。例如ARM64架构采用的MOESI Protocol。多一种Owned状态。多出来的状态也是为了更好的优化性能。

---------------------------------------------------------------------------------------------------------------------------

我们知道kernel地址空间是所有进程共享的,所以kernel空间的全局变量,任何进程都可以访问。假设有2个全局变量global_A和global_B(类型是long),它们在内存上紧挨在一起,假设cache line size是64Bytes,并且global_A是cache line size对齐。所以global_A和global_B如果同时load到Cache中,一定是落在同一行cache line。就像下面这样。

现在我们知道多核Cache一致性由MESI协议保证。有了这些基础之后,我们现在来思考一个问题,如果我们的系统有2个CPU,每个CPU上运行完全不相干的两个进程task_A和task_B。task_A只会修改global_A变量,task_B只会修改global_B变量。会有什么问题吗?

我们遇到什么问题

最初全局变量global_A和global_B都不在cache中缓存,如下图示意。task_A绑定CPU0运行,task_B绑定CPU1运行。task_A和task_B按照下面的次序分别修改或读取全局变量global_A和global_B。

a) CPU0读取global_A,global_A的数据被缓存到CPU0的私有L1 Cache。由于Cache控制器是以cache line为单位从内存读取数据,所以顺便就会把global_B变量也缓存到Cache。并将cache line置为Exclusive状态。

b) CPU1读取global_B变量,由于global_B被CPU0私有Cache缓存,所以CPU0的L1 Cache负责返回global_B数据到CPU1的L1 Cache。同样global_A也被缓存。此时CPU0和CPU1的cache line状态变成Shared状态。

c) CPU0现在需要修改global_A变量。CPU0发现cache line状态是Shared,所以需要发送invalid消息给CPU1。CPU1将global_A对应的cache line无效。然后CPU0的cache line状态变成Modified并且修改global_A。

d) CPU1现在需要修改global_B变量。此时global_B变量并没有缓存在CPU1私有Cache。所以CPU1会发消息给CPU0,CPU0将global_B数据返回给CPU1。并且会invalid CPU0的cache line。然后global_B对应的CPU1 cache line变成Modified状态,此时CPU1就可以修改global_B了。

如果CPU0和CPU1就这样持续交替的分别修改全局变量global_A和global_B,就会重复c)和d)。意识到问题所在了吗?这就是典型的cache颠簸问题。我们仔细想想,global_A和global_B其实并没有任何的关系,却由于落在同一行cache line的原因导致cache颠簸。我们称这种现象为伪共享(false sharing)。global_A和global_B之间就是伪共享关系,实际并没有共享。我们如何解决伪共享问题呢?

如何解决伪共享

既然global_A和global_B由于在同一行cache line导致了伪共享问题,那么解决方案也很显然易见,我们可以让global_A和global_B不落在一个cache line,这样就可以解决问题。不落在同一行cache line的方法很简单,使global_A和global_B的内存地址都按照cache line size对齐,相当于以空间换时间。浪费一部分内存,换来了性能的提升。当我们把global_A和global_B都cache line size对齐后,我们再思考上面的问题。此时CPU0和CPU1分别修改global_A和global_B互不影响。global_A和global_B对应的cache line状态可以一直维持Modified状态。这样MESI协议就不会在两个CPU间不停的发送消息。降低了带宽压力。

实际应用

在Linux kernel中存在__cacheline_aligned_in_smp宏定义用于解决false sharing问题。

#ifdef CONFIG_SMP
#define __cacheline_aligned_in_smp __cacheline_aligned
#else
#define __cacheline_aligned_in_smp
#endif

我们可以看到在UP(单核)系统上,宏定义为空。在MP(多核)系统下,该宏是L1 cach line size。针对静态定义的全局变量,如果在多核之间竞争比较严重,为了避免影响其他全局变量,可以采用上面的宏使变量cache line对齐,避免false sharing问题

---------------------------------------------------------------------------

atomic是原子的意思,意味"不可分割"的整体。在Linux kernel中有一类atomic操作API。这些操作对用户而言是原子执行的,在一个CPU上执行过程中,不会被其他CPU打断。最常见的操作是原子读改写,简称RMW。例如,atomic_inc()接口。atomic硬件实现和Cache到底有什么关系呢?其实有一点关系,下面会一步步揭晓答案。

问题背景

我们先来看看不使用原子操作的时候,我们会遇到什么问题。我们知道increase一个变量,CPU微观指令级别分成3步操作。1) 先read变量的值到CPU内存寄存器;2) 对寄存器的值递增;3) 将寄存器的值写回变量。例如不使用原子指令的情况下在多个CPU上执行以下increase函数。

int counter = 0;

void increase(void)
{
        counter++;
}

例如2个CPU得系统,初始值counter为0。在两个CPU上同时执行以上increase函数。可能出现如下操作序列:

   +    +----------------------+----------------------+
   |    |    CPU0 operation    |    CPU1 operation    |
   |    +----------------------+----------------------+
   |    | read counter (== 0)  |                      |
   |    +----------------------+----------------------+
   |    |       increase       | read counter (== 0)  |
   |    +----------------------+----------------------+
   |    | write counter (== 1) |       increase       |
   |    +----------------------+----------------------+
   |    |                      | write counter (== 1) |
   |    +----------------------+----------------------+
   V
timeline

我们可以清晰地看到,当CPU0读取counter的值位0后,在执行increase操作的同时,CPU1也读取counter变量,同样counter的值依然是0。随后CPU0和CPU1先后将1的值写入内存。实际上,我们想执行两次increase操作,我应该得到counter值为2。但是实际上得到的是1。这不是我们想要的结果。为了解决这个问题,硬件引入原子自增指令。保证CPU0递增原子变量counter之间,不被其他CPU执行自增指令导致不想要的结果。硬件是如何实现原子操作期间不被打断呢?

Bus Lock

当CPU发出一个原子操作时,可以先锁住Bus(总线)。这样就可以防止其他CPU的内存操作。等原子操作结束,释放Bus。这样后续的内存操作就可以进行。这个方法可以实现原子操作,但是锁住Bus会导致后续无关内存操作都不能继续。实际上,我们只关心我们操作的地址数据。只要我们操作的地址锁住即可,而其他无关的地址数据访问依然可以继续。所以我们引入另一种解决方法。

Cacheline Lock

为了实现多核Cache一致性,现在的硬件基本采用MESI协议(或者MESI变种)维护一致性。因此我们可以借助多核Cache一致性协议MESI实现原子操作。我们知道Cache line的状态处于Exclusive或者Modified时,可以说明该变量只有当前CPU私有Cache缓存了该数据。所以我们可以直接修改Cache line即可更新数据。并且MESI协议可以帮我们保证互斥。当然这不能不能保证RMW操作期间不被打断,因此我们还需要做些手脚实现原子操作。

我们依然假设只有2个CPU的系统。当CPU0试图执行原子递增操作时。a) CPU0发出"Read Invalidate"消息,其他CPU将原子变量所在的缓存无效,并从Cache返回数据。CPU0将Cache line置成Exclusive状态。然后将该cache line标记locked。b) 然后CPU0读取原子变量,修改,最后写入cache line。c) 将cache line置位unlocked。

在步骤a)和c)之间,如果其他CPU(例如CPU1)尝试执行一个原子递增操作,CPU1会发送一个"Read Invalidate"消息,CPU0收到消息后,检查对应的cache line的状态是locked,暂时不回复消息(CPU1会一直等待CPU0回复Invalidate Acknowledge消息)。直到cache line变成unlocked。这样就可以实现原子操作。我们称这种方式为锁cache line。这种实现方式必须要求操作的变量位于一个cache line。

LL/SC

LL/SC(Load-Link/Store-Conditional)是另一种硬件实现方法。例如aarch64架构就采用这种方法。这种方法就不是我们关注的重点了。略过。

总结

借助多核Cache一致性协议可以很方便实现原子操作。当然远不止上面举例说的atomic_inc还有很多其他类似的原子操作,例如原子比较交换等。

发布了125 篇原创文章 · 获赞 57 · 访问量 16万+

猜你喜欢

转载自blog.csdn.net/armlinuxww/article/details/105584811
今日推荐