
小编说:除了CPU,内存大概是最重要的计算资源了。基本成为分布式系统标配的缓存中间件、高性能的数据处理系统及当前流行的大数据平台,都离不开对计算机内存的深入理解与巧妙使用。本文将探索这个让人感到熟悉又复杂的领域。
首先,我们澄清几个容易让人混淆的CPU术语。
然后,我们先从第1个非常简单的问题开始:CPU可以直接操作内存吗?可能99%的程序员会不假思索地回答:“肯定的,不然程序怎么跑。”如果理性地分析一下,你会发现这个回答有问题:CPU与内存条是独立的两个硬件,而且CPU上也没有插槽和连线可以让内存条挂上去,也就是说,CPU并不能直接访问内存条,而是要通过主板上的其他硬件(接口)来间接访问内存条。
第2个问题:CPU的运算速度与内存条的访问速度之间的差距究竟有多大?这个差距跟王健林“先挣它个一个亿的”小目标和“普通人有车有房”的宏大目标之间的差距相比,是更大还是更小呢?答案是“差不多”。通常来说,CPU的运算速度与内存访问速度之间的差距不过是100倍,假如有100万元人民币就可以有房(贷)有车(贷)了,那么其100倍刚好是一亿元人民币。
既然CPU的速度与内存的速度还是存在高达两个数量级的巨大鸿沟,所以它们注定不能“幸福地在一起”,于是CPU的亲密伴侣Cache闪亮登场。与来自DRAM家族的内存(Memory)出身不同,Cache来自SRAM家族。DRAM与SRAM最简单的区别是后者特别快,容量特别小,电路结构非常复杂,造价特别高。
造成Cache与内存之间巨大性能差距的主要原因是工作原理和结构不同,如下所述。
Cache是被集成到CPU内部的一个存储单元,一级Cache(L1 Cache)通常只有32~64KB的容量,这个容量远远不能满足CPU大量、高速存取的需求。此外,由于存储性能的大幅提升往往伴随着价格的同步飙升,所以出于对整体成本的控制,现实中往往采用金字塔形的多级Cache体系来实现最佳缓存效果,于是出现了二级Cache(L2 Cache)及三级Cache(L3 Cache),每一级Cache都牺牲了部分性能指标来换取更大的容量,目的是缓存更多的热点数据。以Intel家族Intel Sandy Bridge架构的CPU为例,其L1 Cache容量为64KB,访问速度为1ns左右;L2 Cache容量扩大4倍,达到256KB,访问速度则降低到3ns左右;L3 Cache的容量则扩大512倍,达到32MB,访问速度也下降到12ns左右,即使如此,也比访问主存的100ns(40ns+65ns)快一个数量级。此外,L3 Cache是被一个Socket上的所有CPU Core共享的,其实最早的L3 Cache被应用在AMD发布的K6-III处理器上,当时的L3 Cache受限于制造工艺,并没有被集成到CPU内部,而是集成在主板上。
下面给出了Intel Sandy Bridge CPU的架构图,我们可以看出,CPU如果要访问内存中的数据,则要经过L1、L2与L3这三道关卡后才能抵达目的地,这个过程并不是“皇上”(CPU)亲自出马,而是交由3个级别的贵妃(Cache)们层层转发“圣旨”(内存指令),最终抵达“后宫”(内存)。

现在恐怕很难再找到单核心的CPU了,即使是我们的智能手机,也至少是双核的了,那么问题就来了:在多核CPU的情况下,如何共享内存?
如果擅长多线程高级编程,那么你肯定会毫不犹豫地给出以下伪代码解决方案:
synchronized(memory)
{
writeAddress(….)
}如果真这么简单,那么这个世界上就不会只剩下两家独大的主流CPU制造商了,而且可怜的AMD一直被Intel“吊打”。
多核心CPU共享内存的问题也被称为Cache一致性问题,简单地说,就是多个CPU核心所看到的Cache数据应该是一致的,在某个数据被某个CPU写入自己的Cache(L1 Cache)以后,其他CPU都应该能看到相同的Cache数据;如果自己的Cache中有旧数据,则抛弃旧数据。考虑到每个CPU有自己内部独占的Cache,所以这个问题与分布式Cache保持同步的问题是同一类问题。来自Intel的MESI协议是目前业界公认的Cache一致性问题的最佳方案,大多数SMP架构都采用了这一方案,虽然该协议是一个CPU内部的协议,但由于它对我们理解内存模型及解决分布式系统中的数据一致性问题有重要的参考价值,所以在这里我们对它进行简单介绍。
首先,我们说说Cache Line,如果有印象的话,则你会发现I/O操作从来不以字节为单位,而是以“块”为单位,这里有两个原因:首先,因为I/O操作比较慢,所以读一个字节与一次读连续N个字节所花费的时间基本相同;其次,数据访问往往具有空间连续性的特征,即我们通常会访问空间上连续的一些数据。举个例子,访问数组时通常会循环遍历,比如查找某个值或者进行比较等,如果把数组中连续的几个字节都读到内存中,那么CPU的处理速度会提升几倍。对于CPU来说,由于Memory也是慢速的外部组件,所以针对Memory的读写也采用类似I/O块的方式就不足为奇了。实际上,CPU Cache里的最小存储单元就是Cache Line,Intel CPU的一个Cache Line存储64个字节,每一级Cache都被划分为很多组Cache Line,典型的情况是4条Cache Line为一组,当Cache从Memory中加载数据时,一次加载一条Cache Line的数据。下图给出了Cache的结构。

每个Cache Line的头部有两个Bit来表示自身的状态,总共有4种状态。
MESI协议是用Cache Line的上述4种状态命名的,对Cache的读写操作引发了Cache Line的状态变化,因而可以理解为一种状态机模型。但MESI的复杂和独特之处在于状态有两种视角:一种是当前读写操作(Local Read/Write)所在CPU看到的自身的Cache Line状态及其他CPU上对应的Cache Line状态;另一种是一个CPU上的Cache Line状态的变迁会导致其他CPU上对应的Cache Line的状态变迁。如下所示为MESI协议的状态图。

结合这个状态图,我们深入分析MESI协议的一些实现细节。某个CPU(CPU A)发起本地读请求(Local Read),比如读取某个内存地址的变量,如果此时所有CPU的Cache中都没加载此内存地址,即此内存地址对应的Cache Line为无效状态(Invalid),则CPU A中的Cache会发起一个到Memory的内存Load指令,在相应的Cache Line中完成内存加载后,此Cache Line的状态会被标记为Exclusive。接下来,如果其他CPU(CPU B)在总线上也发起对同一个内存地址的读请求,则这个读请求会被CPU A嗅探到(SNOOP),然后CPU A在内存总线上复制一份Cache Line作为应答,并将自身的Cache Line状态改为Shared,同时CPU B收到来自总线的应答并保存到自己的Cache里,也修改对应的Cache Line状态为Shared。某个CPU(CPU A)发起本地写请求(Local Write),比如对某个内存地址的变量赋值,如果此时所有CPU的Cache中都没加载此内存地址,即此内存地址对应的Cache Line为无效状态(Invalid),则CPU A中的Cache Line保存了最新的内存变量值后,其状态被修改为Modified。随后,如果CPU B发起对同一个变量的读操作(Remote Read),则CPU A在总线上嗅探到这个读请求以后,先将Cache Line里修改过的数据回写(Write Back)到Memory中,然后在内存总线上复制一份Cache Line作为应答,最后将自身的Cache Line状态修改为Shared,由此产生的结果是CPU A与CPU B里对应的Cache Line状态都为Shared。
以上面第2条内容为基础,CPU A发起本地写请求并导致自身的Cache Line状态变为Modified,如果此时CPU B发起同一个内存地址的写请求(Remote Write),则我们看到状态图里此时CPU A的Cache Line状态为Invalid,其原因如下。
CPU B此时发出的是一个特殊的请求——读并且打算修改数据,当CPU A从总线上嗅探到这个请求后,会先阻止此请求并取得总线的控制权(Takes Control of Bus),随后将Cache Line里修改过的数据回写道Memory中,再将此Cache Line的状态修改为Invalid(这是因为其他CPU要改数据,所以没必要改为Shared)。与此同时,CPU B发现之前的请求并没有得到响应,于是重新发起一次请求,此时由于所有CPU的Cache里都没有内存副本了,所以CPU B的Cache就从Memory中加载最新的数据到Cache Line中,随后修改数据,然后改变Cache Line的状态为Modified。
如果内存中的某个变量被多个CPU加载到各自的Cache中,从而使得变量对应的Cache Line状态为Shared,若此时某个CPU打算对此变量进行写操作,则会导致所有拥有此变量缓存的CPU的Cache Line状态都变为Invalid,这是引发性能下降的一种典型Cache Miss问题。
在理解了MESI协议以后,我们明白了一个重要的事实,即存在多个处理器时,对共享变量的修改操作会涉及多个CPU之间的协调问题及Cache失效问题,这就引发了著名的“Cache伪共享”问题。
下面我们说说缓存命中的问题。如果要访问的数据不在CPU的运算单元里,则需要从缓存中加载,如果缓存中恰好有此数据而且数据有效,就命中一次(Cache Hit),反之产生一次Cache Miss,此时需要从下一级缓存或主存中再次尝试加载。根据之前的分析,如果发生了Cache Miss,则数据的访问性能瞬间下降很多!在我们需要大量加载运算的情况下,数据结构、访问方式及程序算法方面是否符合“缓存友好”的设计,就成为“量变引起质变”的关键性因素了。这也是为什么最近,国外很多大数据领域的专家都热衷于研究设计和采用新一代的数据结构和算法,而其核心之一就是“缓存友好”。
Cache伪共享问题是编程中真实存在的一个问题,考虑如下所示的Java Class结构:
class MyObject
{
private long a;
private long b;
private long c;
}按照Java规范,MyObject的对象是在堆内存上分配空间存储的,而且a、b、c三个属性在内存空间上是近邻,如下所示。
a(8个字节) | b(8个字节) | c(8个字节) |
|---|
我们知道,X86的CPU中Cache Line的长度为64字节,这也就意味着MyObject的3个属性(长度之和为24字节)是完全可能加载在一个Cache Line里的。如此一来,如果我们有两个不同的线程(分别运行在两个CPU上)分别同时独立修改a与b这两个属性,那么这两个CPU上的Cache Line可能出现如下所示的情况,即a与b这两个变量被放入同一个Cache Line里,并且被两个不同的CPU共享。

根据MESI协议的相关知识,我们知道,如果Thread 0要对a变量进行修改,则因为CPU 1上有对应的Cache Line,这会导致CPU 1的Cache Line无效,从而使得Thread 1被迫重新从Memory里获取b的内容(b并没有被其他CPU改变,这样做是因为b与a在一个Cache Line里)。同样,如果Thread 1要对b变量进行修改,则同样导致Thread 0的Cache Line失效,不得不重新从Memory里加载a。如此一来,本来是逻辑上无关的两个线程,完全可以在两个不同的CPU上同时执行,但阴差阳错地共享了同一个Cache Line并相互抢占资源,导致并行成为串行,大大降低了系统的并发性,这就是所谓的Cache伪共享。
解决Cache伪共享问题的方法很简单,将a与b两个变量分到不同的Cache Line里,通常可以用一些无用的字段填充a与b之间的空隙。由于伪共享问题对性能的影响比较大,所以JDK 8首次提供了正式的普适性的方案,即采用@Contended注解来确保一个Object或者Class里的某个属性与其他属性不在一个CacheLine里,下面的VolatileLong的多个实例之间就不会产生Cache伪共享的问题:
@Contended
class VolatileLong {
public volatile long value = 0L;
}本文分享自 博文视点Broadview 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!