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

弱内存模型

SciencePedia玻尔百科
核心要点
  • 为追求性能,现代 CPU 放弃了严格的顺序一致性,从而产生了弱内存模型,在这种模型中操作会被重排序。
  • 这种重排序会导致反直觉的异常,例如消费者在看到关联数据之前就看到了“就绪”标志。
  • 程序员必须使用显式同步机制,如内存屏障或 acquire-release 语义,来强制保证正确的操作顺序。
  • 如果没有对内存排序规则的深刻理解,就不可能构建正确的无锁数据结构和管理安全的内存回收。

引言

在理想世界中,计算机的内存就像一个单一的共享账本,每个处理器都能以完全相同的顺序看到每一个变化。这个简单的概念被称为“顺序一致性”(Sequential Consistency),也是我们直观认为并发程序应该工作的方式。然而,对性能的不懈追求已导致现代计算机架构师放弃了这一简单的现实。为了达到惊人的速度,CPU 采用了大量优化手段——缓存、存储缓冲和乱序执行——这些手段实际上打破了单一时间线的幻象。这创造了一个由弱内存模型原则支配的、远为复杂和混乱的环境。理解这种隐藏的混乱并非学术探讨,而是任何编写正确且高效并发软件的人的绝对必需。

本文旨在作为掌握这一复杂领域的指南。在接下来的章节中,您将踏上进入这个世界的旅程:

  • ​​原理与机制​​ 将揭示弱内存模型存在的原因,探索它们创造出的奇异的“机器中的幽灵”——从撕裂读到因果悖论——并介绍优雅的同步语言,例如让我们能够恢复秩序的 acquire-release 语义。
  • ​​应用与交叉学科联系​​ 将连接理论与实践,展示这些原则如何成为现代系统编程的基石。我们将看到它们在构建无锁数据结构、安全管理内存以及与硬件设备协调方面的关键作用,揭示程序员、编译器和芯片之间的普适契约。

通过理解这些概念,您将获得驯服现代硬件的“桀骜不驯”并构建健壮、高性能的并发系统所必需的知识。

原理与机制

宏大的幻觉:单一共享的现实

想象一下,您和一位同事正在协作编辑一个存储在云端的巨大文档。您输入一个句子,您直觉上会期望在您完成的那一刻,无论您的同事身在何处,都能看到您所写的内容。如果您先写 A 段,再写 B 段,要是他们看到 B 段出现在 A 段之前,您会感到震惊。这种共享宇宙的简单、有序的图景,即每个事件都发生在一个单一的、普遍认同的序列中,是我们脑海中都怀有的理想。

在计算世界中,这个理想有一个名字:​​顺序一致性 (Sequential Consistency, SC)​​。它是一个美好而简单的承诺:任何在多个处理器上运行的程序,其结果都等同于将它们各自的指令简单交错到一个单一的时间线中,并且来自任何一个处理器的指令在这个时间线中出现的顺序与它们在代码中编写的顺序相同。这是我们所希望的世界,一个易于推理的世界。但是,正如我们将看到的,这是一个美丽的谎言。

对速度的需求,外表下的裂痕

为什么真实世界不是顺序一致的?答案,正如工程领域中常见的那样,是对性能的不懈追求。一个现代中央处理单元(CPU)核心是一个速度不可思议的引擎,每秒能够执行数十亿条指令。相比之下,主内存是一个缓慢、遥远的数据仓库。强迫一个 CPU 核心等待每一次内存读写完成其往返主内存的漫长旅程,就像强迫 F1 赛车手在校区遵守限速一樣。性能将会惨不忍睹。

为了突破这个类似光速的限制,计算机架构师们开发了许多聪明的技巧。每个核心都有自己的私有高速​​缓存(caches)​​,以便将常用数据放在手边。为了避免写操作停顿,核心使用​​存储缓冲(store buffers)​​——就像一个个人发件箱——在那里它们可以快速记下一个更改,然后让其他电路处理稍后将其发送到主内存的缓慢任务。它们执行​​乱序执行(out-of-order execution)​​,预读程序并运行数据已就绪的指令,而不是盲目地遵循编写的顺序。

这些优化非常有效,但它们打破了单一共享现实的幻觉。每个核心现在都在自己的时间泡沫中运行,看到一个略有不同、略微延迟的内存版本。云端那份单一、原始的文档已被一堆本地缓存的副本、便利贴和乱序编辑所取代。欢迎来到​​弱内存模型(weak memory models)​​的世界。

机器中的幽灵展览

当顺序一致性的严格规则被放宽时,奇怪且反直觉的现象——机器中的幽灵——就会出现。这些不是传统意义上的“bug”;它们是一个为速度而非简单性而优化的系统的逻辑结果。

撕裂的字

对我们直觉最根本的违反是单个操作不再是单一的。我们认为向内存写入一个数字是一个不可分割的,或称​​原子(atomic)​​的行为。但如果这个数字比处理器的自然数据路径更宽呢?一个 32 位的 CPU 可能会分两次 32 位的块来写入一个 64 位的数字。一个线程在恰好错误的时机读取那个 64 位的数字,可能会看到新的前半部分 32 位块和旧的后半部分——这就是​​撕裂读(torn read)​​,它产生了一个从未真实存在过的垃圾值。这是第一个迹象,表明我们必须明确表达我们对原子性的需求,特别是对于那些没有为硬件完美对齐或适配大小的数据。

重排序的消息

也许最常见且最重要的异常出现在一个简单的生产者-消费者场景中。一个线程(生产者)准备一些数据,然后设置一个标志来表示数据已就绪。另一个线程(消费者)等待该标志,然后读取数据。

loading

这有什么可能出错呢?在一台弱排序的机器上,这个程序可能会打印出 0。消费者看到 flag 是 1,但当它读取 data 时,却得到了旧的、未初始化的值。这主要通过两种方式发生:

  1. ​​生产者侧重排序​​:生产者的 CPU 为了追求速度,可能会重排序写操作。它看到 data 和 flag 无关,所以可能会在写入 data 之前就把对 flag 的写入推送到内存系统。信号在消息之前到达。

  2. ​​消费者侧重排序​​:消费者的 CPU 可能会在确定 while 循环已结束之前,推测性地执行 print(data) 指令。它获取了 data 的旧值(0),之后才确认 flag 现在是 1。

这种通信失败是硬件重排序独立内存访问的直接结果。CPU 不知道 flag 是 data 的信号;它只看到两个独立的变量。这个问题是如此基础,以至于无处不在,从简单的基于标志的信令到复杂的无锁数据结构(如共享队列),并且在具有非一致性内存访问(NUMA)的大型系统中也是一个关键问题,其中生产者和消费者之间的物理距离加剧了这些时序问题 [@problem_id:3656202, @problem_id:3656627]。

一个常见的混淆点是缓存一致性的作用。像 MESI 或 MOESI 这样的协议确保对于任何单个内存地址,所有核心都会就其上的写操作顺序达成一致。但一致性是针对单个位置的保证。它确保内存地址 A 的故事是一致的,但对于地址 A 的事件与地址 B 的事件之间的相对时序,它什么也没说。我们的生产者-消费者问题跨越了 data 和 flag 两个地址,所以仅靠缓存一致性是不足以拯救我们的。

因果悖论

这种疯狂能走多远?程序能凭空捏造值吗?考虑一下这个奇怪的构造:

loading

这个程序有可能得出 r1 = 42 和 r2 = 42 的结果吗?在顺序一致性下,这简直可笑。要读到 42,必须先有一次写入 42 的操作。但程序只写入它刚刚读取的值。这是一个闭环。42 这个值不可能是第一个出现的。它必须是​​凭空(out-of-thin-air, OOTA)​​产生的。

这正是即使是弱内存模型也要划清界限的地方。处理器可能会推测性地读取一个值,但这种推测最终必须由另一个线程的真实写入来证实。如果一个系统里,线程 1 推测 y 是 42,并将其写入 x,然后这被用来证实线程 2 推测 x 是 42,线程 2 接着将其写入 y,从而证实线程 1 最初的推测,那么这个系统就打破了因果关系。这种循环的、自我证实的推理是被禁止的。现代内存模型,即使是最弱的,也内置了规则来防止此类悖论,确保一个基本的因果结构保持完整 [@problem_id:3G75152]。

驯服混乱:同步的语言

如果硬件可以自由地重排序我们的操作,我们怎么可能编写出正确的并发程序呢?我们需要一种方法告诉处理器:“停。这里的顺序很重要。”我们需要将我们的意志强加于机器之上。

最直接的工具是​​内存屏障(memory fence)​​(或​​memory barrier​​)。​​写内存屏障(write memory barrier, WMB)​​是一条指令,它告诉处理器:“确保我在此屏障之前发出的所有写操作都先于其后的任何写操作对系统可见。” ​​读内存屏障(read memory barrier, RMB)​​同样对读操作进行排序。在我们的生产者-消费者例子中,生产者可以在写入 data 和 flag 之间放置一个 WMB,而消费者可以在读取 flag 和 data 之间放置一个 RMB,从而恢复预期的顺序。

虽然屏障很有效,但它们可能有点“用力过猛”。一种更精炼、更具表达力的方法见于​​acquire 和 release 语义​​。这是一个优美的概念,它将同步从一个命令转变为一个契约。

  • 当一个线程需要发布信息时,比如我们的生产者设置标志,它会执行一个 ​​store-release​​ 操作。这不仅仅是一次写入,它是一个声明。它说:“我在此 release 之前所做的所有内存更改现已完成。我正在将它们发布给其他人看。”

  • 当一个线程需要读取那个信号时,比如我们的消费者检查标志,它会执行一个 ​​load-acquire​​ 操作。这是一个相应的声明:“在我获得该信号的值之前,我不会继续执行依赖于此信号的操作。”

当一个 load-acquire 读取了由一个 store-release 写入的值时,一条同步链接就建立了。硬件现在保证生产者在其 release 之前所做的所有工作对消费者在其 acquire 之后都是可见的。对 data 的陈旧读取变得不可能 [@problem_id:3656209, @problem_id:3656627]。

这种优雅的 acquire-release 之舞是现代并发编程的基本节奏。它使我们能够构建复杂、高性能且正确的结构。例如,一个用于互斥的​​自旋锁(spinlock)​​不仅仅是一条原子的 test_and_set 指令。一个正确的锁在加锁时必须具有 acquire 语义(以防止临界区内的操作被推测性地提前),在解锁时必须具有 release 语义(以确保临界区内的所有工作在另一个线程可以获得锁之前都是可见的)。锁不仅仅是访问的门,它也是可见性的门。

清醒的谱系

并非所有处理器都同样“弱”。内存模型的景象是一个谱系。

在谱系的一端,你有 x86-64 处理器相对较强的模型,称为​​全局存储顺序(Total Store Order, TSO)​​。TSO 允许处理器缓冲自己的存储操作,因此后续的加载操作可能会看起来发生在较早的存储操作之前。然而,它不会重排序存储操作相对于其他存储操作的顺序。

在另一端是像 ARM 和 POWER 这样的架构,它们的宽松模型可能允许更广泛的重排序。例如,考虑“存储缓冲”测试样例:线程 1 运行 x = 1; r1 = y;,线程 2 运行 y = 1; r2 = x;(其中 x 和 y 初始为 0)。在 TSO 上,由于存储缓冲,r1=0, r2=0 的结果是可能的,而顺序一致性禁止这种结果。更弱的模型,如 ARM 和 POWER,可能允许 TSO 禁止的重排序,例如重排序对不同内存位置的写入。

现代方法甚至更加细致,提供了一个丰富的工具包。系统可能允许程序员为单个操作指定内存排序。人们可以使对变量 x 的操作具有严格的顺序一致性行为,而对不相关的变量 y 的操作则完全放宽,从而可以对性能和推理简易性之间的权衡进行细粒度调整。

这段从顺序一致性的简单幻觉到弱排序的混乱现实,最终到像 acquire-release 这样优雅而强大的抽象的旅程,是计算机科学之美的证明。它展示了我们如何能够在本质上“桀骜不驯”的硬件基础上构建健壮、可靠且极其复杂的系统。我们学习机器中幽灵的规则,不是为了被它们困扰,而是为了驾驭它们。

应用与交叉学科联系

如果你曾窥探过现代计算机的心脏,你可能会把它想象成一个充满完美逻辑和秩序的地方,一块硅制瑞士手表。然而,事实,正如物理学中常有的情况一样,要混乱和有趣得多。为了达到惊人的速度,现代处理器是天生的“善意”骗子。它们不是恶意的,而是为了方便而撒谎。它们承诺对单个孤立的指令序列给出正确答案,但为了做到这一点,它们会在幕后进行重排序、延迟、缓冲和推测性地执行操作。在一个单线程的孤独世界里,这种秩序的幻觉被完美地维持着。但是当多个线程——或者一个线程和一个硬件设备——试图通信时,这种隐藏的混乱就会暴露出来。

本节就是进入那个狂野前沿的旅程。我们将看到,弱内存模型的原则不仅仅是学术上的好奇心,而是任何构建正确且高性能并发系统的人的必备工具包。这是一个关于我们,作为工程师和科学家,如何在硬件优化的流沙之上搭建一座正确性之桥的故事。

基本契约:发布与订阅

大多数并发程序的核心在于一个简单的模式:一个线程,即生产者,准备一些数据,另一个线程,即消费者,使用它。想象一下,你正在写一封信,把它塞进信封(数据),然后竖起邮箱上的旗子向邮递员发出信号(完成标志)。还有比这更简单的事吗?然而,在一台弱排序机器上,这可能会出大问题。从远处观察的邮递员可能会看到旗子竖起来,而信件实际上还未在邮箱中可见。他们冲过来,打开邮箱,却发现里面是空的,或者只有一张写了一半的纸条。

这正是当一个线程复制一个数据块然后设置一个标志来表示完成时可能发生的危险。看到标志的核心可能尚未看到程序顺序中在其之前的所有数据写入。同样的危险潜伏在操作系统的日志子系统中,过早写入提交标志可能导致恢复线程处理不完整的日志条目,从而损坏文件系统。

解决这个困境的方案是一项优美的概念工程:release-acquire 契约。生产者对标志执行一个 ​​store-release​​ 操作。这个操作带有一个强大的保证:“确保我之前的所有内存写入在此次存储本身变得可见之前,对所有人可见。”它就像一个屏障,阻止任何先前的工作泄露到信号之后。相应地,消费者使用一个 ​​load-acquire​​ 来检查标志。这带有一个互补的保证:“在我处理完这次加载并获得生产者保证其之前的所有历史的可见性之前,不要开始我后续的任何工作。”

这种 release 和 acquire 的配对在线程之间锻造了一个 happens-before 关系,一条驯服混乱的因果链。它确保如果消费者看到了信号,它就保证能看到所有在其之前发生的工作。这不仅仅适用于简单的标志。任何时候操作系统更新其缓存中的复杂数据结构,然后翻转一个 valid 位使其“生效”时,它都在使用这个原则来保护其他线程免于看到“撕裂读”——一种新旧数据可怕的混合体。甚至更高级、低开销的原语,如 seqlock(在像 Linux 这样的内核中用于单写者场景),也建立在同样的基础上,使用序列计数器和谨慎的内存屏障,允许读者快速前进,但如果它们恰好在修改期间读取,则能可靠地检测到并重试。

无锁世界的艺术

几十年来,管理并发的标准工具一直是锁。要访问共享资源,你获取一个锁,在释放它之前,没有其他人可以触及它。锁很有效,但它们可能很慢。它们可能导致线程等待,还可能导致死锁等问题。如果我们能构建无需锁就能正确工作的系统呢?这就是无锁世界的承诺,而它完全建立在内存排序原则之上。

让我们从“简单”的自旋锁开始。你可能认为只需要一个原子的 test_and_set 指令就够了。那你就错了。在这里,我们遇到了一个新的捣蛋鬼:优化编译器。由于没有明确的规则禁止,编译器可能认为将某个共享数据的读取从临界区内部移动到锁被获取之前更有效率。或者它可能会将数据写入移动到锁被释放之后!从单线程的角度看,什么都没变,但在并发世界里,所有互斥的保证都被破坏了。这揭示了一个深刻的真理:内存模型不仅仅是一个硬件规范;它是硬件、编译器和程序员之间的三方契约。要构建一个正确的锁,你不仅需要硬件的原子指令,还需要编译器屏障来防止代码重排序,以及硬件屏障(实现 acquire 和 release 语义)来强制跨核心的顺序。

现在,让我们来构建一些真正的无锁结构。想象一个可以被多个生产者和多个消费者同时使用的高速消息队列(MPMC 队列)。为了让它飞速运行,我们使用了一系列原子操作的交响乐,每个操作都根据其所需的确切排序强度进行了调整。分发“票据”给线程的计数器可以使用廉价的 relaxed 原子操作,因为它们唯一的任务是保证唯一性,而不是对其他内存操作进行排序。但是,将一个数据槽位从生产者移交给消费者,或从消费者交還给生产者,是一次神圣的所有权转移。这需要完整的 release-acquire 之舞。正是这种对排序的谨慎、简约的应用,使得这样的数据结构能够实现惊人的吞吐量。

这种艺术性延伸到了像有序链表这样的动态结构。当其他线程可能正在读取一个节点或试图在其旁边插入一个新节点时,你如何删除它?巧妙的解决方案是将逻辑删除与物理删除分开。首先,你使用带有 release 语义的比较并交换(Compare-And-Swap, CAS)操作,原子地将节点标记为逻辑上已死。这是不可逆转的一步;该节点现在已从链表的逻辑视图中消失。只有在那之后,作为单独的清理步骤,你或其他任何提供帮助的线程才能物理地断开指针。这个两阶段过程,由 CAS 驱动并由 release-acquire 语义 governs,允许这些复杂的、精心编排的修改并发进行,而无需用锁来暂停整个世界。

来世的幽灵:安全的内存回收

我们已经构建了这些漂亮、高性能的无锁数据结构。但是当我们最终用完一个节点时,我们能直接告诉系统 free() 它的内存吗?如果我们这样做,另一个核心上的另一个线程可能仍然持有一个指向它的指针,正处于操作中途。突然间,那个指针指向的不再是有效数据,而是无意义的垃圾,或者更糟的是,指向一个在其位置上被重新分配的完全不同的对象。这是一个释放后使用(use-after-free)的 bug,是所有系统编程中最阴险、最危险的 bug 之一。

在这里,我们发现了 release-acquire 契约的局限性。它保证你看到对象的正确状态,但它对对象的生命周期只字不提。一个竞态条件仍然存在:一个读者可能在看到一个对象“正在使用”时,一个写者恰好决定要退役并释放它。即使有完美的内存排序,这种竞争也可能导致灾难。

这就是高级内存回收方案旨在解决的问题。像读-复制-更新(Read-Copy-Update, RCU)和基于纪元的回收(Epoch-Based Reclamation, EBR)等技术提供了一种管理数据“来世”的方法。在 RCU 中,写者必须等待一个“宽限期”——一段足以保证所有先前存在的读者已完成其临界区的时间——然后才能安全地释放旧数据。在 EBR 中,读者注册它们所在的“纪元”,写者只能在验证所有线程都已进入一个更新的纪元后,才能释放旧纪元的数据。

这些策略之间的选择揭示了有趣的工程权衡。RCU 的读者可以快得惊人,通常不需要对共享内存进行任何写操作,这对缓存性能来说是一个巨大的胜利。相比之下,EBR 的读者必须执行一次写操作来宣布它们的纪元,这会导致更多的核心间缓存一致性流量。然而,RCU 的宽限期可能会被一个缓慢的读者无限期延迟,而 EBR 则提供了另一套性能特征。这些都是内核开发者为了调整我们操作系统的性能而努力解决的深层设计决策。

超越处理器:一个排序的宇宙

内存排序的原则并不仅限于 CPU 核心之间错综复杂的舞蹈。它们是普适的,出现在任何需要通过共享内存进行协调的异步代理之间。

考虑一个硬件设备,比如使用直接内存访问(DMA)直接向内存写入数据的网卡或存储控制器。设备是生产者,CPU 是消费者。设备写入一个数据包,然后更新内存中的一个完成标志。但是 CPU,以其弱排序的大脑,可能会在确认标志已设置之前推测性地读取数据包,导致它处理陈旧或不完整的信息。解决方案是我们之前见过的那个:CPU 在看到标志后必须使用 load-acquire 或等效的内存屏障。这强制建立了一条因果链:在“完成”信号及其所有历史被正确接收之前,不要触碰数据。

这个原则甚至延伸到了你的程序与操作系统之间的边界。当你进行系统调用时,你可能会假设这种向更高权限执行模式的转换就像一个强大的内存屏障,神奇地将一切都整理好。对于许多现代架构来说,这是一个危险的假设。系统调用指令提供的保证可能出奇地少。为了科学地证明这一点,不能只检查内核是否正确读取了你传递给它的缓冲区;那个简单的案例通常因为单核排序效应而能正常工作。相反,必须设计一个使用多个内存位置的“石蕊测试”,来看是否会发生弱内存异常——即内核看到了一个标志但却看到了来自另一位置的陈舊数据。这教会了我们一个至关重要的教训:不要假设边界处会发生魔法。正确性必须被明确表达出来。

最后,这些思想将我们与并发系统设计的历史和未来联系起来。像 Dekker 算法这样在纸上被证明是正确的经典算法,在弱排序共享内存上可能会因为我们之前看到的同样的存储缓冲异常而失败。一个线程写入其意图标志,然后读取其邻居的标志,但硬件可能在写入对邻居可见之前处理读取,导致两者都进入临界区。修正方法当然是一个显式的内存屏障。这与一种完全不同的并发范式——消息传递——形成了鲜明的对比。在一个基于消息传递的系统中,send() 和 receive() 的行为中內建了隐式同步。send 的完成保证 happen-before (先行发生于) 相应 receive 的完成。排序是通信本身的内在属性,优雅地回避了共享内存程序员必须如此小心翼翼地明确处理的危险。

从顺序一致性的简单理论世界到现代硬件弱排序现实的旅程,是一个用显式性能换取隐式简单的故事。我们程序之下的世界是一个由重排序的、推测性的操作组成的翻腾的海洋。但是通过理解这种混乱并运用内存排序的工具,我们可以搭建起正确性之桥。我们可以构建不仅速度惊人,而且可证明是健壮的系统,将处理器的“善意谎言”转变为计算未来的坚实基础。

// Initially: data = 0, flag = 0 // Producer Thread data = 42; flag = 1; // Consumer Thread while (flag == 0) { /* spin and wait */ } print(data);
// Initially: x = 0, y = 0 // Thread 1 r1 = y; x = r1; // Thread 2 r2 = x; y = r2;