
rename 系统调用等原语或预写式日志和两阶段提交等协议来构建原子事务。在计算世界中,瞬时、不可分割操作的错觉是可靠性的基础。这种“全有或全无”的保证,即所谓的原子性,是在无数操作同时发生的系统中防止数据混乱的基石。没有它,像保存文件或转移资金这样的简单任务都可能导致数据损坏和状态不一致——这个问题被称为“撕裂读”或“撕裂写”。本文将揭示使原子性成为可能的层层工程技术。旅程始于“原理与机制”部分,探讨硬件如何通过原子指令和缓存一致性来强制实现不可分割性,以及原子性与内存排序之间的关键区别。然后,我们将在“应用与跨学科联系”中拓宽视野,审视这些基本原理如何应用于构建健壮的操作系统、数据库,乃至遍布全球的分布式系统,将“全有或全无”的简单承诺转变为我们数字世界的基础。
在我们穿越数字世界的旅程中,我们常常对其中一个最深奥的错觉习以为常:“瞬间”的错觉。当你保存文件、更新数据库记录或发送消息时,你感觉它是一个单一的、不可分割的事件。它要么发生,要么不发生。你永远不会看到一个半保存的文件,或者一笔银行转账中钱已从一个账户转出但尚未到达另一个账户。这种“全有或全无”的保证被称为原子性,它是可靠计算的基石。“原子”(atom)一词源于希腊语 átomos,意为“不可切割的”或“不可分割的”。在计算中,原子操作是指不能被任何其他进程中途切断的操作,对所有观察者来说,它就像是在一个单一、瞬时的闪光中发生的。
但我们的计算机并非建立在瞬时之上。它们是并发活动熙攘的都市,拥有多个处理器核心、网卡和其他设备,所有这些都在读写共享内存。在这样混乱的环境中,我们如何铸就不可分割的错觉?让我们层层剥茧,探索从芯片到软件,使原子性成为可能的美妙机制。
想象一下,你正在尝试更新一条由两部分组成的关键信息,比如一个坐标 。你首先写入新的 ,然后写入新的 。但是,如果在这两个动作之间的短暂瞬间,有其他人读取了这个坐标怎么办?他们可能会看到新的 值和旧的 值,导致一个从未真实存在过的“撕裂”坐标。这是原子性必须解决的根本危机。
这不仅仅是一个理论上的担忧;它在真实系统中确实会发生。考虑一个试图显示视频帧的图形子系统。该系统可能维护一个共享状态,包含一个指向当前帧数据的指针 和一个用于跟踪帧的代际计数器 。一个写入者线程准备一个新帧 并更新共享状态:它首先将新指针写入 ,然后递增 。与此同时,多个读取者线程在不断地采样这个状态以显示视频。一个读取者可能恰好在写入者更新了指针但尚未更新计数器之后执行。该读取者观察到 ——这是一个灾难性的撕裂状态,它将新帧数据与旧帧号结合在了一起。
这个问题可能更加微妙。假设一个线程正在将一个 16 位数从 更新到 。在某些机器上,这可能会以两次独立的 8 位(字节)写入的方式发生:首先,将 写入低位字节,然后将 写入高位字节。初始状态是 。第一次字节写入后,中间状态是 ,代表值 。最终状态也是 。一个并发读取这个 16 位数的线程可能会读到初始值 或中间/最终值 。在这种特定情况下,没有出现奇怪的值。但这是一个危险的巧合!如果更新是从 (字节为 )到 (字节为 ),那么一次撕裂读可能观察到中间状态 ,即无意义的值 。没有错误并不保证正确性。真正的原子性是必需的。
唯一稳健的解决方案是将多部分数据视为一个单一单元。我们可以将指针和计数器打包成一个更大的单一原子对象(例如,一个 128 位字),而不是使用两个独立的原子变量,并用一个不可分割的操作来更新它。这就把我们带到了这种力量的源头:硬件本身。
处理器实际上是如何执行一个“不可切割”的操作的?它提供了一套特殊的原子指令,这是它对不可分割性的庄严承诺。这些不是普通指令;它们是像 test-and-set、compare-and-swap 或 fetch-and-add 这样的基本原语,构成了所有其他同步机制的基石。
然而,这个承诺通常附带条件——这是程序员与芯片之间的契约。其中最重要的一个就是对齐。大多数处理器被设计为按特定大小的块(例如,4 或 8 字节)访问内存。自然对齐意味着任何大小为 的数据都必须位于一个 的倍数的内存地址上。一个 8 字节整数应该位于一个能被 8 整除的地址;一个 4 字节整数应该位于一个能被 4 整除的地址,依此类推。
如果你试图在一个不是 8 的倍数的地址上对一个 8 字节值执行原子操作,你就违背了契约。硬件可能会通过引发错误来回应,或者它可能会尝试通过将你的单个操作拆分成两个更小的、非原子的内存访问来“修复”它。这种原子性的静默失败重新引入了我们试图消除的撕裂读。可移植的、正确的并发代码总是尊重数据对齐。
在满足对齐契约的情况下,处理器如何在其多个核心之间强制实现不可分割性?其中的奥秘在于缓存一致性协议。现代 CPU 不直接与主内存打交道;它们将数据的本地副本保存在小而快的缓存中。为了保持这些缓存的一致性,它们遵循像 MESI(修改、独占、共享、无效)这样的协议。核心规则很简单:要写入一块数据,一个核心必须拥有对它的独占所有权。
这个日常的数据共享规则被巧妙地重新用于提供原子性。当一个核心需要对一块数据执行原子读-改-写(RMW)操作时,它使用一致性协议来获取该数据缓存行(cache line)的独占所有权(将其置于 'M' 或 'E' 状态)。一旦拥有独占所有权,其他任何核心都不能读写该数据。然后,该核心可以在其缓存内本地执行读取、修改和写入,完全与外界隔离。这种优雅的机制通常被称为缓存锁定。它在不暂停整个系统的情况下确保了原子性。
但是,如果数据无法被缓存,比如一个内存映射的硬件寄存器呢?或者如果它是一个未对齐的值,愚蠢地跨越了两个不同的缓存行(一个“跨行锁”)呢?在这些情况下,优雅的缓存锁定机制会失败。处理器必须退回到一种更粗暴、更原始的方法:断言一个总线锁。它实际上是在大喊“大家停下!”,冻结系统级互连上的所有其他内存事务,直到它的 RMW 操作完成。这为所有情况保证了原子性,但代价是显著的性能开销。它是缓存锁定的手术刀所对应的锤子。
原子性契约还有另一条细则,它既微妙又深刻:一个操作的原子性是相对于某一组特定的观察者而言的。一个常见的误解是,CPU 核心上的一个原子指令会自动保护数据免受系统中所有可能的访问。这只对我们可能称之为强原子性的情况成立。
一个强原子指令,通常通过总线锁实现,对于所有访问内存的代理来说确实是不可分割的:其他 CPU 核心、中断处理程序,甚至像网卡或使用直接内存访问(DMA)的存储控制器等外部设备。
然而,许多原子指令只提供弱原子性。它们仅相对于参与缓存一致性协议的其他 CPU 核心是不可分割的。它们不一定能阻止非一致性 DMA,甚至不一定能阻止同一核心上的异步中断。这可能导致令人费解的竞态条件。
想象一个锁变量 和一个状态字 恰好位于同一个缓存行中。一个 CPU 线程使用弱原子的 test-and-set 操作 来获取锁,意图安全地读取状态 。与此同时,一个网卡使用 DMA 直接向主内存中的 写入一个新状态。CPU 的原子操作继续进行:它获得了缓存行的独占所有权并更新了其本地 的副本。然而,它并不知道主内存中正在发生的 DMA 写入。稍后,当 CPU 的缓存行被写回内存时,它旧的、过时的 值覆盖了来自网卡的最新更新。来自 DMA 设备的数据被静默地丢失了。锁是相对于其他 CPU“原子地”获取了,但它本应保护的关键数据却被那个影响范围之外的代理给破坏了。
我们现在来到了现代并发编程中最关键的区别之一:原子性与排序不同。原子性确保一个操作是不可分割的。而内存排序则确保操作的结果以可预测的顺序对其他核心可见。
考虑两个线程。线程 将一个值写入载荷变量 ,然后原子地递增一个标志变量 。线程 读取标志 ,然后读取载荷 。
如果 atomic_increment 仅仅是原子的,但没有提供排序保证(一个“松散”原子操作),那么一种奇怪的结果是可能的:线程 可能观察到新值 ,但读到旧值 !。这是怎么发生的?对 的递增确实是原子的——没有人看到一个半递增的值。但是处理器和编译器被允许重排操作。从 的角度看,对 的写入在对 的写入之前变得可见。对 操作的不可分割性并没有约束它相对于对 操作的排序。
为了解决这个问题,我们需要明确地将两者联系起来。这通过获取-释放语义来完成。
通过在同一个原子变量上配对使用释放和获取,我们创建了一个“先于发生”(happens-before)关系。现在,对 的写入保证在对 的读取之前发生。原子性提供了不可分割的事件;排序提供了使其可用于通信的因果关系。
有了这些硬件原语,我们就可以在软件中构建更大的原子构造。在许多体系结构中可以找到一个优雅的原语是Load-Linked/Store-Conditional (LL/SC) 对。这是一种实现原子性的乐观方法。一个线程首先执行 Load-Linked 来读取一个值并“链接”到该内存位置。然后它计算一个新值。最后,它尝试执行 Store-Conditional。只有在自最初的 Load-Linked 以来没有其他代理写入该位置的情况下,存储才会成功。如果失败,线程就知道发生了冲突,可以重试整个序列。
这是一个强大的工具。操作系统可能会用它来原子地更新内存中的页表项(PTE)。然而,即使是成功的 LL/SC 更新也揭示了原子性作为工具的局限性。内存中的 PTE 更新是原子的,但这并不会自动使每个核心的转换后备缓冲区(TLB)中缓存的该翻译的过时副本失效。这需要一个独立的、显式的软件协议,涉及处理器间中断来“击落”这些过时的条目。原子性解决了一个问题——内存竞争——但没有解决系统范围一致性的整个逻辑问题。
原子性的概念可以扩展到更高的抽象层次。想一想保存一个配置文件。如果你直接覆盖文件,而系统在中途崩溃,你就会得到一个损坏的文件——一个大规模的撕裂写。解决方案是一个优美的软件协议,它反映了硬件原子操作的逻辑。你不是就地覆盖,而是:
[fsync](/sciencepedia/feynman/keyword/fsync) 这样的系统调用)。rename 操作来即时地用新文件替换旧文件。[fsync](/sciencepedia/feynman/keyword/fsync),使重命名操作本身持久化。在任何崩溃之后,你得到的要么是完整的旧文件,要么是完整的新文件,但绝不会是损坏的混合体。你已经从一个单一的文件系统级原子原语(rename)构建了一个应用级的原子操作,就像硬件从缓存一致性协议构建原子 RMW 操作一样。
这段从撕裂字节的危机到原子保存文件的安全的旅程,揭示了一种深刻的统一性。原子性是一个分层结构。在每一层,我们都能找到巧妙的机制——缓存锁定、LL/SC、软件协议——它们利用更低层次的不可分割性保证来创建更高层次的保证。“不可切割”操作的追求是贯穿现代计算每一层的一条连续线索,从硅晶片到我们日常使用的应用程序。
有一个深刻而简单的思想,支撑着我们构建过的几乎所有可靠的数字系统。这就是原子性原则,“全有或全无”的保证。就像原子(atom)曾被认为是物质的不可分割单元一样,原子操作是一个不可分割的工作单元。它要么完全完成,使系统进入一个新的、有效的状态;要么完全失败,使系统好像该操作从未发生过。没有中间地带,没有混乱的、半完成的状态。这个简单的承诺是在复杂、并发的计算机世界中对抗混乱的堡垒。
我们很容易想当然地认为这一点。当你把一个文件拖到一个新文件夹时,你不用担心电脑会中途崩溃,导致文件处于一种量子叠加态,既不在旧位置也不在新位置。当你在网上转账时,你相信资金不会从你的账户消失而没有出现在收款人的账户里。这种信任并非偶然,而是精心设计的结果。理解原子性的旅程,就是深入计算机科学核心的旅程,从处理器的逻辑门到构成云的全球服务器网络。
我们的旅程从软件与硬件交汇的最底层开始。想象一下计算机的中央处理器(CPU)和一个外围设备(比如网卡)之间的对话。网卡有一个“状态寄存器”,这是 CPU 可以读取的一小块内存。也许这个寄存器中的某一位在有新网络包到达时会翻转为 。CPU 可能会读取这个寄存器,看到一个 ,然后决定无事可做。但在 CPU 读取与其下一个动作之间的纳秒级时间内,一个数据包到达,硬件将该位翻转为 。CPU 根据过时的信息采取行动,错过了一个事件。
更糟糕的是“读-改-写”风险。CPU 读取一个包含多个标志的寄存器,在它的本地副本中更改一个位,然后将整个内容写回。但如果硬件在 CPU “思考”的时候改变了寄存器中的另一个标志呢?CPU 的写入会毫不知情地覆盖硬件的更新,从而破坏新信息。这是一个经典的竞态条件。
为了防止这种情况,工程师们发明了巧妙的硬件机制,使某些更新具有原子性。外围设备可能提供“写一清零”(W1C)语义,而不是凌乱的读-改-写。为了清除一个状态标志,软件执行一个单一的、不可分割的写操作,在相应位的位置上写入一个 。硬件保证这个单一操作只清除那个特定的标志,而不管并发发生了什么,所有其他标志都保持不变。通过提供一个单一的原子操作,硬件和软件可以安全地协调,而不会互相干扰。
将一个强大的原子指令作为并发构建块的思想在现代计算中无处不在。思考一下并行图搜索的挑战,比如绘制一个社交网络。现代处理器使用 SIMD(单指令多数据)让许多处理“通道”并行工作。在探索网络时,多个通道可能同时发现同一个新的、未访问过的人。一场竞赛随之而来:谁能“认领”这个人并将其添加到待探索列表中?如果我们不小心,这个人可能会被多次添加到列表中,从而浪费大量工作。
解决方案是一个优美而基础的原子原语:“test-and-set”指令。多个通道可以尝试为一个共享内存位置中的人“test-and-set”一个“已访问”标志。硬件保证这些尝试是可线性化的——它们看起来像是以某个单一的、顺序的次序发生的。只有一个通道,即在这个概念序列中恰好是“第一个”的通道,会读到旧值 并成功地将其设置为 。所有其他通道都会读到 。这个原子操作的返回值是这场竞赛的最终结果。然后算法可以简单地使用这个结果:只有“胜利者”(读到 的那个)继续将此人添加到工作队列中。所有失败者都退下。通过这种方式,一个在多个通道上并行执行的单一原子指令,确保了每个人都恰好被访问和入队一次,从而实现了大规模的、正确的并行处理。
操作系统(OS)是抽象的大师,它在混乱的硬件现实之上构建了一个理智而有序的世界。原子性是其最重要的工具之一。
想一想在你的电脑上移动一个文件。当你把一个文件从一个文件夹拖到同一磁盘的另一个文件夹时,无论文件大小如何,操作都感觉是瞬时的。这是因为操作系统执行了一个原子元数据操作。文件系统就像一个巨大图书馆的卡片目录。文件的数据块是书架上的书,而目录是一张告诉你去哪里找它们的索引卡。“移动”文件只是把对这本书的引用从一张索引卡上取下,写在另一张上。书本身并没有移动。对目录的这种更新被设计成一个单一的、原子的步骤。
但是,当你把那个文件拖到一个外部 USB 驱动器时会发生什么?现在,“书”必须真正地从一个图书馆复制到另一个。操作系统不能再执行单一的原子元数据更新。这两个文件系统是独立的。在一个文件系统上是原子的 rename() 系统调用,此时会失败并返回一个特殊错误 EXDEV(跨设备链接)。你电脑的文件管理器会回退到一个非原子序列:它首先逐块复制文件,然后才删除原始文件。如果中途断电,你可能会在 USB 驱动器上留下一个部分副本,并且原始文件仍然完好无损。原子“移动”的错觉被打破了,揭示了底层的机制。
这就是为什么文件系统对原子性如此执着。没有它,你的数据就处在持续的危险之中。再考虑一下 rename 操作,但这次是移动整个目录。如果操作是一个天真的序列,即“从旧父目录中移除链接”然后“向新父目录添加链接”,那么在这之间发生崩溃将导致该目录及其所有内容完全与文件系统树断开连接——一个“孤立的子图”,迷失在虚空中 [@problem_d:3619390]。另一个经典的失败是“撕裂指针”,即文件系统更新一个索引块以指向一个新分配的数据块,但在数据实际写入该新块之前发生崩溃。重启后,文件指向一块垃圾数据。
为了解决这些问题,现代文件系统使用一种称为日志记录(journaling),或称预写式日志(Write-Ahead Logging, WAL)的技术。在对主文件系统结构进行任何危险的更改之前,操作系统首先在一个特殊的日志(journal)中写下一个便条,描述它将要做什么(例如,“我打算将目录 X 从 A 移动到 B”)。它确保这个便条被安全地保存到磁盘。只有在那之后,它才执行实际的操作。如果发生崩溃,操作系统在重启时只需读取它的日志,就可以干净地完成或撤销该操作,确保它是全有或全无的。日志将一个复杂的多步序列变成了一个单一的原子事务。
从单一机器的坚实基础出发,我们现在进入大规模数据库和分布式系统的世界,在这些世界里,原子性必须跨越庞大的数据集和不可靠的网络来得以保持。
数据库的生存依赖于 ACID 属性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。这里的原子性是不可协商的。典型的例子是银行转账:一个账户的借记和另一个账户的贷记。这两个动作必须捆绑成一个单一的原子事务。系统不能允许借记成功而贷记失败。为了在极其复杂的数据结构上为数十亿笔交易提供这种保证,数据库采用了远超简单日志的复杂日志记录机制。它们使用诸如物理逻辑日志(physiological logging)和补偿日志记录(Compensation Log Records, CLRs)等技术,以确保即使是复杂的、多页的对其内部 B+ 树索引的结构性修改,也能够原子地执行或回滚,并在任何崩溃中幸存下来。
如果系统没有提供你所需要的确切原子操作该怎么办?有时,你可以自己构建它。想象一下,你需要删除一个复杂数据结构(如链表)的一部分,但同时有其他进程可能正在读取它。你不能直接开始拆除节点——一个读者可能会跟随一个指针进入虚无。解决方案既优雅又强大:你根本不去碰活动的数据结构。相反,你通过复制需要保留的部分,并将它们链接起来绕过你想删除的部分,从而创建该结构的新版本。一旦这个完美的新版本在旁边完全构建好,你就使用一个单一的、硬件保证的原子指令,将一个指针从旧的结构头交换到新的结构头。在那一瞬间,更改生效。已经走在旧路径上的读者继续他们的路程,不受影响。新的读者从新的头开始。没有人会看到一个损坏的状态。这就是持久化数据结构的核心思想,一种实现无锁并发的美妙方式。
最后的疆域是在网络上实现原子性。例如,在软件安装过程中,你如何原子地更新一组 个文件?崩溃不能让你留下一个半安装的程序。一种常见的模式是使用间接方式。应用程序在一个临时的“暂存”目录中准备好所有新版本的文件。当每个文件都完美就位后,它执行一个单一的原子 rename 操作,将暂存目录的名称与活动目录的名称交换。整个复杂的更新在一个不可分割的瞬间完成提交。
当涉及到独立的计算机时,这个挑战变得更大。在分布式文件系统中,如果一个向服务器写入文件的客户端崩溃了,服务器不能留下一个半写的文件。这通过结合几种巧妙的思想来解决。服务器授予客户端一个有时间限制的锁,或称租约(lease)。它将传入的写入视为一个事务,并将其暂存在一边。客户端必须在每次写入时包含一个唯一的防护令牌(fencing token),以证明它是当前的租约持有者。只有当客户端发送一个明确的“提交”消息时,服务器才会最终确定该事务——使更改永久化。如果租约在该消息到达之前到期,服务器就假定客户端已经崩溃,中止事务,并丢弃暂存的数据。任何来自“僵尸”客户端的迟到消息都会因为它们的防护令牌已经过时而被拒绝。
最终的分布式挑战是原子提交问题:确保一个跨越多个独立系统(例如,位于不同大洲的数据库)的操作要么在所有地方提交,要么在所有地方中止。这通过一个模仿正式谈判的协议来解决,其中最著名的是两阶段提交(2PC)。
从硬件寄存器中的一个比特位到遍布全球的事务处理,原子性原则是一条贯穿始终的统一线索。正是这个简单、强大且至关重要的“全有或全无”的承诺,让我们能够用简单的、往往不可靠的部件,构建出可靠、复杂而优美的系统。
// 最初:x = 0, y = 0
线程 T_0: 线程 T_1:
y = 1; r1 = atomic_load(x);
atomic_increment(x); r2 = load(y);