
在多核处理器的世界里,我们编程时常常带有一个简单的幻觉:所有处理器核心都看到一个统一的内存,其中所有的更改都按顺序即时显现。这个被称为顺序一致性 (Sequential Consistency) 的直观模型,为并发编程提供了一个可预测的基础。然而,对性能的不懈追求已导致现代硬件采用了更复杂、更微妙的规则,这在程序员对代码的直观推理与机器实际执行方式之间造成了鸿沟。本文旨在通过揭开内存一致性模型这一神秘世界的面纱,来弥合这一鸿沟。
本次探索分为两部分。在第一章“原理与机制”中,我们将剖析基本概念,对比顺序一致性的严格世界与宽松内存模型的更快但更危险的世界。我们将揭示导致意外行为的硬件技巧,如存储缓冲区,并介绍程序员用以恢复秩序的基本工具——内存屏障和原子操作。在第二章“应用与跨学科联系”中,我们将看到这些原理的实际应用,发现它们是构建健壮的操作系统、可靠的设备驱动程序和正确的并发应用程序的关键基础,揭示了支撑我们数字世界的统一逻辑。
想象一下,你和几个人在图书馆里,共同处理一份摊在中央桌子上的大型共享文档。为了交流,你们在文档的页边空白处写笔记。你自然会假设一些简单的规则:如果你先写下笔记 A,再写下笔记 B,其他人会看到 A 在 B 之前出现。如果有人阅读文档,他们会看到那一刻的最新版本。这个简单、直观的世界正是程序员为多核处理器编写代码时所期望的。但现代硬件的现实要复杂、微妙和迷人得多。
在多核处理器的核心,几个独立的大脑——核心 (cores)——并行工作。它们通过一个共享资源进行通信:主内存。当我们编写并发程序时,我们常常在一个方便的幻觉下操作:所有核心都看着完全相同的、单一的内存。我们想象当一个核心将一个值写入内存位置,比如 $x = 1$,这个变化会立即同时对所有其他核心可见。这个令人安心的图景就是顺序一致性 (Sequential Consistency, SC) 模型。
顺序一致性是我们直觉上期望的黄金法则。它做出两个简单的承诺:
可以把它想象成一个全局时间线。每个核心的每一次内存操作都被放置在这条时间线上的某个位置。唯一的约束是,一个核心自身的操作不能相对于彼此进行重排。
让我们看看我们的直觉是否与这条规则匹配。考虑两个线程 和 ,以及两个共享变量 和 ,初始值都为 。
有没有可能两个线程都读到值 呢?在顺序一致性下,答案或许令人惊讶,是肯定的!。我们可以构建一个有效的全局时间线来产生这个结果:
这个序列尊重了两个线程的程序顺序(每个线程都是先 read 后 write),并导致两者都读到 。到目前为止,一切顺利。
现在让我们翻转操作。
两个线程是否仍然可能都读到 ?让我们尝试为这个结果构建一个 SC 时间线,其中 读到 而 读到 。
但是程序顺序要求 写入 在它读取 之前,而 写入 在它读取 之前。如果我们将这些要求串联起来,我们会得到一个不可能的循环: 对 的读取必须发生在 对 的写入之前,而 对 的写入必须发生在 对 的读取之前,而 对 的读取又必须发生在 对 的写入之前,而 对 的写入又必须发生在 对 的读取之前。你不能让一个事件发生在它自己之前!这是一个悖论。因此,在顺序一致性下,这个结果是被禁止的。SC 是合乎逻辑、严格且可预测的。那么为什么任何处理器设计师会放弃它呢?
答案,就像在计算领域中经常出现的那样,是性能。现代处理器核心是一个不耐烦的野兽。它每秒可以执行数十亿条指令。等待一个写操作一路跋涉到主内存再返回,对处理器时间来说是永恒。这就像一个厨师停止厨房里的所有工作,亲自看着一道菜被送到餐桌上。
为了避免这种延迟,核心使用了几种技巧。它们有自己的本地高速缓存 (caches),这是一种小而快的内存存储。而且,至关重要的是,它们有存储缓冲区 (store buffers)。存储缓冲区就像核心的私人记事本。当一个核心需要写入一个值,比如 $x = 1$,它不会等待。它只是在自己的存储缓冲区里草草记下“将 设为 1”,然后立即继续执行下一条指令。之后,当内存系统不那么繁忙时,存储缓冲区的内容才会被清空并持久化到高速缓存和主内存中。
这是一个绝妙的优化,但它打破了顺序一致性的简单幻觉。让我们重新审视我们那个“被禁止”的场景:
在一个带有存储缓冲区的真实处理器上,情况可以是这样的:
$x = 1$。这个写操作被放入 的存储缓冲区。从其他所有人的角度看, 仍然是 。 立即继续。$y = 1$。这个写操作进入 的存储缓冲区。从其他所有人的角度看, 仍然是 。 立即继续。结果呢?两个线程都读到了 。那个“不可能”的结果发生了!。这种行为,即一次存储操作后紧跟一次对不同地址的加载操作,看起来被重排了,是一个常见的宽松内存一致性模型的标志,即全局存储顺序 (Total Store Order, TSO),我们熟悉的 x86 处理器就非常接近这个模型。
此时,你可能会问:“那缓存一致性呢?” 一致性协议,如著名的 MESI 协议,旨在确保所有核心对内存有一个一致的视图。这是真的,但一致性 (coherence) 和一致性 (consistency) 不是一回事。
缓存一致性 (Cache coherence) 是一个局部属性。它保证对于任何单个内存位置,所有核心都会就该位置的写入序列达成一致。它防止两个核心在同一时间对 $x$ 持有不同的有效值。
内存一致性 (Memory consistency) 是一个全局属性。它定义了对不同内存位置的操作的表观排序规则。
一个系统可以做到完美的缓存一致性,但不是顺序一致的。想象一个场景,一个线程先写入 $x$,然后写入 $y$。由于高速缓存和存储缓冲区的复杂交互,对 $y$ 的写入可能在对 $x$ 的写入之前对其他核心可见。另一个线程可能因此读到 $y$ 的新值,但读到 $x$ 的旧值。每个位置($x$ 和 $y$)都是缓存一致的——在任何给定时刻,每个人都同意它的值——但是跨位置的写入顺序被打乱了,违反了 SC。这就是关键区别:缓存一致性确保我们都同意书中某一页的历史记录;内存一致性定义了我们如何感知写在不同页面上的句子的顺序。
如果硬件要对我们的排序规则掉以轻心,我们如何编写正确的程序?我们需要一种方法告诉处理器:“停!这里的顺序真的很重要。”我们通过称为内存屏障 (memory fences)(或栅栏, barriers)的特殊指令来做到这一点。
一个完整的内存屏障就像一个严格的命令:“在所有先前的内存读写操作完成并对所有人可见之前,不要越过此点。” 在我们那个存储缓冲区的例子中,在 write 和 read 之间插入一个屏障,将强制处理器在执行读取之前清空其存储缓冲区,从而防止非 SC 结果的发生。
然而,屏障是一个钝器。还有一个更根本的问题:读和写这个行为本身。如果操作本身不是一个单一、瞬时的事件怎么办?考虑一个 16 位变量 $x$。一个线程可能会先写低字节,再写高字节来更新它。如果另一个线程在这两次单字节写入之间执行一次 16 位读取,它将看到一个“撕裂”的值——一个由新旧数据混合而成的无意义的值。
这就是原子操作 (atomic operations) 发挥作用的地方。当我们在现代编程语言中将一个变量声明为 atomic 时,我们是在要求编译器和硬件保证对其的所有操作都是不可分割的。一次 16 位原子读或写将一次性全部发生,或者根本不发生。没有其他线程能看到它执行到一半的状态。这种防止撕裂的保证是基础性的,即使在宽松内存模型上也成立。
此外,原子操作可以被赋予排序语义。例如,一个存储-释放 (store-release) 操作保证它之前的所有内存操作都在这次存储本身变得可见之前完成。一个加载-获取 (load-acquire) 保证它之后的所有内存操作都不能在加载完成之前开始。当一个线程执行一个加载-获取操作,读到了一个来自存储-释放操作的值时,就发生了一次同步“握手”,在两个线程的代码块之间建立了一个明确的先后关系。这是一种比使用完整屏障更精准、更高效的强制排序方式。
内存模型的世界甚至更加离奇。TSO 尽管宽松,但仍坚守一个重要原则:多副本原子性 (multi-copy atomicity)。这意味着当一次写入最终变得可见时,它会同时对所有其他核心可见。存在一个所有人都同意的、单一的全局写入时间线。
但某些架构,如 Power 和 ARM,具有更弱的模型。它们可能是非多副本原子性的 (non-multi-copy atomic, nMCA)。在这样的系统中,一个核心的写入可能在不同时间对其他核心变得可见。想象一下核心 0 写入 $x=1$。由于芯片复杂布线中的随机传播延迟,核心 2 可能在 0.2 微秒后看到 $x=1$,而核心 3 在 0.6 微秒时读取,可能仍然看到旧值 $x=0$,因为这个消息还没传到它那里。
这粉碎了我们关于共享现实的最基本直觉。两个观察者可以在(几乎)同一时间观察同一个变量,却看到不同的历史。这可能发生,因为一个核心可能被允许在某个值提交到全局可见的缓存系统之前,就直接从另一个核心的存储缓冲区“窥探” (snarf) 到这个值。TSO 明确禁止这样做,以维护其单一、统一的存储事件时间线。在更弱的模型中,我们为了更高的性能放弃了这条统一的时间线,从而给程序员带来了更大的负担,需要他们使用屏障和原子操作来建立顺序。
至关重要的是要记住这些内存一致性模型所管辖的范围:线程访问共享内存空间的复杂舞蹈。它们是硬件层面的交战规则。然而,它们并不规定操作系统提供的更高级别抽象的行为。
例如,当你的程序向一个文件写入时,它会向操作系统内核发出一个系统调用。然后内核管理对磁盘的物理写入。文件 I/O 的规则由操作系统及其 API(如 POSIX)定义。例如,如果你用 O_APPEND 标志打开一个文件,操作系统保证每次 write 调用都是一个原子操作,将数据放在文件末尾,防止其他线程干扰。这是一个操作系统层面的保证,与处理器的内存一致性模型完全分离。理解这个边界——一边是硬件内存的狂野、重排的世界,另一边是操作系统资源的有序、抽象的世界——是编写正确和健壮的并发软件的最后一块拼图。
在上一章中,我们穿行于内存一致性的基本原理之间,学习了那些支配计算机不同部分如何感知内存状态的微妙且时而违反直觉的“语法”。这些规则——顺序一致性、宽松排序、屏障和释放-获取语义——可能看起来很抽象。但它们不仅仅是计算机架构师的理论构想。它们是编织现代计算结构之布的无形丝线。
现在,我们将看到这套语法的实际应用。我们将聆听在你的设备内部持续进行的无声、高速的对话。我们将发现这些基本原理如何成为构建一切事物的关键,从启动你计算机的操作系统,到将其连接到外部世界的驱动程序,再到在其上运行的应用程序。这是一段揭示惊人统一性的旅程:无论是与硅晶片对话,还是构建区块链,关于排序和可见性的同样深刻的思想反复出现。
操作系统 (OS) 内核是你计算机的总指挥,它是一个复杂的软件,必须协调硬件和软件组件的交响乐。为了做到这一点而不产生杂音,它深刻地依赖于内存模型的保证。
想象一下创建新进程的动作——当你双击一个应用程序图标时。许多现代操作系统使用一个叫做“写时复制”(copy-on-write) 的巧妙技巧。操作系统不是为新的子进程浪费地复制所有父进程的内存,而是简单地让它们共享物理内存页,并将这些页标记为只读。只有当其中一个进程试图写入一个共享页时,才会最终制作一个私有副本。但这提出了一个微妙的问题。假设父进程向一个变量 写入了一个新值,然后调用 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 原语来创建子进程。我们如何确保在另一个处理器核心上运行的子进程会读到 的新值而不是旧值?答案是,操作系统本身必须将进程创建原语视为一个同步事件。父进程中的调用扮演释放 (release) 的角色,而子进程中从调用返回则扮演获取 (acquire) 的角色。这建立了一个先行发生 (happens-before) 关系,在时间上创造了一道屏障。这是操作系统保证父进程在 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 调用之前所做的一切,对子进程开始其生命周期后都是可见的方式。如果没有这个内建于操作系统结构中的隐式内存屏障,创建新进程将是一场不可靠、充满竞争条件的噩梦。
这种对顺序的需求延伸到了 CPU 理解内存的核心方式:虚拟内存。内核维护着称为页表 (page tables) 的数据结构,以将程序使用的虚拟地址转换为 RAM 芯片的物理地址。当内核需要更改此映射时,它会向这些表中写入新条目。然而,CPU 内部一个名为“页表遍历硬件”(page walker) 的特殊硬件,正在不断地读取这些相同的表以执行地址转换。如果页表遍历硬件偶然发现一个未完成的更新怎么办?它可能会读到一个指向较低级别表的新指针,但发现该表中的条目尚未写入,从而导致系统崩溃。为了防止这种情况,架构提供了解决方案。在像 x86 这样的处理器上,切换活动页表的特殊指令(对 $cr3$ 寄存器的写入)是串行化 (serializing) 的。它就像一个强大的屏障,强制所有先前的内存写入在切换生效前变得全局可见。在内存模型较弱的架构上,这种保证不存在,操作系统程序员必须在激活新表之前插入一个显式的内存屏障。在这两种情况下,目标是相同的:确保 CPU 内部的自主代理——硬件页表遍历硬件,永远不会从部分更新的页表中读到“谎言”。
计算机不是一座孤岛;它必须与外部世界对话。你的每一次按键、屏幕上的每一个像素、来自互联网的每一个数据包,都涉及 CPU 和一块硬件之间的对话。这种通信几乎总是通过共享内存发生,在这个领域,内存一致性模型不仅重要,而且是绝对关键的。
考虑一个机器人平台,CPU 在其中运行一个控制循环。在每次迭代中,它为机器人的马达计算新指令,将它们写入内存缓冲区,然后写入一个特殊的内存映射I/O (MMIO) 寄存器以触发马达控制器。在一个弱序 CPU 上,硬件可能会为了性能而重排这些操作。它可能在新的指令数据实际从 CPU 的私有缓存刷新到主内存之前,就执行了“触发”写入。马达控制器看到触发信号后,就会读取缓冲区并根据陈旧的、旧的指令行动——这可能是一个灾难性的错误。解决方案是驱动程序程序员插入一个存储屏障 (store barrier) 或在触发写入上使用存储-释放语义 (store-release semantics)。这是给 CPU 的一个直接命令:“确保我在此之前写入的所有指令数据在系统其他部分可见之后,才让这个触发写入变得可见”。
对话也向相反方向流动。网络接口控制器 (NIC) 可能会接收一个数据包,通过直接内存访问 (DMA) 将其内容写入主内存中的一个缓冲区,然后更新内存中的一个描述符标志以表示“数据包已就绪”。在一个宽松架构上,一个轮询此标志的 CPU 核心可能会掉入类似的陷阱。它可能会在其读取标志确认数据包确实已就绪之前,就推测性地执行对数据包数据的读取。为了防止处理不完整或垃圾数据,CPU 驱动程序必须使用读取屏障 (read barrier) 或加载-获取语义 (load-acquire semantics)。这个原语就像一扇门,强制执行顺序:“在你成功观察到标志被设置之前,不要继续读取数据包数据”。
有人可能想知道某些系统事件是否提供“自然”的排序。例如,如果一个设备向内存写入数据,然后引发一个中断,那么当 CPU 执行中断处理程序时,数据肯定必须是可见的吧?这是一个危险且常常是错误的假设。中断信号和 DMA 数据在机器中通过不同的物理和逻辑路径传播。快速的中断信号完全有可能在较慢的 DMA 写入完成其在内存层次结构中的旅程之前到达 CPU 并触发处理程序。中断仅仅是门铃;它并不会神奇地将包裹传送到门口。中断处理程序仍然必须执行一次获取 (acquire) 操作,以确保数据已经到达,然后才能尝试使用它。
支撑所有这些 CPU-设备通信的是由操作系统建立的一个契约。用于 MMIO 寄存器的内存区域不能像普通内存一样对待。操作系统必须配置页表,将这块内存映射为非缓存 (uncached) 和强序 (strongly-ordered)。这告诉 CPU 硬件,对于这些特定地址,要暂停其通常激进的缓存和重排策略。试图使用普通的、缓存的内存映射与设备通信从根本上是错误的;即使最巧妙地使用屏障也无法修复一个通信通道,在这个通道中,写入可能永远不会离开 CPU 的私有缓存,而读取则满足于陈旧的缓存数据,而不是去查询设备本身。
支配 CPU 与硬件之间精妙舞蹈的相同原则,也是正确并发编程的基础,在并发编程中,多个软件线程协作完成一项任务。
经典的生产者-消费者问题是一个完美的缩影。想象一个线程,生产者,创建物品并将其放入一个共享的邮箱。写入物品后,它递增一个计数器以表示有新物品可用。第二个线程,消费者,轮询该计数器。当它看到计数增加时,它就读取物品。这会有什么问题?在一个宽松的机器上,消费者可能观察到更新后的计数器,但在生产者的写入操作对它可见之前就去读取物品的内存,导致消息混乱。解决方案是我们之前在设备驱动程序中看到的那个优雅的释放-获取模式。生产者的计数器写入必须是一个释放 (release) 操作,而消费者的计数器读取必须是一个获取 (acquire) 操作。这个简单而强大的配对建立了必要的先行发生保证,将潜在的数据竞争转变为一个健壮可靠的通信渠道。
这个模式不仅仅是学术上的。考虑一个现代的区块链系统。一个核心,“验证者”,可能会检查一笔交易的有效性。一旦验证通过,它将交易数据写入一个共享内存池,然后设置一个标志。另一个核心,“矿工”,轮询该标志。当它看到标志被设置时,它就抓取该交易以包含在一个候选区块中。如果由于宽松的内存排序,矿工在交易数据完全可见之前就读取了它,它可能会在区块链中包含一个无效或不完整的交易。一个价值数十亿美元的金融系统的完整性,其核心可能就取决于对一个释放-获取对的严谨使用。
在这场看不见的对话中,还有一个最后的、关键的角色:编译器。编译器是一个优化器,一个过分热心的助手,其工作是让你的代码运行得更快。有时,它对效率的单一追求可能会导致它在不知不觉中破坏你精心构建的同步契约。
让我们回到我们的消费者线程,它在一个循环中空转等待一个标志:while (flag == 0) { /* spin */ } r = read(data);。循环内部对 flag 的 load-acquire 是为了保护后续对 data 的读取。然而,一个执行像循环不变量代码外提 (Loop-Invariant Code Motion, LICM) 这样的机器无关优化的编译器可能会审视这段代码。它看到,从单线程的角度来看,data 的值并没有被循环改变。为了“高效”,它可能决定将 read(data) 操作提升到循环开始之前。
这个看似无害的转换对正确性来说是一场灾难。它将对 data 的读取从其在 load-acquire 之后 的安全位置移动到了 之前 的危险位置。这个优化完全拆除了同步协议,重新引入了程序员努力防止的那个数据竞争。这揭示了关于现代系统的一个深刻真理:编译器不能对并发性一无所知。一种语言中定义的内存排序语义,如 C++11 的原子类型,不仅仅是建议。它们构成了一个严格的契约。一个 acquire 操作必须阻止后续的内存操作被重排到它之前,这不仅是硬件的要求,也是对编译器的要求。一个健壮的编译器中间表示 (Intermediate Representation, IR) 必须内建这些语义,强制所有优化过程都尊重作为正确并发代码基础的排序约束。
从管理页表的最底层操作系统,到与硬件对话的设备驱动程序,再到协作任务的并发线程,甚至到编译器的逻辑转换,内存一致性的原则是整个系统能正常运作的统一秘诀。理解这套看不见的语法——释放-获取的握手和内存屏障的交通信号——是将编程从一门手艺提升为一门工程学科的关键。它让我们得以一窥那深刻、优美且惊人统一的逻辑,正是这种逻辑使我们复杂的数字世界成为可能。