
在现代计算中,效率至关重要。操作系统不断地调配资源以提供无缝的体验,但像创建新进程这样的基本任务可能会出奇地昂贵。复制一个进程的整个内存空间的传统方法速度缓慢且常常是浪费的,造成了严重的性能瓶颈。这正是写时复制(Copy-on-Write, CoW)这一巧妙的优化策略旨在解决的挑战。它是一种“惰性效率”原则,深刻地影响了我们日常使用的无数系统的架构。
本文探讨了写时复制的优雅世界。在第一章“原理与机制”中,我们将剖析 CoW 的底层工作原理,探索操作系统与硬件如何协同合作,在推迟高昂工作的同时,创造出私有内存的假象。我们将深入研究页错误、引用计数以及其中涉及的微妙权衡。随后,在“应用与跨学科联系”中,我们将拓宽视野,看看这个单一思想如何远远超出了进程创建的范畴,影响了虚拟化、数据库管理,乃至编程语言的设计。读完本文,您不仅将理解什么是写时复制,还将明白为什么它代表了计算机科学中一种优美效率的基本模式。
要真正领会写时复制(CoW)的精妙之处,我们必须首先想象一个没有它的世界。在现代操作系统的宇宙中,其最神圣的职责之一就是为每个运行的程序或进程提供一个深刻的幻觉:它独占了计算机的全部内存。这个私有的“圣殿”对于稳定性和安全性至关重要。如果一个程序崩溃,它不会拖垮其他程序。但是,当我们想要创建一个与另一个进程完全相同的副本时,会发生什么呢?这正是著名的 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 系统调用所做的事情。
响应 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 请求最直接、最暴力的方式就是一丝不苟地照搬字面意思。操作系统可以暂停父进程,然后勤勉地将其内存中的每一个字节都复制到一组新的物理内存帧中,供子进程使用。这种“即时复制”(eager copying)的方法虽然易于理解,但效率极其低下。
想象一个内存占用为 MiB 的服务器应用程序。如果该应用程序通过为每个传入请求派生一个新的工作进程来处理请求,并且每秒接收 个请求,那么计算结果将是惊人的。为每次派生都即时复制内存意味着系统必须在内存总线上传输 ,即 的数据。这是一个巨大的负载,会占满内存带宽,使 CPU 陷入饥饿状态,并可能让整个系统瘫痪。更糟糕的是,这种努力常常是完全白费的。一种常见的模式是,新派生的子进程立即调用 execve(),清除其刚刚复制的内存,以加载一个全新的程序。我们为了一场空,做了大量的无用功。
在这里,我们看到了计算机科学中一个优美的原则浮现出来,它反映了人性的某个方面:有目的的拖延。为什么今天能做的事要推迟到明天,尤其是当明天可能永远不会到来的时候?这就是写时复制的灵魂。操作系统不会预先复制所有东西,而是采取最懒惰的方式:共享。
当 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 被调用时,操作系统决定不复制数百万字节的实际数据。相反,它只复制父进程的页表。页表就像进程的地址簿;它将程序认为自己正在使用的虚拟地址映射到计算机 RAM 中的实际物理帧。通过给子进程一份这个地址簿的副本,父进程和子进程现在都拥有了指向完全相同的物理帧的虚拟页。从宏观上看,什么都没有被复制,但两个进程现在共享着同一套内存。这速度惊人,效率极高。
但这种优雅的懒惰立即带来了一个危险的局面。如果子进程写入一个内存地址,它将改变父进程也在使用的物理帧中的数据。私有内存空间的神圣幻觉将被打破。这就是该机制中“on-Write”(写时)部分以及操作系统与硬件之间巧妙合谋发挥作用的地方。
为了防止一个进程在无形中破坏另一个进程的内存,操作系统设置了一个陷阱。在 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 之后,它会遍历父进程和子进程的页表,并将所有共享页标记为只读。这个权限位是一个标志,硬件的内存管理单元(MMU)在每次内存访问时都会检查它。它就像一根数字绊索。
同时,操作系统需要跟踪有多少个进程指向一个给定的物理帧。它通过与每个帧关联的引用计数来实现这一点。最初,父进程的页面的引用计数为 1。在 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 之后,所有共享页的引用计数现在都变为 2(如果创建了多个子进程,则会更多)。
现在,舞台已经搭好。当子进程试图向其内存中写入一个字节时,会发生什么呢?
陷阱被触发:子进程的 CPU 执行一个存储指令。MMU 在子进程的页表中查找该地址,并看到了只读标志。对只读页的写入是一种违规!硬件作为一个强大但无智能的仆人,会立即停止该进程,保存其状态,并触发一个称为页错误的异常。它实际上是在向其上级——操作系统内核——呼救。
内核的诊断:内核的页错误处理程序被唤醒。它看到了一个写操作引发的保护错误。一个天真的内核可能会因为进程行为不当而简单地终止它。但我们的内核更聪明。它会查阅自己的、更高级别的记录——进程的虚拟内存区域(VMA)列表。这些记录表明,该内存区域实际上应该是可写的。内核识别出这种不匹配:基本权限(VMA)允许写入,但临时的硬件权限(PTE)却不允许。这个特定的信号告诉内核,这不是一个真正的错误,而是一个计划好的 CoW 事件。这是过去的内核(设置陷阱者)与现在的内核(处理陷阱者)之间的一个秘密握手。
复制操作:此时,且仅在此时,内核才执行它一直推迟的复制操作。它分配一个全新的、空的物理帧。它将原始共享页的全部内容(例如, 的数据)复制到这个新帧中。然后,它更新子进程的页表条目,将其指向新的私有帧,并且至关重要地,将其权限设置为可写。最后,它递减原始共享帧的引用计数,因为子进程不再使用它。
恢复执行:内核将控制权交还给用户进程。CPU 在异常处理完毕后,按其职责重新执行导致错误的那个 store 指令。这一次,当 MMU 检查页表时,它找到了一个可写的页面。写入操作顺利成功。
这整个舞蹈——错误、诊断、分配、复制和恢复——对用户程序来说是完全透明的。进程被暂停了几微秒然后继续执行,对刚刚为了维持其私有小宇宙而发生的复杂编排浑然不觉。
这种“惰性”策略带来的性能提升是巨大的。让我们回到服务器的例子。如果一个派生的工作进程平均只修改其继承内存的 10%(这个比例我们称之为 ),那么 90% 的昂贵复制工作就完全避免了!节省的内存带宽是惊人的 ,即 。从概率的角度来看,如果一个进程有 个页,并且对任何给定页的写入概率为 ,那么昂贵的复制操作的期望次数不是 ,而仅仅是 。
然而,CoW 并非免费的午餐;它是一种权衡。它推迟了内存分配的成本,但并没有消除它。想象一个场景,一个拥有 个可用内存帧的系统,有一个使用 个帧的父进程。此时只剩下 个空闲帧。如果父进程派生一个子进程,而该子进程开始执行写密集型任务,修改了其 80% 的页面,这将触发对 个新帧的需求。系统只有 个空闲帧,突然之间严重超额分配。它必须开始疯狂地将页面换出到磁盘以腾出空间,导致性能崩溃,即所谓的颠簸(thrashing)。CoW 将内存压力从 fork 时转移到了运行时,而在这种情况下,系统无法承受这笔延迟的账单。
还有一些更微妙的成本。CoW 的粒度是页(例如, 字节),而 CPU 内存访问的粒度是缓存行(例如, 字节)。这种不匹配可能导致一种称为伪共享(false sharing)的现象。想象两个从同一父进程派生出来的子进程。一个写入共享页的第一个字节,另一个写入最后一个字节。它们处理的是完全独立的数据。然而,因为它们的写入落在同一个 字节的页面内,两者都会触发一次完整的、昂贵的页面复制。它们相互干扰,不是因为它们在共享数据,而是因为它们的数据恰好驻留在同一个内存管理块上。
我们讨论的原则虽然在概念上很优美,但要在现代多核处理器上正确实现它们,却是一项工程壮举。内核必须使用锁来防止一个 CPU 核心在修改页表时,另一个核心正在读取它。它必须使用原子级的硬件指令来更新引用计数,这样两个核心就不会试图同时增加或减少计数值。
也许最令人费解的问题是保持转译后备缓冲器(TLB)——每个 CPU 核心私有的近期地址翻译缓存——的最新状态。当内核在一个核心上更改一个页表条目时(例如,使一个 CoW 页面变为可写),它必须立即通知所有其他可能在其 TLB 中拥有该翻译的陈旧只读副本的核心。这通常涉及发送一个处理器间中断(IPI),就像给其他 CPU“拍拍肩膀”,告诉它们刷新坏的条目。这种“TLB 刷落”(TLB shootdown)是一个复杂、精细且对性能至关重要的舞蹈,对于保证正确性至关重要。
从一个简单、优雅的想法——非到万不得已不复制——衍生出一个充满复杂机制、微妙权衡和深刻工程挑战的世界。写时复制证明了在我们日常使用的程序表面之下,层层叠叠的巧思正默默而高效地维护着现代计算所依赖的种种幻象。
掌握了写时复制的优雅机制后,我们现在就像配备了新式强力透镜的旅行者。透过它,我们开始看到这个简单思想的印记无处不在,深刻地烙印在现代计算的体系结构中。它是一条原则,在每个抽象层面上都低语着同一句深刻的建议:“为何要今天做明天才能完成的事?更好的是,如果事实证明它并非必要,又何必去做呢?”这种“惰性效率”的哲学不仅仅是一个聪明的技巧;它是一种反复出现的模式,为纷繁复杂的技术带来了速度、安全性和精妙性。让我们踏上一段旅程,去发现这些联系,从操作系统的核心到编程语言设计的前沿。
写时复制(COW)最经典、或许也是最重要的应用,存在于类 Unix 操作系统的 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 系统调用中。在早期,创建一个新进程是一件极其昂贵的事情。操作系统会费力地逐字节复制父进程的整个内存空间,以创建子进程。如果一个父进程占用了一千兆字节的内存,创建一个子进程就意味着在复制一千兆字节数据的过程中长时间的停顿,即使子进程的第一个动作就是丢弃那部分内存并加载一个新程序。
COW 改变了这一局面。操作系统现在不再进行完全复制,而是玩了一个戏法:它给子进程一套新的虚拟地址映射,但让它们指向与父进程完全相同的物理页。为防止混乱,它将这些共享页对两者都标记为只读。[fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 调用变得几乎是瞬时的。真正的工作被推迟了。如果子进程立即调用 execve() 来变成一个新程序,几乎没有任何数据被复制。巨大的浪费被消除了。节省的时间是可观的——对于一个大型应用程序,原本可能需要几十或几百毫秒的任务,变成了一个只需几微秒的任务。这种优化不仅仅是一种改进;它正是使“将许多小型、专门化的进程链接在一起”的 Unix 哲学变得切实可行的关键所在。
但这种能力也带来了其特有的微妙之处。当你 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 一个拥有多个执行线程的进程时会发生什么?想象一下,一个线程,我们称之为 ,调用了 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman),而另一个线程 正处于一个精细操作的中间,比如更新程序的内存分配器,并且为了防止干扰而持有一个锁(互斥锁)。POSIX 标准规定,只有调用线程 会在子进程中被复制。子进程继承了父进程内存的一个完美、冻结的快照,其中包括那个仍处于“锁定”状态的互斥锁。问题在于,持有这把锁钥匙的线程 并没有被复制到子进程中。它根本不存在。如果子进程现在尝试分配内存,它将永远等待一个永远无法被释放的锁。写时复制在这里并不能拯救子进程;它忠实地保留了这种损坏的状态,虽然将其与父进程隔离开来,却让子进程陷入了死锁的困境。这揭示了一个深刻的真理:强大的工具需要小心使用,而像 COW 这样的抽象会与并发等其他系统特性发生复杂的相互作用。
COW 的影响还延伸到程序如何通过内存映射(mmap)与文件交互。当一个程序使用 MAP_PRIVATE 标志映射一个文件时,它请求的是一个私有的、隔离的视图。如果这个进程随后派生,COW 是实现这一保证的自然机制。父进程和子进程开始时共享文件的页面,但任何一方的第一次写入都会触发一次复制,确保它们的更改保持私有,并且不会被写回到原始文件中。相比之下,使用 MAP_SHARED 是一个明确的声明:“我们想要协作。” 在这里,COW 被绕过,父进程、子进程或任何其他共享该映射的进程所做的写入会立即对所有进程可见,作用于内核统一页缓存中的相同物理内存。因此,COW 成为隔离的默认选项,而真正的共享则成为明确的例外。
[fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 与 COW 的行为为一个经典的计算机科学难题——读者-写者问题——提供了一个引人入胜的系统级解决方案。该问题在于如何允许多个“读者”并发访问数据,同时确保一个“写者”拥有独占访问权以进行更新。传统的解决方案涉及复杂的锁定方案。
COW 提供了一种不同的方法。如果我们将父进程视为写者,并派生多个子进程作为读者,操作系统免费为我们提供了一个强大的保证:快照隔离。每个子进程在创建时都会收到父进程在该瞬间内存的一个完美的、不变的快照。父进程可以继续写入和修改其数据,触发 COW 页错误并为自己创建私有副本,但子进程完全不受影响。它们继续读取原始的、未被修改的数据。没有锁,没有等待。读者永远不会阻塞写者,写者的工作也永远不会破坏读者的视图。这里的权衡从时间(等待锁)转移到了空间(写者复制的页面所消耗的内存)。
这不仅仅是一个理论上的奇想;它是一种在高性能数据库系统中使用的实用策略。一个数据库可能需要运行一个冗长、复杂的分析查询,这个查询需要一个一致的数据视图。数据库可以简单地 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman),而不是使用可能减慢传入写事务的复杂软件锁定。包含只读查询的子进程会获得一个数据库缓冲池的瞬时、隔离的快照来进行工作。与此同时,父进程可以自由地继续处理来自用户的新写入,COW 会在数据页被修改时透明地管理它们的分化。
如果写时复制强大到可以复制一个进程,为什么不能复制一整台计算机呢?这正是驱动现代虚拟化的洞见。虚拟机监视器(hypervisor)——运行虚拟机(VM)的软件——可以使用完全相同的原理来创建运行中虚拟机的快照或克隆。
虚拟机监视器可以瞬间创建一个新的虚拟机,其虚拟内存映射指向与原始虚拟机相同的主机物理页,而不是复制虚拟机拥有的数千兆字节内存。利用诸如 Intel 的扩展页表(EPT)或 AMD 的嵌套页表(NPT)等硬件支持,虚拟机监视器可以将这些共享的主机页标记为只读。当新虚拟机尝试写入内存时,CPU 硬件本身会检测到该尝试并陷入到虚拟机监视器中,后者随后执行我们熟悉的 COW 之舞:分配一个新的主机页,复制数据,并更新进行写入的虚拟机的二级页表。整个操作对虚拟机内部的客户机操作系统是完全透明的,它仍然幸福地不知道自己刚刚被克隆了。这使得一些不可思议的壮举成为可能,比如从单个模板几乎即时地启动数千个相同的虚拟机,或者在执行有风险的升级之前对服务器进行实时的、零停机时间的快照。
COW 哲学甚至可以应用于重塑我们对文件系统的概念。想象一种被标记为“不可变”的新文件类型。根据定义,其内容永远不能改变。那么,人们如何才能“编辑”这样的文件呢?COW 方法提供了一个优雅的答案。尝试以写入方式打开文件会触发 VFS(虚拟文件系统)创建一个新的、空的 inode。这个新的 inode 最初将是原始 inode 的一个轻量级逻辑副本,共享其所有数据块而无需物理复制。写入操作将被导向这个新的 inode,在块级别触发 COW。当编辑会话完成时,一个单一的、原子的 rename() 操作会将文件名从指向旧 inode 切换到指向新 inode。任何打开了旧文件的进程将继续看到旧版本,而任何新打开文件的进程将看到新版本。这提供了事务性的、全有或全无的更新,以及一种内置的版本控制形式,所有这些都由内核强制执行。
写时复制的影响力远远超出了传统的 CPU 和操作系统。考虑一下计算机图形学的世界,其中海量数据由图形处理单元(GPU)操纵。一种常见的资产是“纹理图集”(texture atlas),这是一个包含许多较小图像或“图块”(tiles)的单个大图像。多个 3D 模型可能会共享这个图集。如果我们想通过稍微改变其纹理来定制一个模型——比如说,给一个盾牌添加一道划痕——该怎么办呢?
没有 COW,我们将不得不在 GPU 的显存(VRAM)中复制整个数兆字节的图集,只为了改变几个像素。通过类似 COW 的策略,GPU 的内存管理器可以给定制模型一个新的逻辑图集,该图集最初共享原始图集的所有图块。当程序“写入”盾牌图块时,只有那个小图块被复制到一个私有内存块中。总内存占用只增加了改动部分的大小,而不是原始资产的大小。
也许最深刻、最美丽的联系存在于操作系统级、硬件强制的 COW 机制与函数式编程的抽象、数学世界之间。函数式编程的一个核心原则是不可变性:数据结构一旦创建,就永远不会改变。对一个持久化数据结构(如平衡二叉树)的“更新”并不会就地修改节点。相反,它会创建一个新的根节点和一条通往“已更新”位置的新路径节点,同时共享原始树中所有未改变的分支和节点。
这本质上是用户空间的写时复制。程序员在数据结构节点级别手动实现了 COW 哲学。现在,考虑当一个运行着这种数据结构的进程调用 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 时发生的美妙交响乐。子进程继承了这棵树。当子进程“更新”这棵树时,它通过向内存写入来创建几个新节点。由于这块内存在 fork 后是共享的,操作系统会触发一个页级别的 COW 错误。我们有两层 COW 在和谐地工作:应用程序的节点级共享和操作系统的页级共享。这场优美之舞的效率取决于新节点在内存中的布局方式;紧凑的分配将比碎片化的分配触发少得多的页面复制。
从驱动我们命令行的 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman),到存储我们信息的数据库,再到运行我们数字世界的虚拟云,最后到我们用以编写代码的范式本身,写时复制的原则是一条统一的线索。它证明了一个关于推迟工作的简单而优雅的想法,如何能够渗透到系统的每一层,创造效率,实现新功能,并揭示计算领域深刻而相互关联的美。