try ai
科普
编辑
分享
反馈
  • 内存模型

内存模型

SciencePedia玻尔百科
核心要点
  • 现代处理器使用宽松内存模型,放弃了严格的顺序一致性,以在多核环境中实现显著的性能提升。
  • 宽松模型允许对内存操作进行重排序,这可能导致反直觉的结果和并发程序中复杂的数据竞争等错误。
  • 程序员可以通过使用显式工具(如硬件内存屏障)或可移植的语言级原语(如释放-获取语义)来强制执行正确的内存顺序。
  • 即使是最弱的内存模型也支持一些基本原则,如对单个内存位置的缓存一致性,并防止“无中生有”地创造值这类违背因果关系的行为。

引言

在学习编程时,我们通常将计算机内存想象成一个单一、有序的空间,所有操作一个接一个地发生。这个直观的概念被称为顺序一致性,它为软件开发提供了一个可预测的世界。然而,在多核处理器时代,这种简单性只是一种幻觉。现代硬件采用了复杂的优化,打破了这种顺序保证,创造了一个混乱的环境,其中内存操作似乎会乱序发生。程序员的心智模型与硬件实际行为之间的这种根本脱节,是并发软件中那些微妙且灾难性错误的根本来源。本文旨在通过弥合这一关键的知识鸿沟,揭开内存模型的神秘面纱。我们将首先探讨核心的​​原理与机制​​,从顺序一致性的理想模型开始,理解导致其被放弃的性能成本,并深入探讨宽松一致性模型(如全局存储顺序)的现实。随后,​​应用与跨学科联系​​部分将把这些理论与实践相结合,展示它们在从高级人工智能应用、编译器优化到操作系统和设备驱动程序的底层工作等各个方面所起的关键作用。

原理与机制

宏大的幻觉:单一、有序的内存

初学编程时,我们被灌输了一个简单而舒适的谎言。我们把计算机内存想象成一个巨大的、单一的文件柜。当我们向某个位置(比如 x = 10)写入一个值时,就像把一个带编号的文件放进指定的抽屉。当我们从 xxx 读取时,我们打开那个抽屉,看到那个文件。如果有多个人——或者在计算机里,多个处理器核心——在使用这个文件柜,他们看到的都是处于相同状态的相同文件。如果一个人更新了文件,下一个查看的人就会看到这个更新。这个心智模型清晰、合乎逻辑且非常直观。它有一个名字:​​顺序一致性(Sequential Consistency, SC)​​。

问题在于,这个田园诗般的文件柜是一种幻觉。现代多核处理器与其说像一个安静的图书馆,不如说更像一个繁忙、混乱的车间。每个核心都是一个独立的工人,试图尽快完成自己的工作。为了避免频繁地跑回主文件柜(主内存)——这个过程很慢——每个工人都有自己的工作台(缓存)和一个私人的“发件箱”(存储缓冲区)来存放已完成的任务。他们并行工作,他们走捷径,而且他们并不总是立即告诉对方自己在做什么。这种混乱的唯一目的就是一件事:速度。正是这种张力——程序员渴望一个简单、有序的世界与硬件对性能的不懈追求之间的矛盾——催生了内存一致性模型这个引人入胜又复杂的世界。

顺序一致性:普适的协定法则

让我们将直观的图景形式化。​​顺序一致性​​是黄金法则:它规定任何执行的结果都必须等同于所有核心的所有操作在某个单一、全局的时间线上执行的结果。此外,任何单个核心的操作在这个时间线上出现的顺序必须与程序指定的顺序相同。就好像有一位伟大的抄写员,所有核心都向这位抄写员提交请求,然后他按某种顺序逐一执行,从而创造出一部关于所有发生事件的权威历史记录。

这并不意味着禁止并行执行。它只是意味着,无论硬件如何重叠和执行指令,最终结果都必须能通过某种串行交错来解释。对于两个并发的、独立的操作,比如核心 A 的一次写入和核心 B 的一次读取,SC 允许两种可能的情形:写入先发生,或者读取先发生。两者都是有效的顺序历史。例如,如果一个线程准备写入 xxx,另一个线程准备写入 yyy,在 SC 模型下,两个线程在任一写入生效前先读取到 yyy 和 xxx 的初始零值是完全合法的。一个可能的全局顺序可以是:线程 1 读取 y=0y=0y=0,线程 2 读取 x=0x=0x=0,线程 1 写入 xxx,线程 2 写入 yyy。这个结果感觉完全合乎逻辑,并且是 SC 所允许的。

SC 的美妙之处在于其简单性。它保证了程序员的直觉得以成立,不会有任何诡异的意外。但这个保证的代价是高昂的。

理智的代价:为何我们放弃纯粹的 SC

想象一个核心执行两条指令:首先,是对内存位置 YYY 的一次存储操作;其次,是从一个完全不相关的位置 XXX 的一次加载操作。在 SC 的严格规则下,处理器在确定对 YYY 的存储操作已被所有核心看到之前,无法确定执行从 XXX 的加载操作是否安全。它实际上必须等待整个系统确认其写入后,才能放心地继续执行其他内存操作。这在程序逻辑上本不存在依赖关系的地方,制造了一个依赖,一个瓶颈。核心只能闲置,等待一个全局的“解除警报”信号。

如果量化这一点,性能损失是惊人的。一个本可以通过同时执行独立指令来利用并行性的程序,被迫进入了串行爬行状态。一个在现代处理器上可能需要 13 个周期的计算,如果被迫遵守 SC 严格的排序规则,可能需要 21 个周期甚至更多,仅仅因为处理器重叠执行独立任务的能力被削弱了。收回这些损失的性能,是更“宽松”的内存模型存在的唯一原因。

为速度而立的契约:宽松一致性的世界

为了获得更快的速度,处理器架构师与程序员达成了一项契约。他们实际上是说:“我们将打破顺序一致性的幻觉。作为回报,你的程序将运行得快得多。不过,我们会在你绝对需要时,提供给你恢复秩序的工具。”这就是​​宽松一致性​​的世界。

最常见和最根本的宽松化来自​​存储缓冲区​​。当一个核心执行写入操作时,它不会等待这个操作一直传播到主内存,而是直接将值写入一个小的、私有的缓冲区——它的“发件箱”。从该核心的角度来看,写入已经完成,它可以立即继续执行下一条指令。存储缓冲区的内容将在后台被排空到主内存。

这一个机制就导致了并行计算中最著名的怪异现象。考虑两个线程:

  • 线程 A: x = 1, 然后 r1 = y
  • 线程 B: y = 1, 然后 r2 = x

初始时,xxx 和 yyy 均为零。在 SC 模型下,r1r_1r1​ 和 r2r_2r2​ 不可能最终都为零。为了使 r1r_1r1​ 为零,线程 A 对 yyy 的读取必须在线程 B 对 yyy 的写入之前发生。为了使 r2r_2r2​ 为零,线程 B 对 xxx 的读取必须在线程 A 对 xxx 的写入之前发生。这在单一时间线上造成了一个逻辑悖论:A 的写入必须在 B 的读取之后,B 的读取在 B 的写入之后,B 的写入又在 A 的读取之后,A 的读取又在 A 的写入之后。这形成了一个环!Awrite→Aread→Bwrite→Bread→AwriteA_{write} \rightarrow A_{read} \rightarrow B_{write} \rightarrow B_{read} \rightarrow A_{write}Awrite​→Aread​→Bwrite​→Bread​→Awrite​。这是不可能的。

但有了存储缓冲区,不可能的事情变成了现实。过程如下:

  1. 线程 A 执行 x = 1。值 1 被放入其私有的存储缓冲区。这个值对线程 B 还不可见。
  2. 线程 B 执行 y = 1。值 1 被放入它的私有存储缓冲区。这个值对线程 A 还不可见。
  3. 线程 A 执行 r1 = y。由于线程 B 的写入仍在它的缓冲区中,线程 A 从主内存中读取了 yyy 的旧值:r1=0r_1 = 0r1​=0。
  4. 线程 B 执行 r2 = x。由于线程 A 的写入仍在它的缓冲区中,线程 B 读取了 xxx 的旧值:r2=0r_2 = 0r2​=0。

这个结果,(r1,r2)=(0,0)(r_1, r_2) = (0, 0)(r1​,r2​)=(0,0),在大多数现代处理器上是完全合法的。一次存储操作与后续加载操作的表观重排序(Store-Load 重排序)是进入宽松一致性世界的这第一步的标志。

在混乱中导航:怪异现象的层级

“宽松”不是单一的状态,而是一个模型谱系,每个模型都由它选择放宽哪些规则来定义。

一个常见且重要的模型是​​全局存储顺序(Total Store Order, TSO)​​,x86 等处理器实现的正是这种模型。TSO 允许我们刚才看到的 Store-Load 重排序,但它增加了一个关键保证:存储缓冲区是先进先出(FIFO)的。如果一个核心先写入 xxx 再写入 yyy,那么可以保证其他核心看到对 xxx 的写入变得可见的时间不晚于对 yyy 的写入。发件箱可能会有延迟,但其内容是按顺序处理的。

这个 FIFO 属性使得某些常见的编程模式在 x86 上“恰好能用”。一个经典的例子是消息传递:

  • 生产者:data = 42; flag = 1;
  • 消费者:while (flag == 0) {}; r = data;

在 TSO 机器上,这是安全的。因为对 data 的写入在对 flag 的写入之前,FIFO 的存储缓冲区确保了当消费者看到 flag 变为 1 时,data 的值保证是 42。

然而,许多其他架构,如 ARM 和 POWER,使用了​​更弱的模型​​。它们不仅有存储缓冲区,而且它们的存储缓冲区不是 FIFO 的。硬件可能出于性能原因,决定让 flag = 1 的写入先于 data = 42 的写入对系统可见。在这种情况下,消费者可以看到标志,然后读取数据,但得到的是旧的、过时的值。这不是假设;这是这些平台上真实存在的错误来源。 这些模型放宽了 Store-Store 顺序,允许更激进的优化和更多可能出现的“怪异”结果。

驯服野兽:屏障、栅栏与保证

在这个混乱的世界里,人们如何编写正确的代码呢?程序员被赋予了工具来约束硬件,在需要时恢复秩序。这些工具被称为​​内存屏障​​或​​内存栅栏​​。屏障是一条指令,它告诉处理器停下来并强制执行某种顺序。例如,在 ARM 处理器上,在 data = 42 和 flag = 1 之间插入一个 store-store 屏障,会告诉它:“你必须确保 data 的写入全局可见之后,才能考虑让 flag 的写入可见。” 这就修复了消息传递的错误。

使用特定于架构的屏障既笨拙又不可移植。现代的解决方案是使用语言级的同步原语,例如 C++11 和其他语言中定义的原子操作。这些原语提供了可移植的语义,如​​释放(release)​​和​​获取(acquire)​​。

  • 带有​​释放​​语义的存储操作就像一位经理说:“我这部分项目完成了,我所有的工作现在都可以供审查了。” 它保证了在程序中位于它之前的所有内存写入,都在这个释放存储操作变得可见之前完成。
  • 带有​​获取​​语义的加载操作就像一位经理说:“在审阅完你完成的报告之前,我不会开始我的工作。” 它保证了所有位于它之后的内存读取,都将看到执行释放操作的线程所产生的效果。

在我们的消息传递示例中,如果生产者对标志使用 release 存储,而消费者使用 acquire 加载,那么在任何架构上这个错误都会被修复。release-acquire 对创建了一种“先行发生(happens-before)”关系,以一种可移植、高级的方式提供了我们所需要的确切排序保证。

宽松世界中的基本真理

即使在宽松一致性的混乱世界中,一些基石般的原则依然存在,为理智提供了基础。

首先是​​缓存一致性(coherence)​​和​​内存一致性(consistency)​​之间的区别。缓存一致性是一个局部属性;它保证对于单个内存位置,所有处理器都会就对该位置的写入序列达成一致。这关乎于保持文件柜中一个文件的一致。内存一致性是一个全局属性;它管辖跨越不同内存位置的操作顺序。消息传递的错误是内存一致性的失败,而不是缓存一致性的失败。系统在 data 和 flag 上各自是完全一致的;问题在于它们的相对顺序不是程序员所期望的。[@problem_-id:3654059]

其次是​​原子性(atomicity)​​的概念。这比排序更为根本。如果一个操作看起来是不可分割地、瞬间发生的,那么它就是原子的。如果你通过两次独立的 8 位写操作来写入一个 16 位的值,另一个线程可能会在你的更新过程中读取这个值,得到新的低位字节和旧的高位字节。这被称为​​撕裂读(torn read)​​。内存模型关乎排序,但原子性关乎单个操作的完整性。现代硬件通常对对齐的、字大小的访问保证原子性。对于其他任何情况,或者为了绝对确保,你必须使用特殊的 atomic 类型和指令,它们能在所有平台上防止撕裂。

最后,也是最深刻的一点,即使是最弱的内存模型也有防止彻底胡言乱语的护栏。它们禁止​​“无中生有”​​地创造值。考虑这个奇怪的程序:

  • 线程 1: r1 = y; x = r1;
  • 线程 2: r2 = x; y = r2;

在 x=0x=0x=0 和 y=0y=0y=0 的初始状态下,这个程序能否最终导致 r1=r2=42r_1 = r_2 = 42r1​=r2​=42?其理由必然是循环的:线程 1 读取了线程 2 将要写入的 42,这个 42 基于线程 1 将要写入的 42,而线程 1 的 42 又基于线程 2... 这就像一条蛇在吃自己的尾巴。处理器可以推测性地“猜测”结果,然后让自己的行为来证明这个猜测是正确的。这违背了因果性。所有理智的内存模型,无论是强的还是弱的,都明确禁止这一点。必须存在一个因果事件链。结果不能先于其原因。这个基本法则揭示了即使在最宽松、最混乱的系统中也存在的深层、潜在的逻辑,确保了尽管它们为了性能而表现出种种怪异,但其核心仍然是理性的机器。

应用与跨学科联系

在我们穿越了内存一致性基本原理的旅程之后,你可能会留有一种精美、抽象的钟表机械感。但这绝非纯粹的学术探讨。内存排序的概念不仅仅是理论构建;它们是维系现代计算整个结构的无形之线。没有它们,我们所知的数字世界将陷入数据错乱和行为不可预测的混乱之中。让我们走出原理的领域,看看这些思想如何在现实世界中体现,从你日常使用的应用程序,到运行你机器的操作系统,再到硅与软件交汇的底层硬件。

程序员的契约与人工智能革命

想象一个 AI 研究团队正在构建一个前沿模型。他们程序的一部分,即“生产者”,在不断地优化一个巨大的神经网络权重向量。在每个训练周期结束后,它通过更新一个简单的周期计数器来表明一套新的、改进的权重已经准备就绪。程序的另一部分,即“消费者”,则监视着这个计数器。当它看到数字增加时,就会抓取新的权重,用验证数据集来运行它们。从纸面上看,逻辑很简单:

  1. 生产者:完成将所有新权重写入内存位置 xxx。
  2. 生产者:将新的周期编号写入内存位置 yyy。
  3. 消费者:在 yyy 中看到新的周期编号。
  4. 消费者:从 xxx 中读取新权重。

这会有什么问题呢?在现代多核处理器上,所有环节都可能出问题。为了速度,处理器自认为有权重排其操作的权利。它可能会让 yyy 中的新周期编号在完成所有 xxx 中权重更新的可见性之前,就对消费者可见。消费者看到信号后,会读取到新旧权重混杂的怪异数据,导致无意义的验证结果。这就是经典的数据竞争,是程序员的噩梦。

这时,内存模型就成了程序员最关键的盟友。像 C++11 这样的高级语言提供了一种可以与硬件达成的“契约”。通过将周期计数器 yyy 声明为原子变量并使用特定的内存顺序,程序员可以强制执行纪律。生产者在更新周期计数器时执行一次​​存储-释放(store-release)​​操作。这是一个承诺:“我庄严宣誓,我在此之前所做的所有内存写入都已完成。” 消费者则相应地使用一次​​加载-获取(load-acquire)​​操作来读取计数器。这是一种信任行为:“在确认生产者的承诺之前,我不会继续进行。”

这种释放-获取配对创建了一种“同步于(synchronizes-with)”关系,这是一座形式化的桥梁,保证任何看到释放结果的线程也能看到它之前的所有内存操作。对权重的写入先行发生于对权重的读取。有趣的是,这个高级合同根据硬件的不同会被翻译成不同的形式。在强有序的 x86 处理器上,硬件的自然行为非常严格,以至于 release 和 acquire 通常被编译成简单的移动指令。然而,在弱有序的 ARM 处理器上,编译器必须生成特殊的指令(STLR/[LDA](/sciencepedia/feynman/keyword/local_density_approximation)R)来建立必要的屏障。这种优雅的抽象允许程序员编写出能够在迥异架构上高效运行的正确并发代码,但它也揭示了一个常见且危险的误解:认为使用 volatile 关键字就足够了。并非如此。volatile 只告诉编译器不要优化掉读写操作;它对硬件的线程间排序不做任何承诺,从而为数据竞争敞开了大门。

编译器的困境:优化的风险

内存模型不仅是程序员与硬件之间的合同;它也是编译器必须遵守的一套严格规则。编译器的任务是让代码运行得更快,它有一系列聪明的技巧来实现这一点。其中一个技巧叫做循环不变量代码外提(Loop-Invariant Code Motion, LICM)。如果一个循环内的操作每次都产生相同的结果,为什么不干脆在循环开始前只做一次呢?

考虑一个线程在等待另一个线程设置一个标志:while (flag == 0) { /* do nothing */ }。一个天真的编译器,看到循环体没有改变 flag,可能会想:“啊哈!这个对 flag 的读取是循环不变量。我就把它提取出来!” 代码变得等价于:temp = flag; while (temp == 0) { /* do nothing */ }。

在单线程世界里,这是一个绝妙的优化。在我们的并发世界里,这是一场灾难。线程读取一次 flag,看到其初始值 0,然后进入一个无限循环。它再也不会去查看 flag 的内存位置,因此也永远不会看到另一个线程的更新。程序陷入了死锁。这表明,一个不具备“并发感知”的编译器可能会破坏完全有效的代码。内存模型禁止在共享变量上进行此类优化,除非使用了同步原语,因为“不变量”的定义必须考虑系统中所有线程可能采取的行动,而不仅仅是被优化的那一个线程。

操作系统:边界的守护者

在计算机的核心——操作系统内部,内存排序的原则变得更为关键。操作系统管理着从复杂数据结构到用户程序与内核之间边界的一切事物。

想象一个并发链表,这是一个基本的数据结构,其中一个线程在末尾添加新节点,而另一个线程正在遍历它。生产者线程分配一个新节点,向其写入数据,然后通过将前一个尾节点的 next 指针链接到这个新节点来发布它。一个可怕的可能性出现了:“部分发布节点的幽灵”。一个正在遍历的消费者线程可能会读到更新后的 next 指针,跳转到新节点,却发现其数据字段仍然充满了垃圾,因为处理器让指针的写入先于数据的写入变得可见。解决方案是我们之前见过的 release-acquire 模式:对 next 指针的更新必须是一个 release 操作,而遍历必须用 acquire 来读取它,从而确保在访问节点本身之前,节点的内容是可见的。同样的逻辑对于无数内核操作至关重要,例如惰性初始化内存分配器。

这个主题在最根本的边界——系统调用——上继续。当你的程序调用 write(fd, my_buffer, size) 时,它是在向内核发出一个请求。陷入内核的行为是否是一个神奇的内存屏障,能确保内核看到你程序之前的所有写入?答案比简单的“是”或“否”更微妙。对于 my_buffer 中的特定数据,正确性通常能得到保证,因为用户代码和内核处理程序在同一个 CPU 核心上运行,该核心会遵守自身的程序顺序。然而,系统调用不是针对不相关内存地址的通用内存屏障。一个巧妙的“石蕊试纸(litmus test)”实验可以证明这一点:如果一个用户程序先写入位置 YYY,然后写入一个标志 XXX,接着进行一次系统调用,一个弱有序的内核可能看到新的 XXX 但却是旧的 YYY 值。这证明了架构师和操作系统开发者不能依赖隐式的保证;他们必须用科学的严谨性来推理这些边界。

最后的疆域:与物理世界对话

内存模型在 CPU 与其他硬件设备——网卡、存储控制器和 GPU——的原始接口处最为关键。这些对话通过内存映射 I/O(MMIO)或直接内存访问(DMA)总线进行,如果没有严格的纪律,它们将变得无法理解。

考虑一个设备驱动程序向一个简单设备发送命令。协议是先将命令数据写入一个 DATA 寄存器,然后写入一个 STATUS 寄存器来按响设备的“门铃”。如果 CPU 重排了这两次写入,设备会先收到门铃通知,然后读取 DATA 寄存器,得到的是过时的、无意义的数据。为了防止这种情况,驱动程序必须在这两次写入之间插入一个​​写内存屏障​​。这个屏障是对 CPU 的一个命令:“在此点之后的所有写入,在之前的所有写入完成之前,都不能对外界可见”。

当设备向 CPU 发送数据时,情况是完全对称的。一个网络接口控制器(NIC)可能会使用 DMA 将数据包的有效载荷写入内存,然后写入一个描述符来宣告数据包的到达。一个轮询的 CPU 线程看到描述符,然后继续读取数据包。但 CPU 自身的推测执行可能会导致它在明确完成读取新描述符之前就去读取数据包数据,再次导致陈旧读取。解决方案是一个​​读内存屏障​​。在读取描述符之后,CPU 执行这个屏障,它命令道:“在我之后的所有内存读取,必须等到我之前的所有内存读取都完成后才能执行。”。

有人可能会想,是否有捷径可走。如果 NIC 不是写入内存位置,而是引发一个中断呢?触发中断这一重大的系统事件,肯定会同步内存吧?这是一个强大而危险的迷思。中断是一个异步信号,它通过与 DMA 内存写入不同的路径传播。它不提供任何固有的内存排序。操作系统中的中断处理程序在安全访问设备在引发中断前写入的数据之前,仍然需要发出一个读内存屏障。

从高级的 AI 算法到低级的设备中断处理程序,我们发现了同样的故事、同样的危险和同样优美、统一的解决方案。内存模型看似深奥的规则是并发的通用语法,让你的计算机内部那个由独立代理组成的混乱集市能够进行连贯、可靠的对话。它们是使我们复杂的数字世界成为可能的无形架构。