atomic 和缓存一致性

atomic 和缓存一致性

缓存一致性和原子性确实是两个紧密相关但本质不同的概念。理解它们的关系和区别对于并发编程至关重要。

  1. 缓存一致性 (Cache Coherence) 的作用 目标: 保证系统中所有处理器核心看到的同一内存地址的数据是一致的。它确保当一个核心修改了其缓存中某地址的数据后,其他核心后续访问该地址时,要么能看到这个修改,要么访问的是持有最新数据的缓存副本。

机制: 通过协议(如 MESI, MOESI)管理缓存行的状态(Modified, Exclusive, Shared, Invalid)。核心在读写内存前,必须通过总线消息与其他核心协调缓存行的状态。

关键点: 当一个核心想要写入一个缓存行时,它必须先获得该缓存行的 Exclusive 或 Modified 状态。获得 Exclusive 状态意味着:

该核心是系统中唯一持有该缓存行有效副本的核心。

其他核心的该缓存行副本都处于 Invalid 状态。

此时,该核心可以安全地修改该缓存行中的数据,而不用担心其他核心同时修改(因为它们没有有效副本)。在修改后,状态通常会变为 Modified。

保证什么: 缓存一致性保证的是单个内存位置(通常是一个缓存行大小)的单一读或写操作的可见性和顺序性(在特定内存模型下)。它确保了当一个核心写入数据并使其状态变为 Modified 后,其他核心在读取该数据前,必须先将自己的无效副本置为 Invalid,并从持有最新数据的核心(或主内存)获取更新后的 Shared 副本。

  1. 原子性 (Atomicity) 的要求 目标: 保证一个操作(通常涉及多个步骤,如“读-修改-写”)作为一个不可分割的整体执行。在操作执行期间,任何其他核心都无法观察到操作的中间状态,也无法干扰该操作的执行。

关键点: 对于像 counter++ 这样的操作(读 counter 的值,加 1,写回 counter),原子性要求这三个步骤必须一气呵成,不能被其他核心的操作打断或插入。

  1. 仅有缓存一致性为何无法保证原子性?(以 counter++ 为例) 假设全局变量 counter 初始值为 0,位于一个缓存行中。核心 A 和核心 B 同时执行 counter++。

初始状态: 假设核心 A 和核心 B 的缓存中都有 counter 所在的缓存行,状态为 Shared (S),值都是 0。

核心 A 执行读操作 (counter):

因为状态是 S,可以安全读取。counter 的值 0 被读入核心 A 的寄存器。

核心 B 执行读操作 (counter):

同样因为状态是 S,可以安全读取。counter 的值 0 被读入核心 B 的寄存器。

问题点 1: 两个核心都读取到了旧值 0。缓存一致性保证了它们读取时值是一致的(都是 0),但它不阻止两个核心同时读取这个旧值。

核心 A 尝试执行写操作 (counter = 1):

核心 A 需要修改缓存行,因此它发出总线消息(如 Read-For-Ownership)。

核心 B 监听到此消息,将自己缓存中的该行状态置为 Invalid (I)。

核心 A 获得缓存行的 Exclusive (E) 状态(现在是唯一有效持有者)。

核心 A 将缓存行中的 counter 值修改为 1。缓存行状态变为 Modified (M)。

核心 B 尝试执行写操作 (counter = 1):

核心 B 也需要修改缓存行(它想写回 0+1=1)。

它检查自己的缓存,发现该行状态是 Invalid (I)。

核心 B 发出总线消息(如 Read-For-Ownership)。

核心 A 监听到此消息:

核心 A 知道自己持有 Modified (M) 数据(最新值 1)。

核心 A 将最新数据 (1) 写回主内存(或响应给请求者),并将自己的状态降级(如变为 Shared 或 Invalid,取决于协议)。

核心 B 接收到数据 1,并独占该缓存行(状态变为 Exclusive (E))。

问题点 2: 核心 B 现在拿到的是值 1(核心 A 修改后的结果),但它并不知道这个值已经被修改过。核心 B 的寄存器里仍然保存着它之前读到的旧值 0!

核心 B 执行计算:0 + 1 = 1。

核心 B 将这个计算结果 (1) 写回到它独占的缓存行中(状态变为 Modified (M))。

最终结果:

两个核心都执行了一次 counter++。

counter 的最终值是 1。

错误发生: 正确结果应该是 2。值 1 被覆盖了。

  1. 核心问题分析 - 为什么缓存一致性不够? 非原子操作本身: counter++ 在硬件层面(通常是 RISC 或 x86 的非原子指令)是 “读-修改-写”(Read-Modify-Write, RMW) 三个独立操作。缓存一致性协议作用于单个内存访问请求(一次读或一次写总线事务)。它不感知这三个操作在逻辑上属于一个整体。

临界区缺失: 在核心 A 执行“读-修改-写”的整个期间(从它读取旧值开始,到它成功写入新值结束),没有机制阻止核心 B 也去执行“读-修改-写”。虽然核心 A 在写入的瞬间需要独占权(保证了那一刻没有其他写并发),但核心 B 在核心 A 写入之前就已经把旧值读走了。核心 A 的写入操作和核心 B 的写入操作在时间上是分开的(都获得了独占权),但它们操作所基于的旧值(都是 0)是相同的、过时的。

状态 vs. 操作: 缓存一致性管理的是数据的状态(哪个缓存有副本,是否有效,是否脏)。原子性要求管理的是操作的执行流(一个多步骤的操作必须连续执行,不能被中断或插入其他操作)。获得 Exclusive 状态只保证了写操作本身的独占性,并没有将之前的读操作和这个写操作“绑定”成一个原子操作。

  1. 缓存一致性是原子性的基础 虽然仅有缓存一致性不能保证原子性,但它是实现原子性的必要基础:

提供独占访问: 原子 RMW 操作(如 LOCK INC [mem], atomic_fetch_add)在执行时,必须先获取目标内存位置的 Exclusive 状态。这是通过缓存一致性协议实现的。

保证最新数据: 在获取独占权的过程中,缓存一致性协议确保操作发起核心获得的是该内存位置的最新值(可能会从其他核心的 Modified 缓存或主存中获取)。

保证修改的传播: 原子操作完成修改后,缓存一致性协议确保修改最终会传播到其他核心的缓存或主存。

  1. 如何保证原子性? 要保证像 counter++ 这样的 RMW 操作的原子性,需要在缓存一致性提供的独占访问基础上,增加额外的机制:

硬件原子指令: CPU 提供专门的指令(如 x86 的 LOCK 前缀指令 LOCK ADD [mem], 1, ARM 的 LDREX/STREX 指令对)。这些指令:

在执行整个 RMW 操作(读、修改、写)期间,持有缓存行的独占权(通常通过锁总线或缓存锁实现)。

确保在它持有独占权的整个过程中,没有其他核心能插入对该地址的读或写操作。

将读取最新值、执行计算、写入新值这三个步骤合并成一个不可分割的、对其他核心可见为原子的总线事务。

软件锁 (Mutex): 在代码层面,使用互斥锁保护临界区。在进入临界区(获取锁)和离开临界区(释放锁)时,通常会利用底层的原子操作(如 CAS)和内存屏障来实现,而这些原子操作又依赖于硬件和缓存一致性。

总结 缓存一致性 确保所有核心看到的单个内存位置的数据是一致的。它通过管理缓存行状态(包括提供 Exclusive 状态)来实现这一点。

原子性 要求一个多步骤的操作(如读-修改-写)作为一个不可分割的整体执行。

仅有缓存一致性无法保证原子性:因为它只保证单个读写操作的可见性和顺序,不阻止多个核心基于同一个旧值并发执行它们的“读-修改-写”操作序列。获得 Exclusive 状态保证了写那一刻的独占,但无法将写操作与之前独立的读操作绑定成一个原子单元。

缓存一致性是基石:实现原子性(无论是通过硬件原子指令还是软件锁)必须依赖缓存一致性协议来获取目标内存位置的独占访问权 (Exclusive) 并传播修改结果。

原子性需要额外机制:硬件原子指令通过在 RMW 操作的整个持续时间内持有独占权并阻止干扰来实现原子性。软件锁则构建在这些原子操作之上。

简而言之:缓存一致性让核心能安全地拿到“最新稿纸”(Exclusive),但原子性要求核心在“阅读旧稿纸->修改->誊写到新稿纸”的整个过程中,这张稿纸不能被别人抢走或同时修改。仅有缓存一致性只保证了誊写(Exclusive)那一刻没人干扰,但别人可能在你阅读之后、誊写之前,也阅读了同一份旧稿纸并准备进行他们自己的誊写。硬件原子指令相当于给“阅读->修改->誊写”整个过程上了一把锁。

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计