
fsync 等屏障,这些屏障以暂时中止性能为代价来强制保证顺序和持久性。在对速度不懈的追求中,现代计算依赖一种强大而又危险的策略:有目的的拖延。这个概念被称为延迟写入或写回缓存,是我们数字生活中流畅性能背后默默无闻的英雄。它解决了计算机系统的根本瓶颈——超高速处理器与相对缓慢的存储设备(如硬盘)之间巨大的速度差异。通过选择等待,系统可以更高效地执行工作,但这种选择在性能和数据安全之间引入了一种关键的权衡。
本文将深入探讨延迟写入的艺术与科学。在第一章原则与机制中,我们将剖析其核心权衡,探索延迟操作如何实现批处理和调度等强大的优化,并审视这种方法固有的危险(如数据损坏)以及用于防范这些危险的机制。随后,在应用与跨学科联系一章中,我们将揭示这同一个思想如何在计算的各个层面反复出现,从 CPU 缓存和持久性内存的微观世界,到遍布全球的云服务架构,从而展示其在系统设计中的普遍重要性。
想象你在家,正做着洗碗这件寻常家务。你有两种方法来处理。第一种是井然有序且安全的方法:你洗好一个盘子,擦干,然后立刻放进橱柜。接着你再处理下一个。第二种方法是洗好一堆盘子,把它们放在一个晾干架上——可以看作一个缓冲区。然后你走开,让它们自然风干,稍后再用一次高效的动作把它们全部收起来。
哪种方法更快?几乎肯定是第二种。但它伴随着一个微小而恼人的风险。当那些盘子放在架子上时,它们处于一个脆弱的中间状态。一个不小心的胳膊肘或一只顽皮的猫都可能把整堆盘子撞到地上摔碎。
这个简单的类比抓住了计算领域最普遍、最强大的思想之一的精髓:延迟写入,也被称为写入缓冲或写回缓存。这是一种根本性的权衡,是我们与物理定律达成的一笔交易。我们用一个微小且可控的风险换取性能的显著提升。要真正理解我们的数字世界如何达到其惊人的速度,我们必须首先欣赏这种美丽而时而危险的等待艺术。
计算的核心在于移动数据。以纳秒为单位思考的处理器,需要不断地与以微秒甚至毫秒为单位响应的内存和存储设备进行通信——在 CPU 看来,这简直是永恒。一次同步写入,或称“直接”写入,就像我们第一种洗碗方法。当一个应用程序告诉系统“保存这个数据”时,一个同步操作基本上会回复:“好的。我会在收到你的数据已安全存放在物理磁盘盘片上的确认后,才告诉你我已完成。”
这听起来非常安全,事实也的确如此。其持久性——即数据在断电后依然存在的保证——是绝对的。但代价是巨大的。应用程序必须等待磁盘缓慢的机械之舞:寻道臂寻找正确磁道,盘片旋转到相应位置。一次典型的同步写入可能需要 12 毫秒。在这段时间里,一个现代处理器本可以执行数千万条指令。这就像让世界级的短跑运动员去等一只乌龟。
这就是延迟写入发挥作用的地方。通过使用一个缓冲区——一块像操作系统页面缓存一样的快速内存区域——系统可以采用我们第二种洗碗策略。当应用程序说“保存这个数据”时,系统迅速将其复制到缓冲区,并立即回复:“收到了!你可以去做别的事情了。” 这个操作快如闪电,只是一次简单的内存复制,可能耗时不到十分之一毫秒。应用程序从缓慢磁盘的暴政中解放出来,感知的延迟降低了超过一百倍。
但我们与魔鬼做了交易。在短暂的时间内,那份“已保存”数据的唯一副本存在于易失性内存中。如果在这段“漏洞窗口”期间发生断电,数据将永远丢失。这不仅仅是理论上的担忧。我们甚至可以对其建模。如果我们假设系统崩溃是一个罕见但随机的事件(一个速率为 的泊松过程),并且我们的数据在缓冲区中平均等待 的时间,那么丢失该特定事务的概率大约为 。我们延迟得越久,风险就越大。那么,我们到底为什么要冒这个风险呢?因为性能的提升不仅是巨大的,而且是变革性的。
延迟写入的魔力不仅仅在于解放单个应用程序。它通过改变工作本身的性质,使整个系统变得极为高效。其关键原则是固定成本摊销。
想一想一个网络数据包。每个数据包,无论多小,都需要固定的开销:报头、校验和计算以及处理时间。如果你把你的小说一个字母一个字母地发给朋友,开销将远超实际数据。明智的做法是把字母捆绑——或合并——成章节,然后作为更大的数据包发送。这正是 TCP 的 Nagle 算法所做的事情。它故意扣留少量待发数据,期望很快会有更多数据到达,以便能够发送一个更大、更高效的数据包。
硬盘驱动器的固定成本甚至更高。在写入哪怕一个字节之前,它的读/写磁头必须物理移动到正确的磁道(寻道时间),并等待盘片旋转到正确的扇区(旋转延迟)。这些机械延迟可能耗时数毫秒,并且在每一次写操作中都会产生,无论是写入 1 字节还是 1 兆字节。用一连串小的、随机的写入来轰炸磁盘,是让系统陷入瘫痪的最有效方法之一。
写入缓冲是完美的解药。通过延迟写入,操作系统可以在其内存缓存中累积许多小的、随机的请求。这段等待期赋予了它两种不可思议的超能力:
这个原则是如此基础,以至于它出现在计算机的每一个层级。即使在 CPU 内部深处,一个写缓冲区也会执行写入合并。如果一个程序在同一个 64 字节缓存行内向几个相邻的内存位置写入数据,写缓冲区可以将这些写入合并为内存总线上的单次事务,从而减少流量和功耗。
像 ext4 这样的现代文件系统通过一种真正优雅的技术——延迟分配,将这一点更进一步。当你向一个新文件写入数据时,文件系统不仅延迟写入数据,它甚至延迟决定数据将存放在磁盘的哪个位置。它让脏页在缓存中累积。只有当需要写入磁盘时,它才会审视情况并说:“啊哈,我看到你已经写入了 9 个块的数据。让我在磁盘上为你找一个连续的 9 块大小的空洞吧。” 这将原本可能是九次小的、碎片化的写入,转变为一次大的、快如闪电的顺序写入,从而最大限度地减少文件碎片并最大化性能。这是一个绝佳的例子,说明了等待如何能带来更明智的决策。
这个充满缓冲、重排序和延迟操作的世界效率极高,但也充满了危险。我们创造了一个“在途”世界,其中应用程序感知的系统状态与物理磁盘的状态可能大相径庭。要驾驭这个世界,需要谨慎的规则,并会引入新型的故障。
最隐蔽的危险是顺序违规。想象一个数据库事务,它首先写入一个数据块 ,然后写入一个提交记录 ,表示“事务完成”。应用程序发出写入 的请求,然后是写入 的请求。操作系统缓存确认了两者。这些写入现在位于缓冲区中,等待被发送到磁盘。但如果磁盘的内部调度器为了追求效率,决定先写入块 呢?如果恰好在此时发生电源故障,磁盘上将包含提交记录,但却没有与之对应的数据。恢复后,数据库会相信一个事务已经完成,而实际上其数据已经丢失。这就是数据损坏。
为了防止这种情况,我们需要屏障(或“栅栏”)。屏障是一个命令,它说:“停下。在你能保证之前所有的操作都已持久化之前,不要越过此点。” 在文件系统中,这就是 [fsync](/sciencepedia/feynman/keyword/fsync)() 系统调用。在存储硬件中,它可能是一个 FLUSH CACHE 命令或带有特殊强制单元访问 (Force Unit Access, FUA) 标志的写入。这些屏障是 I/O 世界的交通警察,它们以造成交通堵塞为代价来强制执行顺序。在一连串本是异步的写入流中,即使只有少数几个同步屏障操作,也可能导致性能崩溃。整个高速的缓冲写入管道必须排空并暂停,等待那一次同步写入完成,从而产生一种称为队头阻塞的现象,这会严重破坏吞`吐量。
这种矛盾在文件系统的实际设计中显而易见。例如,ext4 文件系统可以运行在 data=writeback 模式下,该模式通过不对数据和元数据之间的顺序做任何保证来提供最高性能。这种模式速度快,但容易出现“幽灵数据”现象:一次崩溃可能导致文件的元数据指向新分配的块,而这些块里仍然包含旧的、过时的数据,因为新数据尚未被写出。为了防止这种情况,ext4 默认采用 data=ordered 模式,这是一种巧妙的折中。它仍然延迟写入,但插入了一个隐式屏障:它保证一个文件的所有数据块都在其关联的元数据提交到日志之前被写入磁盘。你仍然可以获得缓冲的大部分好处,同时还有一个关键的安全网来防止此类损坏。
缓冲区是一个引人入胜的地方——一个介于应用程序的期望与磁盘现实之间的临时存放地。管理这个空间是一门复杂的艺术。
首先,缓冲区是有限的。如果一个应用程序产生脏数据的速度超过了磁盘写出的速度,缓冲区就会被填满。当这种情况发生时,系统必须施加背压。原本立即返回的 write() 系统调用现在会阻塞,迫使应用程序等待。快车道关闭了。这是一种基本的流控制形式,一种随处可见的机制。CPU 的流水线在其写缓冲区满时会停顿,也是同样的原理;TCP 的滑动窗口防止快速发送方压垮慢速接收方的缓冲区,也是同样的原理。
其次,当错误在事后很久才发生时,会怎么样?一个应用程序写入了一千兆字节的数据。write() 调用都成功了,随着数据填满缓存而立即返回。应用程序相信它的工作已经完成,于是关闭了文件。十秒后,操作系统的后台刷新程序开始将这些数据写入磁盘,却发现没有剩余空间了。操作失败。操作系统如何报告这个错误?它无法回到过去去更改原始 write() 调用的返回值。像 Linux 这样的系统采用的健壮解决方案是“锁存”该错误,并在下一个可用的同步点——比如 [fsync](/sciencepedia/feynman/keyword/fsync)() 或 close() 的返回时——向应用程序报告。这是针对一个完全由延迟写入这一选择所造成的棘手问题的务实解决方案。
最后,系统必须决定多久刷新一次缓冲区。这是一个微妙的节流问题。如果你在两次刷新之间等待很长时间(例如,在 Linux 中设置一个大的 dirty_writeback_centisecs),你可以累积大量的脏数据。然后你可以将它以一个巨大的、高效的顺序突发方式一次性写入硬盘。这对于后台吞吐量来说非常棒。然而,在那几秒钟的突发写入期间,磁盘被完全占用,无法为任何其他请求服务。一个试图打开一个小文件的交互式用户会经历令人沮丧的“冻结”。相反,如果你过于频繁地刷新缓冲区,你会产生许多小的、低效的写入,损害整体吞吐量,但能保持磁盘的响应性。因此,操作系统就像一个杂耍演员,不断调整其写回策略,以平衡高吞吐量和低延迟这两个相互竞争的需求。
延迟写入原则,源于对速度的简单渴望,迫使我们直面系统设计中最深刻的一些挑战:性能与可靠性之间的权衡,混乱世界中秩序的强制执行,以及竞争下有限资源的管理。我们的系统每天数十亿次地执行着这种精妙而高风险的平衡动作,而我们大多时候甚至没有察觉,这正是计算机科学智慧的证明。
在我们迄今的旅程中,我们已经探讨了“延迟写入”这一核心思想——即推迟工作的简单而深刻的策略。我们已经看到,操作系统如何通过使用一部分内存作为称为页面缓存的临时存放区,来创造一个强大的幻象:缓慢的机械磁盘几乎和闪电般快速的内存一样快的幻象。这种“拖延”行为平滑了磁盘 I/O 顿挫、走走停停的本质,为我们在计算机上做的几乎所有事情提升了性能。
但是,这个原则,这种等待最佳时机的艺术,并不仅仅是用于文件的一种巧妙技巧。它是整个计算机科学中最普遍、最反复出现的主题之一。它出现在每一个抽象层次,从硅芯片上的晶体管到遍布全球的互联网服务。通过在这些不同领域中追溯这同一个思想,我们可以开始看到现代计算美妙而统一的架构。这是一个关于同样的基本权衡——速度与安全——如何在截然不同的时间和空间尺度上被一次又一次地面对和解决的故事。
让我们从最熟悉的地方开始:你自己的计算机。当你保存一个文档时,你的应用程序似乎瞬间就完成了工作。这就是延迟写入在起作用。操作系统接收你的数据,将其放入内存缓存,并在数据踏上前往物理磁盘的缓慢旅程之前,就说“我收到了!”。
然而,这种安排立即给我们带来了核心困境:如果在操作系统完成这项工作之前断电了怎么办?你“已保存”的数据就消失了。系统设计者提供了旋钮来控制这种风险。文件系统可以以 synchronous(同步)模式挂载,这基本上关闭了延迟;每一次写入都必须在磁盘上完成后,应用程序才能继续。这很安全,但很慢。更常见的方法是使用日志文件系统,它会周期性地将待处理的变更提交到磁盘上的日志中。这些提交之间的间隔——比如每 5 秒或 30 秒——就成了一个“漏洞窗口”。较短的间隔会减少你在崩溃中可能丢失的数据量,但它也会因为迫使磁盘更频繁地工作而降低性能。这是系统管理员每天都在管理的性能与持久性之间直接、可调的权衡。
当我们考虑到像数据库这样有自己严格完整性概念的应用程序时,情况就变得更加复杂了。像 SQLite 这样一个可能管理你的浏览器历史或应用程序设置的简单数据库,不能只是盲目地相信操作系统的拖延。为了提交一个事务,数据库可能需要按特定顺序执行几次写入:首先,是描述变更的日志条目 ,然后是新数据本身 ,最后,是使事务永久化的元数据更新 。如果操作系统为了追求效率而重排了这些延迟写入,数据库在崩溃后可能会处于损坏状态。想象一下,如果元数据()被写入磁盘,宣告一个事务完成,但实际的数据()仍然静静地躺在内存缓冲区里并丢失了。数据库现在就不一致了。为了防止这种情况,应用程序和操作系统之间必须进行一场复杂的舞蹈。应用程序可以发出特殊命令([fsync](/sciencepedia/feynman/keyword/fsync))或设置选项(PRAGMA synchronous)来强制操作系统按特定顺序写出内容,确保事务的承诺是建立在持久存储的现实之上,而不仅仅是内存缓存中短暂的内容。
那个与磁盘精心编排这支精妙舞蹈的操作系统缓存,还服务于另一个美妙的目的。当一个程序写入一个文件,而另一个程序想要读取它时,最慢的方式是第一个程序一路写到磁盘,第二个程序再一路读回来。一个更优雅的解决方案是可能的。通过使用“内存映射文件”(mmap),程序可以请求操作系统将文件的内容直接映射到其地址空间。它所看到的“内存”实际上就是操作系统用于其延迟写入的同一个页面缓存。这就创建了一个高速的通信通道:当一个进程写入文件时,数据落入页面缓存。另一个映射到同一文件的进程几乎可以立即看到这些变化,而无需任何东西接触磁盘。用于延迟向慢速设备写入的同一机制,被重新用作连接快速进程的桥梁。
到目前为止,我们一直将操作系统视为拖延大师。但兔子洞还要更深。操作系统运行在中央处理器(CPU)上,而 CPU 本身就是一个疯狂的拖延者,在纳秒的时间尺度上运作。CPU 有自己的缓存层次结构——微小、超快的内存碎片——在其往返于主系统内存(DRAM)的路上缓冲数据。
几十年来,这都是硬件自己的事。但持久性内存(NVRAM)——速度接近 DRAM 但断电后仍能保留内容的内存——的出现,迫使程序员不得不面对 CPU 的私有延迟。如果你将数据写入持久性内存,你可能会认为它是安全的。但它很可能不是。你的数据可能正静静地躺在 CPU 的易失性私有缓存中。为了保证持久性,应用程序现在必须发出特殊指令(CLWB 或 CLFLUSH)来告诉 CPU:“将这个特定数据从你的私有缓存中驱逐出去。” 即便如此,数据可能仍被缓冲在内存控制器中。还需要最后一条指令,一个“存储栅栏”(SFENCE),来暂停 CPU,直到所有先前的写入都已排空,并真正安息在它们的持久性家园。
这创造了一个有趣的平行。一个 [fsync](/sciencepedia/feynman/keyword/fsync) 系统调用是给操作系统的一条消息:“停止拖延,把这个文件写到磁盘上。” 一个 CLWB 加上一个 SFENCE 是给 CPU 的一条消息:“停止拖延,把这个数据写到持久性内存控制器。” 原理是相同的,只是在系统堆栈的不同层级。
即使没有持久性内存,这种硬件级别的缓冲也会带来挑战。其他设备,如网卡或存储控制器,可以使用直接内存访问(DMA)来读写系统内存而无需 CPU 参与。这就构成了一个潜在的竞争:如果一个 DMA 设备写入一个内存位置,而 CPU 对同一位置有一个不同的、待处理的更新正存放在自己的写缓冲区中,会发生什么?为了防止 CPU 陈旧的、延迟的写入覆盖来自设备的新鲜数据,硬件必须实现自己的一致性协议。设备的写入会触发一个“窥探”消息传遍系统互连总线,提醒 CPU,CPU 随后会检查自己的缓冲区并取消其现在已过时的延迟写入。这是一场微观的、纳秒尺度的协调戏剧,全都是为了管理延迟写入的后果。
当我们从一台计算机转向多台计算机时会发生什么?“延迟”这个简单的概念在复杂性上爆炸式增长,管理它成为现代计算的核心挑战。
考虑一个多核 CPU 内部的核心。从程序员的角度来看,这是一个微型的分布式系统。每个核心都有自己的私有缓存和缓冲区,自己对内存的“延迟”视图。如果一个核心写入一个值,而第二个核心立即尝试读取它,它会看到新值吗?不一定。这个写入可能仍然滞留在第一个核心的本地缓冲区中。这种重排序和延迟,如果管理不当,会使并行编程几乎不可能。解决方案是“内存屏障”或“栅栏”,一种程序员插入以强制顺序的特殊指令。这是一个命令,意为:“刷新我所有待处理的、延迟的写入,并且在它们对其他所有人都可见之前不要继续。” 这就是我们在一个并发的世界里建立事件顺序共识的方式,驯服由每个核心的私下拖延所引入的混乱。
现在将此扩展到运行大规模科学模拟的超级计算机。这样的机器可能需要时不时地保存其状态的“快照”,这个过程可能涉及写入太字节(TB)的数据。如果整个模拟必须为这次写入暂停,进展将陷入停滞。取而代之的是,高性能计算依赖于异步 I/O。模拟告诉 I/O 系统:“这里有大量数据要写入”,然后立即返回去计算下一个时间步。I/O 系统在后台工作,慢慢地将数据写入并行文件系统。其目标是让一个步骤的计算时间足够长,以完全掩盖上一步的 I/O 时间。这是延迟写入原则的一次有意的、大规模的应用,其中“延迟”被用来重叠和隐藏延迟。
最后,我们到达了全球云的规模。想一想一个分布式键值存储,那种支撑着社交媒体信息流和在线购物车的数据库。当你发布一个更新时,它被写入一个副本,然后异步地传播到世界各地的其他副本。这里的“延迟”现在是网络延迟,可能长达数百毫秒。在这段延迟期间,数据库处于不一致的状态。与不同副本通信的不同用户可能会看到不同版本的数据。
这引出了一个惊人的认识。这些行星尺度系统的设计者所面临的问题,与 CPU 架构师几十年前在一颗芯片内部解决的“数据冒险”问题完全相同。
完全相同的逻辑难题再次出现,只是舞台更加宏大。解决方案更加复杂——它们涉及诸如为数据附加版本号(多版本并发控制)和使用逻辑时钟为事件加盖时间戳等技术——但其根本目标是相同的:在一个操作从根本上是延迟和异步的系统中,创造出一种秩序和一致性的表象。
从在你的笔记本电脑上保存一个文件,到全球互联网的一致性,延迟写入的原则是一个永恒的伴侣。它是一种根本性的权衡,一把双刃剑,以复杂性和风险为代价为我们带来性能。理解它穿越抽象层次的旅程,就是去欣赏那些使我们的数字世界成为可能的独创性和深刻、统一的原则。