存储体系结构

根据程序的空间局部性和时间局部性原理,一个处理得当的程序,缓存命中率要想达到 70~90% 并非难事。因此,在存储系统中加入缓存,可以让整个存储系统的性能接近寄存器。image.png
缓存通常是由 SRAM(静态随机存储)组成的,它的本质是一种时序逻辑电路,具体的每个单元(比特)由锁存器构成,锁存器的功能就是让电路具有记忆功能。SRAM 的单位造价还是比较高的,而且要远高于内存的组成结构 DRAM(动态随机存储)的造价。这是因为要实现一个锁存器需要六个晶体管,而实现一个 DRAM 仅需要一个晶体管和一个电容,但是 DRAM 因为结构简单,单位面积可以存放更多数据,所以更适合做内存。
为了兼顾这两者的优缺点,于是它们中间需要加入缓存。在制造方面,DRAM 因为有电容的存在,不再是单纯的逻辑电路,所以不能采用 CMOS 工艺制造,而 SRAM 可以采用。这也是为什么缓存可以集成到芯片内部,而内存是和芯片只能分开制造的原因。
在多核芯片上,缓存集成的方式主要有以下三种:

  • 集中式缓存:一个缓存和所有处理器直接相连,多个核共享这一个缓存;
  • 分布式缓存:一个处理器仅和一个缓存相连,一个处理器对应一个缓存;
  • 混合式缓存:在 L3 采用集中式缓存,在 L1 和 L2 采用分布式缓存(L1 缓存还分指令缓存以及数据缓存)。

image.png

缓存的工作原理

cache line 是缓存进行管理的一个最小存储单元,也叫缓存块。从内存向缓存加载数据也是按缓存块进行加载的,一个缓存块和一个内存中相同容量的数据块(下称内存块)对应。
image.png

上图的小方框就是一个 cache line,而整个缓存的容量=组数×路数×缓存块大小

为了简化寻址方式,同一内存块总是会被映射到在某一个固定的组里面,但可以放在该组内的任意路上。
根据缓存中组数和路数的不同,缓存的映射方式通常分为三类:

  • 直接相连映射:缓存只有一路(映射到同一组的内存块由于冲突导致频繁地替换);
  • 全相连映射:缓存只有一个组,所有的内存块都放在这一个组的不同路上(在很大程度上能避免冲突,但要查询某个内存块时,需要逐个遍历每个路,而且电路实现也比较困难);
  • 组相连映射:缓存同时有多个组和多个路。

缓存块的内部结构:
image.png

其中,V(valid)表示这个缓存块是否有效,M(modified)表示这个缓存块是否被修改,B 表示缓存块的 bit 个数

假设要寻址一个 32 位的地址,缓存块的大小是 64 字节,缓存组织方式是 4 路组相连,缓存大小是 8K。经过计算可以得到缓存一共有 32 个组(8×1024÷64÷4=32)。那么对于任意一个 32 位的地址 Addr ,它映射到缓存的组号(set index)通过 Addr 的第 6~10 位来表示( (Addr >> 6) & 0x1F),而 Addr 的低 6 位是缓存块的内部偏移(26 为 64 字节),那么高 21 位是用来干嘛的呢?确定需要被映射到哪个组之后(第 6~10 位相同),只需比较 Addr 的高 21 位与 tag 是否相等。如果相等,就说明该内存块已经载入到缓存中;如果没有匹配的 tag,就说明缓存缺失,需要将内存块放到该组的一个空闲缓存块上;如果所有路的缓存块都正在被使用,那么需要选择一个缓存块(通常替换策略采用 LRU 算法),将其移出缓存,把新的内存块载入。
image.png

缓存对程序性能的影响

缓存性能主要取决于缓存命中率,也就说缓存缺失(cache miss)越少,缓存的性能就越好。一般来说,引起缓存缺失的类型主要有三种:

  • 强制缺失:第一次将数据块读入到缓存所产生的缺失,也被称为冷缺失(cold miss),因为当发生缓存缺失时,缓存是空的(冷的);
  • 冲突缺失:由于缓存的相连度有限导致的缺失(同一组内路数有限); ```c // cache.c

    include

    include

define N 100000000

define M 10000000

int main() { printf(“%ld”, sizeof(long long)); long long a = (long long())calloc(M, sizeof(long long));

  1. for (int i = 0; i < N; i++)
  2. {
  3. for (int j = 0; j < 4096; j += 512) # 1
  4. // for (int j = 0; j < 8192; j += 512) # 2
  5. {
  6. a[j]++; // 每次 a[j] 所对应的内存块都会映射到同一组内
  7. }
  8. }
  9. return 0;

}

  1. ```bash
  2. $ getconf -a |grep CACHE
  3. LEVEL1_ICACHE_SIZE 32768
  4. LEVEL1_ICACHE_ASSOC 8
  5. LEVEL1_ICACHE_LINESIZE 64
  6. LEVEL1_DCACHE_SIZE 32768
  7. LEVEL1_DCACHE_ASSOC 8
  8. LEVEL1_DCACHE_LINESIZE 64
  9. LEVEL2_CACHE_SIZE 262144
  10. LEVEL2_CACHE_ASSOC 4
  11. LEVEL2_CACHE_LINESIZE 64
  12. LEVEL3_CACHE_SIZE 6291456
  13. LEVEL3_CACHE_ASSOC 12
  14. LEVEL3_CACHE_LINESIZE 64
  15. LEVEL4_CACHE_SIZE 0
  16. LEVEL4_CACHE_ASSOC 0
  17. LEVEL4_CACHE_LINESIZE 0
  18. // 每组有 8 路
  19. $ gcc cache.c
  20. $ time ./a.out
  21. 8./a.out 1.73s user 0.00s system 99% cpu 1.727 total
  22. 8./a.out 14.18s user 0.00s system 99% cpu 14.176 total

虽然运算量只增加了一倍,但运行时间却增加了 8 倍,相当于性能劣化 4 倍。劣化的根本原因就是当 i > 4096 时,也就是访问 4096 之后的元素,同一组的 cache line 已经全部使用,必须进行替换,并且之后的每次访问都会发生冲突,导致缓存块频繁替换,性能劣化严重

  • 容量缺失:由于缓存大小有限导致的缺失(当程序运行的某段时间内,访问地址的范围超过缓存大小很多)。

此外,编程时为避免缓存缺失,还需考虑程序的局部性以及伪共享。
伪共享(false-sharing)的意思是说,当两个线程同时各自修改两个相邻的变量,由于缓存是按缓存块来组织的,当一个线程对一个缓存块执行写操作时,必须使其他线程含有对应数据的缓存块无效。这样两个线程都会同时使对方的缓存块无效,导致性能下降。

解决伪共享的办法是,通过数据填充使得原本相邻的变量不位于同一个 cache line 中,这样两个线程分别操作不同的 cache line 不会相互影响。

MESI 协议

天下没有免费的午餐,缓存在带来性能提升的同时,也引入了缓存一致性问题。缓存一致性问题的产生主要是因为在多核体系结构中,如果有一个 CPU 修改了内存中的某个值,那么必须有一种机制保证其他 CPU 能够观察到这个修改。

  1. global sum = 0
  2. // Thread1:
  3. sum += 3
  4. // Thread2:
  5. sum += 5

image.png

为了保证缓存一致性,必须解决两个问题,分别是写传播(第 3 步,一个处理器对缓存中的值进行了修改,需要通知其他处理器,即缓存一致性协议)和事务串行化(第 5 和第 6 步,多个处理器对同一个值进行修改,在同一时刻只能有一个处理器写成功,必须保证写操作的原子性,多个写操作必须串行执行)

在缓存一致性的问题中,关于 CPU 修改缓存后,这些修改什么时候同步到内存的策略至关重要:

  • 写回:当 CPU 采取写回策略时,对缓存的修改不会立刻传播到主存,只有当缓存块被替换时,这些被修改的缓存块,才会写回并覆盖内存中过时的数据;
  • 写直达:当 CPU 采取写直达策略时,缓存中任何一个字节的修改,都会立刻传播到内存。

同时,当某个 CPU 修改了缓存中的某个值时,其他 CPU 的缓存中所持有该数据副本的更新策略也有两种:

  • 写更新:如果 CPU 采取写更新策略,每次向缓存中写入新的值,该 CPU 都必须发起一次 CPU 核间总线请求,通知其他 CPU 将对应的缓存值更新为刚写入的值,所以写更新会很占用总线带宽。如果一个 CPU 缓存执行了写操作,其他 CPU 需要多次读这个被写过的数据时,那么写更新的效率就会变得很高,因为写操作执行之后马上更新其他缓存中的副本,所以可以使其他处理器立刻获得最新的值;
  • 写无效:如果在一个 CPU 修改缓存时,将其他 CPU 中的缓存全部设置为无效,这种策略叫做写无效。这意味着,当其他 CPU 再次访问该缓存副本时,会发现这一部分缓存已经失效,此时 CPU 就会从内存中重新载入最新的数据。

    在具体的实现中,绝大多数 CPU 都会采用写无效策略。这是因为多次写操作只需要发起一次总线事件即可,第一次写已经将其他缓存的值置为无效,之后的写不必再更新状态,这样可以有效地节省 CPU 核间总线带宽。

另一个方面,当前要写入的数据不在缓存中时,根据是否要先将数据加载到缓存中,写策略又分为两种:

  • 写分配(Write Allocate):在写入数据前将数据读入缓存,这是写分配策略。当缓存块中的数据在未来读写概率较高,也就是程序空间局部性较好时,写分配的效率较好;
  • 写不分配(Not Write Allocate):在写入数据时,直接将要写入的数据传播到内存中,而并不将数据块读入缓存,这是写不分配策略。当数据块中的数据在未来使用的概率较低时,写不分配性能较好。

总的来说,从缓存和内存的更新关系看,写策略分为写回和写直达;从写缓存时 CPU 之间的更新策略来看,写策略分为写更新和写无效;从写缓存时数据是否被加载来看,写策略又分为写分配和写不分配。
针对上述不同的策略,对应不同的缓存一致性协议。

基于写直达、写无效和写不分配策略的缓存一致性协议

这类协议比较简单,这里假设系统只有单级缓存,可以接收来自处理器的请求,也可以处理来自总线侦听器的总线侦听请求,其中,处理器的请求包含:

  • PrRd:表示处理器自己请求从缓存块中读出;
  • PrWr:表示处理器自己请求向缓存块写入。

来自总线(其他处理器)的请求包含:

  • BusRd:总线侦听到一个来自另一个处理器的读出缓存请求;
  • BusWr:总线侦听到来自另一个处理器写入缓存的请求。

在“写直达”策略中,BusWr 即另一个处理器向内存的写入请求。每个缓存块都有两种状态,包括:

  • Valid(V):缓存块有效,意味着该缓存块中的内容与主存中相同;
  • Invalid(I):缓存块无效,访问该缓存块会出现缓存缺失。

image.png

在上图中,“/”前表示的是请求,这个请求可能来自 CPU 自己,也可能来自总线,“/”后表示的是当前请求所引起的总线事件,“-”表示不产生总线事件。

对于这类基于写直达、写无效和写不分配策略的缓存一致性协议,缺点在于写传播时需要较大带宽,原因是对于缓存块的每次写入,都会触发 BusWr 从而占用带宽。相反的是,在“写无效”缓存策略下,如果同一个缓存块中的数据被多次写入,只需占用一次总线带宽来使得其他处理器的缓存副本失效。

基于写回策略的 MESI 协议

处理器对缓存的请求类似与上面的协议:

  • PrRd:处理器请求从缓存块中读出;
  • PrWr:处理器请求向缓存块写入。

而总线对缓存的请求和基于写直达、写无效和写不分配策略的缓存一致性协议稍有不同,分别是:

  • BusRd:从总线侦听到来自另一个处理器的读出缓存请求;
  • BusRdX:从总线侦听到来自另一个尚未取得该缓存块所有权的处理器读独占(或者写)缓存的请求;
  • BusUpgr:从总线侦听到其他处理器要写入本地缓存块上的数据的请求;
  • Flush:从总线侦听到缓存块被另一个处理器写回到主存的请求;
  • FlushOpt:从总线侦听到缓存块经由总线与另一个处理器的缓存达到同步的请求,和 Flush 类似,但只不过是从缓存到缓存的传输请求。

缓存块的状态分为 4 种,也是 MESI 协议名字的由来:

  • Modified(M):缓存块有效,但是是“脏”的,其数据与主存中的原始数据不同,同时还表示处理器对于该缓存块的唯一所有权,表示数据只在这个处理器的缓存上是有效的;
  • Exclusive(E):缓存块是干净有效且唯一的;
  • Shared(S):缓存块是有效且干净的,有多个处理器持有相同的缓存副本;
  • Invalid(I):缓存块无效。

image.png
对于处理器发起的请求(黑线部分):

  • M 状态:读写操作都不会改变状态,并且因为能够确定不会有其他副本,因此不会产生任何总线事务;
  • E 状态:任何对该缓存块的读操作都会缓存命中,且不触发任何总线事务。一个对 E 状态的写操作,也不会产生总线事务,只需将缓存块状态改为 M;
  • S 状态:当处理器读时,缓存命中,不产生总线事务。当处理器写时,需要产生 BusUpgr 事件,通知其他处理器我要写这个缓存块,并将缓存块状态置为 M;
  • I 状态:当处理器发出读请求时,遇到缓存块缺失,要把数据加载进缓存,产生一个 BusRd 总线请求。内存控制器响应 BusRd 请求,将所需要的缓存块从内存中取出,同时会检查有没有其他处理器也有该缓存块拷贝,如果发现拷贝则将状态置为 S,并且把其他有拷贝的处理器的状态也相应地置为 S;如果没有发现其他拷贝,则将状态置为 E。

对于总线发起的请求(红色部分):

  • M 状态:该缓存块是整个系统里唯一有效的,并且内存的数据也是过时的。因此当侦听到 BusRd 时,缓存块必须被刷入内存保证写传播,所以会产生 Flush 事件,并且将状态置为 S。当侦听到 BusRdX 时,也必须产生 Flush 事件,因为其他处理器有独占读写的需求,所以当前缓存块置为 I;
  • E 状态:当侦听到 BusRd 请求时,说明另一个处理器遇到了缓存缺失,并试图获取该缓存块,因为最终的结果是要将这个缓存块,放在多个处理器缓存上,所以状态必须被置为 S,并且会产生 FlushOpt 事件,来完成缓存到缓存的传输;当 BusRdX 被侦听到时,说明有其他处理器想要独占这个缓存块上的数据,这种情况下,本地缓存块需要将状态置为 I,同时也会产生 FlushOpt 事件,完成缓存到缓存的传输,将当前数据的最新值同步给需要进行写操作的其他处理器;而当侦听到 BusUpgr 时,说明其他处理器要写当前处理器持有的缓存副本,所以要将状态置为 I,但是不必产生总线事务;
  • S 状态:当侦听到 BusRd 时,也就是另一个处理器遇到缓存缺失而试图获取该缓存块,因为 S 状态本身是共享的,所以状态保持 S 不变,并且产生 FlushOpt 事件,来完成缓存到缓存的传输;
  • I 状态:侦听到的 BusRd、BusRdX、BusUpgr 都不会影响它,所以忽略该情况,状态保持不变。

总体来讲,MESI 协议通过引入了 Modified 和 Exclusive 两种状态,并且引入了处理器缓存之间可以相互同步的机制,非常有效地降低了 CPU 核间带宽。它是当前设计中进行 CPU 核间通讯的主流协议,被广泛地使用在各种 CPU 中。

缓存一致性协议是个约定,具体实现上实际是由硬件电路保证的。

为什么有了 MESI 协议,i++ 在多线程中仍然是不安全的

假设有两个 CPU A,B 同时执行 i++ 操作(i 初始值为 0)

  1. CPU A 请求读入 i,缓存块状态为 E,CPU B 请求读入 i,此时 CPU A,B 的缓存块状态都为 S;
  2. CPU A 对 i 执行加一操作,但是此时只是在寄存器中,还没写入缓存,此时状态还是 S,同理 CPU B 在对 i 执行加一操作后,状态也为 S;
  3. CPU A 将处理后的 i 写入缓存,缓存块状态变为 M,此时 CPU B 的缓存块状态变为 I;
  4. CPU B 将处理后的 i 写入缓存时,自己的缓存块状态为 I,而 CPU A 的缓存块状态为 M,那么 CPU A 缓存块中的 i 需要刷入内存(i 在内存中为 1),CPU A 缓存块状态变为 I,而 CPU B 缓存块状态变为 M,缓存块中 i 的值为 1

因此,MESI 协议并不能保证内存一致性。

严守 MESI 协议的 CPU 的性能问题

MESI 协议能够解决多核 CPU 体系中,多个 CPU 之间缓存数据不一致的问题。但是,如果 CPU 严格按照 MESI 协议进行核间通讯和同步,核间同步就会给 CPU 带来性能问题。
严格遵守 MESI 协议的 CPU 设计,在某一个核在写一块缓存时,它需要通过核间总线通知其他所有的核:我要写这块缓存了,如果你们谁有这块缓存的副本,请把它置成 Invalid 状态。Invalid 状态意味着该缓存失效,如果其他 CPU 再访问这一块缓存时,就需要从主存中加载正确的值。
发起写请求的 CPU 中的缓存状态可能是 Exclusive、Modified 和 Shared,每个状态下的处理是不一样的。

  • 如果缓存状态是 Exclusive 和 Modified,那么 CPU 的一个核修改缓存时不需要通知其他核,这是比较容易的;
  • 但是在 Share 状态下,如果一个核想独占缓存进行修改,就需要先给所有 Share 状态的同伴发出 Invalid(BusUpgr) 消息,等所有同伴确认并回复它 Invalid acknowledgement 以后,它才能把这块缓存的状态更改为 Modified,这是保持多核信息同步的必然要求。这个过程相较于直接在核内缓存里修改缓存内容,是非常漫长的。

因此在 CPU 的具体实现中,可以通过放宽 MESI 协议的限制来获得性能提升。

写缓冲与写屏障

CPU 的设计者为每个核都添加了一个名为 store buffer 的结构,store buffer 是硬件实现的缓冲区,它的读写速度比缓存的速度更快,所有面向缓存的写操作都会先经过 store buffer(store buffer 会收集多次写操作,然后在合适的时机进行提交)。

cache 所存储的信息是副本,cache 中的数据即使丢失了,也可以从内存中找到原始数据(这里不考虑脏数据的情况),cache 存在的意义是加速查找;而 buffer 更像是蓄水池,buffer 中的数据没有副本,一旦丢失就彻底丢失了。

增加了 store buffer 以后的 CPU 缓存结构如下图所示:
image.png
在这样的结构里,如果 CPU 的某个核再要对一个变量进行赋值,它就不必等到其他所有的核都确认完,而是直接把新的值放入 store buffer,最后再将新的值写入 cache以及进行缓存的核间同步。而且,每个核的 store buffer 都是私有的,其他核不可见。
但用 store buffer 会有一个问题,那就是它并不能保证变量写入缓存以及主存的顺序。

  1. // CPU0
  2. void foo() {
  3. a = 1;
  4. b = 1;
  5. }
  6. // CPU1
  7. void bar() {
  8. while (b == 0) continue;
  9. assert(a == 1);
  10. }

上述代码在对 a、b 进行赋值时,可能会产生赋值顺序被打乱的现象。原因可能是 CPU 的乱序执行以及 store buffer 在写入时,b 所对应的缓存块会先于 a 所对应的缓存块进入独占状态(进入独占状态这个过程可能比较漫长),也就是说 b 会先写入缓存。
为了解决这个问题,CPU 设计者就引入了内存屏障,屏障的作用是前边的读写操作未完成的情况下,后面的读写操作不能发生。这就是 Arm 上 dmb 指令的由来,它是数据内存屏障(Data Memory Barrier)的缩写。

  1. // CPU0
  2. void foo() {
  3. a = 1;
  4. smp_mb();
  5. b = 1;
  6. }
  7. // CPU1
  8. void bar() {
  9. while (b == 0) continue;
  10. assert(a == 1);
  11. }

smp_mb 就代表了多核体系结构上的内存屏障,加上这一道屏障以后,CPU 会保证 a 和 b 的赋值指令不会乱序执行,同时写入 cache 的顺序也与程序代码的顺序保持一致。

总的来说,store buffer 的存在是为提升写性能,放弃了缓存的顺序一致性,这种现象称为弱缓存一致性。

失效队列与读屏障

由于 store buffer 的存在提升了写入速度,那么 invalid 消息的确认速度相对就慢了,这就带来了速度的不匹配,很容易导致 store buffer 的内容还没有及时更新到 cache 里,store cache 的容量就被撑爆了,从而失去了加速的作用。
为了解决这个问题,CPU 设计者又引入了 invalid queue,也就是失效队列这个结构。引入了这个结构后,收到 Invalid 消息的 CPU,比如称它为 CPU1,在收到 Invalid 消息时立即向 CPU0 发回确认消息,但这个时候 CPU1 并没有把自己的 cache 由 Shared 置为 Invalid,而是把这个失效的消息放到一个队列中,等到空闲的时候再去处理失效消息,这个队列就是 invalid queue。
经过这样的改进后,CPU1 响应失效消息的速度大大提升了,带有 invalid queue 的缓存结构如下图所示:
image.png
在引入 invalid queue 结构后,仍然带来了问题。
还是以上面的代码为例,假如,CPU0 和 CPU1 的缓存中都有变量 a 的副本(值为 0),也就是说变量 a 所对应的缓存行在 CPU0 和 CPU1 中都是 Shared 状态。CPU1 中没有变量 b 的副本,b 所对应缓存在 CPU0 中是 Exclusive 状态。
当 CPU0 在将变量 a 写入缓存的时候,会产生 Invalid 消息,这个消息到达 CPU1 以后,CPU1 不再立即处理它了,而是将这个消息放入 invalid queue,并且立即向 CPU0 回复了 invalid acknowledgement 消息。CPU0 在得到这个确认消息以后,就可以独占该缓存了,直接将这块缓存变为 Modified 状态,然后把 a 写入。在 a 写入以后,foo 函数中的内存屏障就可以顺利通过了,接下来就可以写入变量 b 的新值。由于 b 是 Exclusive 的,状态直接转换为 Modified,不产生总线请求。
接下来再看 CPU1 中的操作。当 CPU1 发起对 b 的请求时,由于 b 不在缓存中,所以它会向总线发出 BusRd 请求,总线会把 CPU0 缓存中的 b 的新值 1 更新到 CPU1。同时,b 所在的缓存行在两个 CPU 中都变为 Shared 状态。CPU1 得到了 b 的新值以后,就可以退出第 10 行的 while 循环,然后对 a 的值进行判断。但是由于 a 的 Invalid 消息还在 invalid queue 里,没有被及时处理(此时状态还是 Shared),CPU1 还是会使用自己的 Cache 中的 a 的原来的值,也就是 0,这就出错了。

虽然 CPU1 并没有乱序执行两条读指令,但是实际产生的效果却好像是先读到了 b 的值,然后才读到了 a 的值。如果是在严格遵守 MESI 协议的 CPU 中,CPU0 一定要确保 a 的值先更新到 CPU1,然后才能继续对 b 赋值。但是放宽了缓存一致性以后,这段代码就有问题了。

解决的方法和写屏障的思路是一样的,我们需要引入一个内存屏障,它会让 CPU 暂停执行,直到它处理完 invalid queue 中的失效消息之后,CPU 才会重新开始执行。

  1. // CPU0
  2. void foo() {
  3. a = 1;
  4. smp_mb();
  5. b = 1;
  6. }
  7. // CPU1
  8. void bar() {
  9. while (b == 0) continue;
  10. smp_mb();
  11. assert(a == 1);
  12. }

读写屏障分离

分离的写屏障和读屏障的出现,是为了更加精细地控制 store buffer 和 invalid queue 的顺序(上述代码的的 smp_mb 可以同时对 store buffer 和 invalid queue 施加影响)。

  • 写屏障的作用是让屏障前后的写操作都不能翻过屏障。也就是说,写屏障之前的写操作一定会比之后的写操作先写到缓存中,同时写屏障会禁止 CPU 对写操作的乱序执行;
  • 读屏障的作用也是类似的,就是保证屏障前后的读操作都不能翻过屏障。假如屏障的前后都有缓存失效的信息,那屏障之前的失效信息一定会优先处理,也就意味着变量的新值一定会被优先更新。

分离的读写屏障还有一个好处,就是它可以在需要使用写屏障的时候只使用写屏障,不会给读操作带来负面的影响,这种屏障也可以称为 StoreStore barrier。同理,只使用读屏障也不会对写操作造成影响,这种屏障也可以称为 LoadLoad barrier。

  1. // CPU0
  2. void foo() {
  3. a = 1;
  4. smp_wmb();
  5. b = 1;
  6. }
  7. // CPU1
  8. void bar() {
  9. while (b == 0) continue;
  10. smp_rmb();
  11. assert(a == 1);
  12. }

这种修改只有在区分读写屏障的体系结构里才会有作用,比如 alpha 结构。而在 X86 和 Arm 中是没有作用的,这是因为 X86 采用的 TSO 模型不存在缓存一致性的问题,而 Arm 则是采用了另一种称为单向屏障的分类方式

非均匀访存

在异质式结构中,CPU 不仅仅对外设内存和主存的访问速度不一样,访问主存不同区间的访问速度也不一样。换句话说,不同的 CPU 访问不同地址主存的速度各不相同,采用这种设计的内存被称为非一致性访存(Non-uniform memory access,NUMA)。

从 CPU 的角度去看,物理内存并不是平坦的,导致 CPU 对物理内存的访问速度也是不一样的,并且有些内存可以使用 CPU cache,有些则不可以,这样的结构被称为异质性结构。

计算机如何组织外设使用到的内存

外设所需要的内存主要包括外设的工作内存、DMA 区域和用于 IO 映射的内存,因此物理内存除了主存以外,还有设备内存和 IO 映射内存。在 Linux 系统上,可以使用以下命令查看物理内存分布情况:

  1. $ cat /proc/iomem
  2. 00000000-00000fff : reserved
  3. 00001000-0009fbff : System RAM
  4. 0009fc00-0009ffff : reserved
  5. 000a0000-000bffff : PCI Bus 0000:00
  6. 000c0000-000c8dff : Video ROM
  7. 000c9000-000c99ff : Adapter ROM
  8. 000f0000-000fffff : reserved
  9. 000f0000-000fffff : System ROM
  10. 00100000-3f7fefff : System RAM
  11. 01000000-0172ac34 : Kernel code
  12. 0172ac35-01d1c9bf : Kernel data
  13. 01e74000-01fdbfff : Kernel bss
  14. 3f7ff000-3f7fffff : reserved
  15. 3f800000-3fffffff : RAM buffer
  16. 40000000-47ffffff : System RAM
  17. f0000000-fbffffff : PCI Bus 0000:00
  18. f0000000-f1ffffff : 0000:00:02.0
  19. f0000000-f015ffff : efifb
  20. f2000000-f2ffffff : 0000:00:03.0
  21. f2000000-f2ffffff : xen-platform-pci
  22. f3000000-f300ffff : 0000:00:02.0
  23. f3020000-f3020fff : 0000:00:02.0
  24. f3021000-f3021fff : 0000:00:04.0
  25. f3021000-f3021fff : ehci_hcd
  26. fc000000-ffffffff : reserved
  27. fec00000-fec003ff : IOAPIC 0
  28. fee00000-fee00fff : Local APIC

操作系统在刚启动的时候,显示器上会显示操作系统相关的信息,包括系统版本号、进入 BIOS 提示信息等内容,不过内容全是字符,没有漂亮的图形界面。在经过了系统引导之后,才有图形界面接口(Graph User Interface,GUI)。
显卡有两种工作模式:一种是字符模式,另一种是图形模式。在字符模式下,只能显示字符。而在图形模式下则可以对屏幕上的每一个像素进行操作。在 Linux 内核的加载启动阶段,选择了使用字符模式。当 CPU 进入保护模式以后,才开始初始化各种外设,设置它们的输入输出端口(IO Port)和相关的内存映射,在这之后,显卡才进入图形模式。
从 640K 到 1M 这一段物理内存区间是预留给 ISA 设备的,由于早期的显卡是通过 ISA 总线和 CPU 进行通讯的,而现代显卡则是使用 PCI/PCIe 总线与 CPU 通讯,显卡作为最典型的外设,以下就以它为例对这段内存进行说明。在字符模式下,BIOS 会将显卡的显存映射到物理地址 0xb8000(位于 0xa0000~0xfffff 区间内)。在实模式下,我们可以通过 mov 指令向这个地址直接写入数据,然后显示器就会显示对应的内容。在保护模式下,显存仍然在物理地址 0xb8000。但是,在保护模式下,我们只能使用线性地址来进行内存访问,所以操作系统必然要在准备内核空间页表项时,准备好从虚拟地址到物理地址的映射,将显存的物理地址通过页表管理起来。
早期的 CPU 与外设之间的总线是 ISA 总线,后来 PCI/PCIe 总线因为具有更好的扩展性和远超 ISA 总线的速度得到普及。所以后来的显卡也不再使用这种提前映射到物理内存的方式了,而是采用 PCI 总线来和 CPU 进行通讯,但因为兼容性问题,所以早期的设计得到了保留。
CPU 与外设进行交互主要有两种手段,分别是 IO 端口 (IO Port) 和 IO 内存映射(Memory Mapped IO, MMIO)。IO 端口是最基本的手段,在 ISA 设备上就在应用,它使用 in/out 等专属指令对外设的寄存器进行操作:设置、读取状态,以及控制数据传输。但是 IO 端口不适合进行大规模的数据传输,所以 PCI 设备主要还是通过 MMIO 进行数据通讯。

IO 端口主要用于状态读取和设置等控制命令的通讯,而 IO 内存映射主要用于大量的数据传输。

NUMA

在多核服务器上,主存也并不是一段平坦的同质的内存。为了加速性能,人们发明了非一致性内存访问(Non-uniform memory access,NUMA),与之对应的是一致性内存访问(Uniform Memory Access, UMA)。这里的一致性是指,同一个 CPU 对所有内存的访问的速度是一样的,因为物理内存是连续且集中的。而非一致性是指,内存在物理上被分为了多个节点 node,CPU 可以访问所有节点,但是为了提升访问效率,CPU 可以有选择地优先访问离自己近的内存节点。所以在多核处理器上,CPU 也根据内存节点划分成多个组,每个组里的 CPU 访问同一个内存节点的效率是相同的。当然了,任何一个 CPU 都可以访问全部的内存节点,只不过因为“距离”远近的差异,导致访问效率不一样。
image.png
如上图所示,对于一致性内存访问,UMA 是基于总线的,CPU 需要先经过前端总线(Front Side Bus,FSB)连接到北桥,然后北桥再连接到内存控制器进行内存访问。
随着处理器核数的增多,UMA 面临的挑战主要包括两个方面:

  1. 总线的带宽压力会越来越大,同时每个节点可用带宽会减少;
  2. 总线的长度也会因此而增加,进而增加访问延迟。

为了解决以上两个问题,NUMA 架构逐渐成为主流。和 UMA 不同,在 NUMA 架构下每个 CPU 现在都有自己的本地内存节点,CPU 与 CPU 之间点对点互联。使用这种方式的典型代表是 intel 的快速通道互联 QPI(Intel QuickPath Interconnect)。如果一个 CPU 要访问远程节点的内存,则先通过 QPI 到达远程节点 CPU 的内存控制器,然后再进行数据传输。
image.png
如上图所示,连接到 CPU1 的内存控制器的内存被认为是本地内存。连接到另一个 CPU 插槽 (CPU2) 的内存被视为 CPU1 的外部或远程内存。远程内存访问比本地内存访问有额外的延迟开销,因为它必须遍历互连(点对点链接)并连接到远程内存控制器。由于两者内存位置不同,访问方式也不同,因此这种系统会经历“不均匀”的内存访问时间。
UMA 架构的优点很明显就是结构简单,所有的 CPU 访问内存都是一致的,都必须经过总线。然而 UMA 架构会随着处理器核数的增多,总线的带宽压力会越来越大。解决办法就只能扩宽总线,然而成本十分高昂,未来可能仍然面临带宽压力。而 NUMA 在扩展时只需要关注 CPU 之间的连接,不占用总线带宽,自然就成为现代处理器的选择。
将进程正确地绑定在相应的核上可以极大地提升程序性能,因此程序员可以使用 numactl 工具,来查看系统的 numa 信息以及进行绑核操作。此外,还可以通过 libnuma 库来调整进程的内存策略来提升程序的性能。

绑核的意思就是将进程的运行环境和特定的 CPU 组,内存节点捆绑在一起。

image.png