Skip to content

内存屏障是什么

1. 为什么需要内存屏障

在单线程、单处理器的时代,程序基本上是按代码顺序执行的,逻辑清晰。然而,在现代多核计算机体系结构中,为了极致地提升性能,编译器和 CPU 会进行两类主要的“优化”,这些优化在单线程下没有问题,但在多线程环境下会引发混乱。

1.1 指令重排序

为了提高效率,指令的实际执行顺序可能与代码编写的顺序不一致。 这种重排序发生在两个层面:

  1. 编译器重排序:在编译阶段,编译器为了优化性能,可能会重新安排指令的顺序。例如,如果两条指令没有数据依赖关系,编译器可能会调换它们的顺序以更好地利用 CPU 资源。例如:x = r; y = 1; 这两行代码在开启编译器优化(如 g++ -O2)后,对 y 的赋值可能会被排到对 x 的赋值之前。
  2. 处理器乱序执行(Out-of-Order Execution):现代 CPU 为了避免因等待内存数据而空闲,会采用乱序执行技术。如果一条指令需要等待,CPU 会跳过它,去执行后面不依赖于等待结果的指令。 这种优化在单个 CPU 核心内部看是没问题的,因为有专门的机制确保最终结果和顺序执行一致。但在多核环境下,一个核心的乱序执行,在另一个核心看来,就是内存状态的非预期变化。

    一个经典的例子是: c // 初始值 x = 0, y = 0 // 线程 1 // 线程 2 a = 1; b = 1; x = b; y = a; 在不重排序的情况下,(x, y) 的结果可能是 (0, 1), (1, 0) 或 (1, 1)。但如果发生重排序,例如线程 1 执行 x=ba=1 之前,同时线程 2 执行 y=ab=1 之前,就可能出现 (x, y) = (0, 0) 的结果。

1.2 缓存可见性问题

在多核(SMP)架构中,每个 CPU 核心都有自己独立的高速缓存(如 L1、L2 Cache)。 当一个核心(CPU 0)修改了一个共享变量(如 x=42)时,它通常是先将新值写入自己的高速缓存中,并不会立即同步到主内存。 这时,另一个核心(CPU 1)如果去读取这个变量,它可能会从自己的缓存或主内存中读到一个旧值。

这就导致了可见性问题:一个线程对共享变量的修改,不能立即被其他线程看到。

2. 解决方案:内存屏障

内存屏障正是为了解决上述重排序和可见性问题而存在的。它通过强制 CPU 执行特定的操作来确保顺序和可见性。

2.1 抑制重排序

内存屏障指令会告诉 CPU 和编译器:“在执行屏障之后的任何读/写操作之前,必须确保屏障之前的所有读/写操作都已经完成。” 这就有效地禁止了指令跨越屏障进行重排序。

2.2 保证可见性

内存屏障通常会附带刷新缓存相关的操作。 例如,一个写屏障(Store Barrier)会强制将当前 CPU 核心的“写缓冲区”(Store Buffer)中的数据刷新到其高速缓存中,并借助缓存一致性协议(如 MESI 协议)通知其他核心它们对应的缓存行已失效。 当其他核心需要读取该数据时,发现自己的缓存已失效,就会从主内存或其他核心的缓存中获取最新值,从而保证了可见性。

3. 内存屏障的类型

不同的 CPU 架构提供了不同粒度的内存屏障指令。从功能上,它们通常可以分为以下几类:

  1. 写屏障 (Write/Store Barrier)

    • 指令示例 (x86): sfence
    • 作用: 确保在该屏障之前的所有“写”操作,都优先于该屏障之后的所有“写”操作被其他处理器看到。它只对写操作排序。
  2. 读屏障 (Read/Load Barrier)

    • 指令示例 (x86): lfence
    • 作用: 确保在该屏障之前的所有“读”操作,都优先于该屏障之后的所有“读”操作执行。它只对读操作排序。
  3. 全功能屏障 (Full Barrier)

    • 指令示例 (x86): mfence
    • 作用: 同时具备读屏障和写屏障的功能。确保屏障前的所有读写操作都优先于屏障后的所有读写操作。这是最强但开销也最大的屏障。

4. 内存屏障与缓存一致性协议(如 MESI)的关系

这是一个非常关键且容易混淆的点。

  • MESI 协议:解决的是多个核心之间缓存数据的最终一致性问题。它确保当一个核心修改数据后,其他核心最终能得到通知(通过将它们的缓存行置为“无效”状态),从而在下次访问时能获取到最新数据。
  • 内存屏障:解决的是操作的顺序性问题。MESI 协议本身不保证操作的顺序。即使有了 MESI,CPU 仍然可能乱序执行,导致其他核心观察到错误顺序的内存更新。

可以这样理解:MESI 协议提供了数据同步的物理基础,而内存屏障则是在这个基础上,由程序员或编译器发出的、用于控制同步时机的“命令”。 内存屏障强制 CPU 等待缓存操作(如等待其他核心对自己缓存失效的确认)完成后,再继续执行后续指令,从而保证了顺序。

5. 实际应用

在应用层开发中,我们通常不直接使用汇编指令来插入内存屏障。相反,我们使用高级语言或库提供的封装好的同步原语,这些原语在底层已经为我们处理了内存屏障。

  • Java 中的 volatile 关键字:对一个 volatile 变量的写操作之后,会插入一个写屏障;在读操作之前,会插入一个读屏障。这保证了对该变量的修改对其他线程立即可见,并禁止了相关指令的重排序。
  • 锁(Locks/Mutexes):在加锁和解锁操作中,都隐含了内存屏障。解锁操作会包含一个写屏障,确保临界区内的所有修改都已同步到主存;加锁操作会包含一个读屏障,确保能读取到最新的共享数据。
  • 原子操作(Atomics/CAS):如 Compare-and-Swap 操作,在 x86 上通常会使用 lock 前缀的汇编指令,这个 lock 前缀本身就起到了全功能屏障的作用,保证了原子性和内存顺序。
  • 无锁数据结构:在操作系统内核或高性能计算库中,为了避免锁带来的开销,开发者会直接使用内存屏障来构建无锁(Lock-free)的数据结构,例如您提供的资料中的 kfifo 无锁环形队列。