3. 缓存基础与一致性需求

各位同学,今天我们来聊聊多核处理器里最让人头疼的问题之一——缓存一致性。说实话,我刚入行做芯片设计那会儿,觉得单核跑得挺溜的,加个核不就是复制粘贴吗?结果第一次在FPGA上调试双核系统时,数据乱得一塌糊涂,我才意识到事情没那么简单。

3.1 什么是缓存一致性

先问大家一个问题:一个变量 x 初始值是 0。Core 0 把它改成 1,Core 1 去读,读到的应该是多少?

你可能会说,当然是 1 啊。但在多核系统里,还真不一定。

每个核心都有自己的私有缓存(L1 Cache)。Core 0 改了 x,数据可能还躺在它的 L1 里,根本没写回主存。Core 1 去读自己的 L1 缓存,发现还是 0。这就出问题了——两个核心看到同一个内存地址的值不一样。

缓存一致性,说白了就是一套规则,保证所有核心对同一内存地址的访问,能达成一致的视图。用术语讲就是:写传播(一个核心的写操作,其他核心最终能看到)和写串行化(所有核心看到同一个地址的写顺序是一样的)。

核心定义:缓存一致性保证每个内存地址的读操作,返回的是最近一次对该地址的写操作的值。注意,是“最近一次”——所有核心都认可的那个“最近”。

3.2 不一致是如何产生的

我总结了一下,缓存不一致的根源就三个:

  1. 核心私有缓存的存在——每个核有自己的小仓库,改了东西不告诉别人。
  2. 写操作的延迟可见性——写操作不会立刻传播到所有缓存和主存。
  3. 指令重排序——编译器或CPU为了性能,可能把读写顺序打乱。

咱们重点看前两个,第三个在内存模型里再细聊。

举个例子,我在一个项目里遇到过这样的场景:Core 0 和 Core 1 共享一个标志位 flag 和一个数据缓冲区 buf。Core 0 先往 buf 写数据,再置 flag = 1。Core 1 轮询 flag,看到 1 就去读 buf。单核时代没问题,多核时代呢?

Core 0 的 buf 可能还在 L1 里没写出去,flag 却因为缓存替换策略先被写回了主存。Core 1 看到 flag = 1,去读 buf——读到的还是旧数据。这就是典型的缓存不一致。

避坑指南:我曾经在一个SoC项目中,因为忽略了缓存一致性问题,导致DMA搬运的数据和CPU看到的数据不一致,查了整整三天。后来发现是CPU的L1缓存没有在DMA传输前做clean操作。从那以后,我养成了一个习惯:凡是涉及共享数据的场景,先画缓存视图。

3.3 一个经典的缓存不一致场景分析

咱们来看一个最经典的例子。假设一个双核系统,主存地址 0x1000 存着变量 a = 5。两个核心的 L1 缓存一开始都是空的。

步骤 Core 0 操作 Core 1 操作 Core 0 L1 Core 1 L1 主存
1 读 a - a=5 空 a=5
2 - 读 a a=5 a=5 a=5
3 写 a=10 - a=10 a=5 a=5
4 - 读 a a=10 a=5 a=5

看到问题了吗?步骤 4 中,Core 1 读到的 a 是 5,但 Core 0 已经把它改成 10 了。这就是缓存不一致的典型表现。

为什么会这样?因为 Core 0 的写操作只更新了自己的 L1 缓存,没有通知 Core 1。Core 1 的 L1 里还躺着那个过时的 5。

下面这张图把整个过程画出来了,大家可以对照着看:

经典缓存不一致场景 主存:a = 5 Core 0 L1 a = 10(已修改) Core 1 L1 a = 5(过期) Core 0 Core 1 Core 0 写 a=10,未通知 Core 1 Core 1 仍读到过期值 a=5 Core 1 读自己的 L1 缓存 未感知 Core 0 的修改

你想想看,如果这是一个银行账户系统,Core 0 扣了钱,Core 1 查余额还是原来的数,那不乱套了?

3.4 解决思路初探

怎么解决?其实思路就两条:

  • 写更新(Write-Update):Core 0 写的时候,直接把新值广播给所有缓存。Core 1 收到广播,把 a 从 5 改成 10。
  • 写失效(Write-Invalidate):Core 0 写的时候,广播一个“失效”消息。Core 1 收到后,把 a 标记为无效。下次 Core 1 读 a,发现缓存失效,就去主存或 Core 0 那里拿最新的。

这两种方式各有优劣。写更新广播量大,写失效则会在读的时候产生一次缺失。目前主流协议(比如 MESI)用的是写失效。为什么?我个人的经验是:写失效在多个核心反复读写同一份数据时,总线流量更可控。

小提示:写失效还有一个好处——如果 Core 0 连续写同一个变量多次,只需要在第一次写时发一次失效,后面的写都在本地完成。写更新的话,每次写都要广播,浪费带宽。