五、数据一致性

73 阅读9分钟

硬件层数据一致性

我们知道程序数据一开始是存放在磁盘上的,然后被加载到内存。而数据要被cpu进行操作,需要从内存读取到L3、L2和L1级缓存,最后被放到寄存器中。cpu从寄存器中拿到数据去执行操作。而且,cpu要读取数据,也是先从L1缓存中查找,没有的话,再去L2缓存中查找,以此类推。

image.png

其中,每个cpu都独有的硬件是寄存器、L1和L2级缓存。这种硬件结构会造成一些问题,例如多个cpu都将内存中的某个数据读取到自己的缓存中,并且进行了操作,这就仿佛多个线程对同一个变量进行操作,如何能保证数据一致性呢?

image.png

在以前,采用了给数据总线加锁的方式。在L3级缓存与L2级缓存之间,数据是通过总线传输的。如果一个cpu从L3级缓存读取了一个数据,那么就将总线上锁,如此一来,其他cpu就无法再读取这个数据进行操作,以此来保证数据一致性。但是,由于总线被上了锁,其他数据也无法被访问了,导致程序执行效率很低。

因此,现在的硬件还提出了MESI的缓存一致性协议。其实有很多缓存一致性协议,只是我们常用的英特尔cpu使用的是MESI。

MESI是四个单词的首字母,这四个单词描述的是缓存中数据的状态,分别是:

假设cpu1从内存中读取数据x到缓存

Modified:当cpu1修改了自己缓存中x的值,那么x被标记为Modified

Exclusive:当只有cpu1从内存中读取了x到缓存,那么x被标记为Exclusive

Shared:除了cpu1,还有其他cpu从内存中读取了x到自身缓存中,那么x被标记为shared

Invalid:当其他cpu修改了x的值,那么cpu1缓存中的x被标记为Invalid

当缓存中的数据被标记为Invalid后,如果cpu要对这个数据进行操作,需要到内存中重新读取到缓存,这样拿到的才是最新的数据,才能对其进行操作。

完整实例流程

假设初始状态:内存中数据 X=10,所有缓存中均无 X 的副本。

1. CPU 1 读取 X(首次加载)​
  • 步骤 1​:CPU 1 发起 Read X,L1 未命中 → 查询 L2。

  • 步骤 2​:L2 未命中 → 查询 L3。

  • 步骤 3​:L3 未命中 → 从内存读取 X=10

  • 步骤 4​:数据加载路径:

    • 内存 → L3(状态 Shared)→ L2(状态 Exclusive)→ L1(状态 Exclusive)。
  • 结果​:

    • L1/L2 中 X=10,状态 Exclusive(因为当前仅 CPU 1 缓存了 X)。
    • L3 中 X=10,状态 Shared(记录该行可能被其他核心共享)。
2. CPU 2 读取 X(共享访问)​
  • 步骤 1​:CPU 2 发起 Read X,L1 未命中 → 查询 L2 → 查询 L3。

  • 步骤 2​:L3 发现 X 状态为 Shared,且 L1/L2 中可能有副本(由包含性缓存保证)。

  • 步骤 3​:L3 通过总线广播 Read 消息,CPU 1 的 L1/L2 监听后响应(因为其状态为 Exclusive)。

  • 步骤 4​:数据共享:

    • CPU 1 的 L1/L2 将 X 状态降级为 Shared
    • X=10 从 L3 加载到 CPU 2 的 L2 → L1,状态均为 Shared
  • 结果​:

    • CPU 1 和 CPU 2 的 L1/L2 中 X=10,状态 Shared
    • L3 中 X=10,状态 Shared
3. CPU 1 修改 X(触发写操作)​
  • 步骤 1​:CPU 1 发起 Write X=20,其 L1 中 X 状态为 Shared,需先获取独占权。

  • 步骤 2​:CPU 1 的 L1 广播 ReadInvalidate 消息:

    • L3 监听后,标记 X 为 Invalid(因为 L3 是包含性的,需保证一致性)。
    • CPU 2 的 L1/L2 监听后,将 X 标记为 Invalid,并发送 Ack
  • 步骤 3​:CPU 1 收到所有 Ack 后:

    • L1 中 X 状态升级为 Modified,写入 X=20
    • L2 中 X 状态为 Modified(或 Exclusive,取决于实现)。
    • L3 中 X 保持 Invalid(因为 L3 不持有最新值)。
  • 结果​:

    • CPU 1 的 L1/L2 中 X=20,状态 Modified
    • CPU 2 的 L1/L2 中 X 为 Invalid
    • L3 中 X 为 Invalid
    • 内存中 X 仍为 10(未回写)。
4. CPU 2 再次读取 X(触发回写)​
  • 步骤 1​:CPU 2 发起 Read X,其 L1/L2 未命中 → 查询 L3。

  • 步骤 2​:L3 中 X 为 Invalid,广播 Read 消息。

  • 步骤 3​:CPU 1 的 L2 监听后,发现自身持有 Modified 数据:

    • 将 X=20 回写到 L3(或直接传给 CPU 2),并降级为 Shared
    • L3 更新为 X=20,状态 Shared
  • 步骤 4​:数据加载到 CPU 2 的 L2 → L1,状态 Shared

  • 结果​:

    • CPU 1 和 CPU 2 的 L1/L2 中 X=20,状态 Shared
    • L3 中 X=20,状态 Shared
    • 内存中 X 仍为 10(回写时机由缓存策略决定)。
5. 内存回写(Write-Back)​
  • 当 Modified 数据被替换出缓存时(如 CPU 1 的 L2 淘汰 X),会将其写回内存。

  • 最终状态​:

    • 内存中 X=20
    • 所有缓存中 X 状态为 Shared 或 Invalid(取决于访问模式)。
6. 为什么缓存一致性无法保证线程安全​

首先缓存一致性只对缓存中的数据起作用,对已经读取到寄存器中的数据不起作用。如果两个线程都已经将数据读取到寄存器中,并且进行了修改,将要写回内存中,那么就会发生线程安全问题。

例如:假设 x 初始值为 0,两个线程同时执行 x++

步骤分解(每个线程的视角)​
  1. 读取 x 到寄存器​:从缓存加载 x=0 到核心A的寄存器RA。

    • 此时缓存行状态可能是 Shared(其他核心也能读)。
  2. 寄存器内计算​:RA = RA + 1(得到 RA=1)。

    • 关键点​:MESI不知道RA的值变了!它只管理缓存。
  3. 写回缓存​:将 RA=1 存回缓存。

    • 此时MESI介入,将其他核心的缓存行设为 Invalid
竞争场景
  • 若核心A和核心B同时将 x=0 读入各自的寄存器(RA和RB),并分别计算 RA=1RB=1

    • 两者写回后,最终 x=1(正确值应为 2)。

3. 类比解释

  • 缓存像共享白板​:MESI确保所有人看到的白板内容一致。
  • 寄存器像私人笔记本​:你可以在笔记本上修改数据,但MESI不知道你改了啥,直到你写回白板。
  • 竞争问题​:两个人同时抄了白板上的旧数据,私下计算后写回,覆盖对方的更新。

缓存行

数据从内存中读取到缓存中,不是一个一个的读取,而是一次性读取一个缓存行的数据,缓存行的大小是64个字节。这样做可以提高效率,但是也存在问题。例如我们上面提到的缓存一致性协议,其实那四种状态是用来标记整个缓存行的。也就是说,如果一个缓存行中有x和y两个数,cpu1读取了这个缓存行后,只需要操作x,但是,另一个cpu2将y读取到缓存中并且修改了值,此时,cpu1中整个缓存行都被标记成Invalid,cpu1再想操作x,就需要去内存中重新读取数据,但其实x并没有被修改过,这种情况下会导致程序执行效率降低。

我们可以用下面的代码模拟这种场景:

数据共享缓存行:

public class CacheLineV1 {
    private static class T {
        public volatile long x = 0L;
    }

    public static T[] arr = new T[2];

    static {
        arr[0] = new T();
        arr[1] = new T();
    }

    public static void main(String[] args) throws Exception{
        Thread t1 = new Thread(() -> {
            for (long i=0; i< 1000_0000L; i++) {
                arr[0].x = i;
            }
        });
        Thread t2 = new Thread(() -> {
            for (long i=0; i< 1000_0000L; i++) {
                arr[1].x = i;
            }
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(System.nanoTime()-start);
    }
}

数据不共享缓存行:

public class CacheLineV2 {
    private static class T {
        public long a1,a2,a3,a4,a5,a6,a7;
        public volatile long x = 0L;
        public long b1,b2,b3,b4,b5,b6,b7;
    }

    public static CacheLineV2.T[] arr = new CacheLineV2.T[2];

    static {
        arr[0] = new CacheLineV2.T();
        arr[1] = new CacheLineV2.T();
    }

    public static void main(String[] args) throws Exception{
        Thread t1 = new Thread(() -> {
            for (long i=0; i< 1000_0000L; i++) {
                arr[0].x = i;
            }
        });
        Thread t2 = new Thread(() -> {
            for (long i=0; i< 1000_0000L; i++) {
                arr[1].x = i;
            }
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(System.nanoTime()-start);
    }
}

数据不共享缓存行的代码执行时间要更少。

目前为了保证数据的一致性,缓存一致性协议和锁总线的方式都会使用。因为有的数据太大,缓存可能会装不下,此时使用的就是锁总线的方式。

缓存行的问题

MESI需要cpu在修改数据后,通知其他cpu,进而改变其他cpu缓存中数据的状态。但是,多个cpu的缓存状态置换是需要消耗时间,当一个cpu中缓存切换状态时,这个cpu需要等待其他cpu收到消息完成各自缓存中相应数据的状态切换并且发出回应消息。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。

实际上,cpu不会傻傻的等响应,而是利用缓存行状态切换等待的这段时间去执行下一个指令,这就是指令重排序现象。具体实现如下:

  • 存储缓存(store buffer):

    • 之前需要同步等待其它cpu返回的消息确认,然后修改缓存中的值
    • 现在直接把当前指令修改的结果放在存储缓存中,然后直接去执行下一次指令,等到异步收到其它cpu 的确认消息后,再把存储缓存中要修改的值同步到内存中
  • 失效队列(invalidate queue):

    • 之前当cpu检测到其它cpu发出的失效通知时,需要当前cpu停止手上的工作,完成对应缓存行的状态切换,回复确认消息
    • 现在直接把失效通知放在失效队列中,立马返回确认消息,后续在合适的时机再处理失效队列里的消息,例如访问缓存中数据的时候,一次性处理失效队列中所有的消息。

image.png

指令重排序可以提高cpu的利用率,但是也存在问题。因为上述的实现导致了处理器对数据的修改不是立即对其他内核可见的(store buffer 与 invalidate queue 都是异步处理的),这样在并发运行的程序下有可能会有数据不一致的产生。

要解决这个问题,需要借助锁机制,例如synchronized等。