
在计算机体系结构领域,最基本的设计选择之一涉及一个简单的问题:当数据被修改时,应该何时更新主存?这个决策催生了两种对立的缓存写策略理念:写回策略的“延迟-批量处理”方法和写通策略的“即时-透明”方法。本文深入探讨写通缓存,这是一种因其简单性而备受推崇,但其性能却常常受到质疑的策略。我们将探索它所体现的关键权衡——在确保数据一致性与被主存拖慢风险之间的平衡。本探索将分为两个关键章节。在“原理与机制”中,我们将剖析写通策略的核心工作方式,量化其在延迟和带宽方面的性能挑战,并揭示使其变得实用的巧妙工程解决方案,如写缓冲区。随后,“应用与跨学科联系”将拓宽我们的视野,揭示这个硬件层面的决策如何在文件系统、数据库和大规模云基础设施的设计中产生回响,从而证明其普遍意义。
任何缓存的核心都存在一个关于信任与责任的问题。当处理器修改一条数据时,缓存应该做什么?是应该囤积新信息,并承诺稍后告知主存?还是应该做一个勤奋的信使,在每次变更时立即更新主存?这个选择定义了缓存写策略的两大家族:写回(write-back)和写通(write-through)。
写回策略是个“拖延者”。它在本地记录变更(将缓存行标记为“脏”),然后说:“我稍后再处理主存。”这个“稍后”通常发生在该缓存行即将被踢出以为新数据腾出空间时。只有到那时,它才会将囤积的变更写回内存。
写通策略,即我们此行的主题,则完全相反。它是透明、即时且简单的。其理念直截了当:当处理器写入一个内存位置时,缓存会更新自己的副本,并立即将变更传播或“写通”到主存。可以把它想象成同时将文件保存到本地硬盘和云存储。主存始终与缓存的最新版本完全同步。
这种简单性具有一种深刻的美感。这意味着,从计算机系统其余部分的视角来看,缓存在写操作上几乎是不可见的。主存始终是唯一的真相来源。在复杂的多核处理器中,这个特性简直是天赐之物。例如,在一个系统中,私有的 L1 缓存是写通的,而共享的 L2 缓存负责管理核心间的一致性,那么 L1 缓存就无需担心持有“脏”数据。它不需要一个特殊的“已修改”状态,因为它从不独占持有主存没有的数据。它只是简单地将每个写操作传递给 L2,再由 L2 处理确保所有核心看到一致内存视图的复杂性。L1 缓存优雅地将责任委托了出去。
然而,这种优美的简单性伴随着高昂的代价:性能。主存一直以来都比处理器及其缓存慢得多。通过坚持每一次都写入内存,写通策略冒着将快如闪电的处理器拴在一个行动迟缓的锚上的风险。
为了以最鲜明的方式看到这一点,让我们想象一个 CPU 正在对连续的内存地址执行一连串的写操作,每次一个字节。我们将写通策略与另一个简单但粗暴的策略配对:不按写分配(no write-allocate)。该策略规定,如果一次写操作未命中缓存,我们直接将数据写入内存,但不费心将相应的块取入缓存。
思考一下会发生什么。第一次写操作必然未命中,因为缓存是空的。由于采用写通策略,这次写操作被发送到主存。由于采用不按写分配策略,缓存仍然是空的。第二次写操作,写入紧邻的下一个字节,因此也是未命中。它也被发送到内存,而缓存依然为空。这种情况对每一次写操作都会发生。如果 CPU 发出 次字节大小的写操作,它将遭受 次缓存未命中,并触发 次独立的、缓慢的主存写入。我们构建了一个完全不缓存写操作的“缓存”!这个思想实验暴露了写通策略的根本性能挑战:每一次 CPU 写操作都可能变成一次缓慢的内存写操作。
问题不仅仅是延迟——让 CPU 等待——还有带宽。通往主存的路径就像一条车道数量固定的高速公路,每秒只能通过这么多流量。写通策略很容易导致交通拥堵。
更糟糕的是,这种流量通常会被放大。处理器可能只想更改一个 8 字节的值,但内存系统通常被设计为以更大、固定大小的块(例如 64 字节的缓存行)来工作。如果每次 8 字节的写操作都迫使系统向内存发送一个完整的 64 字节行,那么我们发送的数据量就是必要数据量的八倍!我们可以定义一个写放大因子 ,即内存总线上实际发送的字节数与 CPU 意图写入的有用字节数之比。在这个简单的例子中,为一个 字节的写操作发送一个 字节的行,放大因子就是 。
这种持续不断的放大写操作流会消耗宝贵的内存带宽。如果处理器产生写流量的速度超过了内存的处理能力,系统就会变得不稳定。我们可以对此进行精确建模。如果写操作以平均速率 次/秒随机到达,并且每次写操作产生 字节的流量,那么总的提供流量速率为 字节/秒。如果内存带宽为 ,那么系统存在一个临界阈值:。如果写速率 超过这个阈值,待处理的写操作队列将无限制地增长,最终导致处理器停顿。内存总线饱和的概率接近 100%。这不仅仅是一种理论上的可能性;它是一个幼稚的写通系统性能的硬性物理限制。
那么,我们是否必须放弃写通策略的简洁之美?完全不必。这正是巧妙的工程设计大显身手的地方。为了解决延迟问题,我们可以通过引入一个写缓冲区(write buffer)来将 CPU 与缓慢的内存解耦。
想象 CPU 是一位语速飞快的高管,而主存是一位缓慢而有条不紊的打字员。写缓冲区就像一位私人助理,能够以高管的语速做记录。CPU 将其数据“写入”高速缓冲区后,立即转向下一个任务,它相信助理最终会将信息传达给打字员。这隐藏了内存写入的漫长延迟。
但这位助理可以比简单的信息传递者更聪明。假设高管口述:“将报告标题改为‘版本1’”,片刻后又说:“实际上,将标题改为‘最终版’。”一个聪明的助理不会费心去发送第一条信息;他们只会更新自己的笔记,然后发送最终版本。这就是写合并(write coalescing)或写组合(write combining)的魔力。
现代写缓冲区正是这样做的。当 CPU 对同一个缓存行执行一系列小的写操作时(例如,写入单个数据结构中的不同字段),写缓冲区可以收集或合并这些小的写操作。它不是向内存发送多个低效的小消息,而是等到它有一个更大的数据块(或整个缓存行)时,再通过一次高效的事务将其发送出去。这直接解决了写放大的问题。每笔事务都有固定的时间开销,因此减少事务的数量是一个巨大的胜利。
这种改进不仅仅是学术上的;它对总线流量和能耗等指标有实际影响。对于给定的工作负载,写回缓存可能会将多次写操作合并为一次节能的行写入,而一个幼稚的写通缓存则会在每一次存储操作上都花费能量。通过合并写操作,写通策略可以开始挽回部分效率。写通和写回之间的性能差距缩小了,而这一切都归功于一个小型而智能的缓冲区。
在现代计算机体系结构中,你很少会发现孤立存在的策略。它们在一个深度的内存层次结构中以团队形式工作。虽然对于直接与主存通信的缓存来说,写通策略可能太慢,但对于与更快、更大的 L2 缓存通信的 L1 缓存来说,它可能是一个完美的选择。
考虑一个采用写通 L1 和写回 L2 的系统。当处理器写入数据时,L1 完成其工作:更新自己的副本并立即将写操作发送给 L2。L2 接收到这个写操作。因为 L2 是写回式的,它可以吸收这个写操作而无需访问主存,只需将自己的缓存行标记为脏。写流量实际上在 L2 处停止了。这种分层安排让我们两全其美:L1 保持简单并使其数据“干净”,而 L2 则充当 L1 的一个巨大的、复杂的写缓冲区,在流量到达缓慢的主存之前对其进行整合。
当然,这会产生一连串的流量。在稳态的流式工作负载中,如果 L1 以速率 向 L2 发送数据,那么大小有限的 L2 最终必须以相同的平均速率 将较旧的脏行驱逐到下一级(L3 或内存)。这种流量守恒会沿着层次结构向下延续。一次 CPU 写操作可能会在缓存系统的每一级产生一波流量涟漪。
即使有这样的层次结构,各级之间写缓冲区的稳定性仍然至关重要。想象一个 L1 缓存向 L2 发送写操作。L2 通常可以很快地接受这些写操作。但如果 L2 未命中并且必须从主存中获取数据呢?在那漫长的未命中惩罚期间,比如说 120 个周期,L2 的写端口可能会被阻塞。与此同时,处理器不会停止;它继续执行指令并在 L1 的写缓冲区中排队更多的写操作。如果 L2 停顿 120 个周期,而 CPU 每 4 个周期产生一个写操作,那么将有 30 个写操作堆积起来。如果这些长停顿之间的平均时间短于 L2 恢复并清空积压所需的时间,缓冲区将不可避免地被填满,处理器将被迫停止。系统的稳定性是到达率、服务率和不可避免的停顿持续时间之间的一场微妙舞蹈。
在这一切之后,人们可能会得出结论:写通策略,即使有其巧妙的缓冲区,充其量也只是一种折衷方案,是写回策略一个更简单但本质上效率较低的表亲。但计算机体系结构的世界充满了奇妙的惊喜。想象一个学生正在运行一个基准测试,该测试在一个远大于任何缓存的巨大数组上进行流式写操作。该学生测量了发送到主存的写事务数量,并矛盾地发现,写通缓存产生的事务少于写回缓存。这怎么可能呢?
答案揭示了性能分析的一个深刻真理。它在于策略与一种称为缓存颠簸(cache thrashing)的现象之间的相互作用,以及测量的具体内容。
让我们先看看写回策略。当程序流式处理巨大的数组时,它不断地将新的行带入缓存,修改它们,然后迅速将它们驱逐出去以便为接下来的行腾出空间。因为每个被驱逐的行都是脏的,所以每次驱逐都会触发一次写回事务。如果程序的访问模式导致它在一组冲突的地址上循环,它可能会驱逐同一行,将其写入内存,再将其带回,弄脏它,然后再次驱逐它,从而为同一行产生多次写回事务。
现在,考虑带有合并写缓冲区的写通策略。当 CPU 写入某一行时,该写操作被提交到缓冲区。缓存中的行本身保持干净!当这行不可避免地被流式工作负载驱逐时,它的驱逐是无成本的——不需要写事务。写事务仅由缓冲区在将其合并的写操作排空到内存时产生。对于一个顺序流,对一个 64 字节行的所有八次 8 字节存储操作都被合并成一个单一的内存事务。困扰写回策略的颠簸——驱逐和重新驱逐脏行——对写通策略的事务计数没有影响,因为它的驱逐是干净的。
在这个特定的、高压的场景中,写回的优势(延迟写操作)变成了它的弱点(将写操作与混乱的驱逐耦合),而写通的设计(通过缓冲区将写操作与驱逐解耦)使其能够更可预测地执行,并且根据这一个指标,更高效。这是一个绝佳的例证,说明在工程学中,没有普遍“最佳”的解决方案,只有权衡,而真正的理解来自于欣赏策略、工作负载和性能定义本身之间的微妙舞蹈。
现在我们已经探索了写通和写回缓存的内部工作原理,你可能会倾向于认为这只是一个次要的实现细节,一个留给芯片设计者这个深奥世界的选择。这与事实相去甚远。这个看似简单的决定——是现在还是稍后写入主存——是计算机科学中最基本的权衡之一。它的回响可以在计算机系统的每个角落听到,从硬件的最底层到软件架构的最高层。这是一个反复出现的主题,一个平衡即时性与安全性、效率与复杂性的经典故事。让我们踏上一段旅程,看看这一个选择如何塑造我们的数字世界。
在其核心,写回缓存创造了一种情况,即处理器的世界观(其缓存中的数据)比“官方记录”(主存中的数据)更新。相比之下,写通缓存则使官方记录保持完美同步。这对任何需要系统内存的一致、可靠快照的操作都有深远的影响。
考虑创建系统检查点的过程,这是一个机器状态的快照,以便稍后可以恢复。要创建一个有效的检查点,主存中的数据必须是程序进度的忠实表示。对于写通缓存,这非常简单。在我们暂停处理器的那一刻,主存就是正确的状态。它已经准备好被保存。但对于写回缓存,我们必须首先执行一次“刷回(flush)”,强制将每一个已修改的或“脏”的缓存行写回内存。这增加了一个显著的延迟,因为系统必须暂停并等待这个内务处理完成才能进行快照。
这不仅仅是一个理论上的顾虑。在现代云计算世界中,完全相同的情景在虚拟机(VM)休眠期间大规模上演。为了休眠一个 VM,云提供商必须将客户机的整个内存映像保存到持久存储中。如果主机使用写通策略,过程很简单:暂停 VM,主机的内存就准备好被复制了。如果它使用写回策略,虚拟机监控程序(hypervisor)必须首先协调一个复杂且耗时的所有脏缓存行的刷回操作,这可能涉及多个处理器和插槽,然后才能开始保存 VM 的状态。写策略的选择直接影响了你的云实例休眠所需的时间以及虚拟机监控程序设计的复杂性。
CPU 并非舞台上孤独的演员;它与许多其他参与者共享内存系统。像网卡和存储控制器这样的设备使用直接内存访问(DMA)来独立地读写内存,而无需 CPU 的参与。这使得情节变得更加复杂。
想象一个网卡接收到一个数据包,并使用 DMA 将其直接写入主存。CPU 需要处理这个数据包。但如果 CPU 的写回缓存中存有该内存区域的旧版本呢?DMA 引擎作为一个独立的实体,并不会通知缓存它的数据现在已经过时了。如果 CPU 从它的缓存中读取,它将看到旧的、不正确的数据,从而导致数据损坏。为了防止这种情况,CPU 的软件(通常是设备驱动程序)必须执行手动且精细的操作:它必须显式地使相应的缓存行无效,强制从主存重新加载。这种复杂性是写回缓存效率的代价。使用写通策略,或者简单地将该共享内存区域映射为“不可缓存”,则完全避免了这个问题,以牺牲一些性能为代价简化了软件。
这个协调问题也出现在不同的 CPU 核心之间。当两个进程(可能在不同的核心上运行)通过共享内存进行通信时,消费者进程如何知道生产者已经完成了写入?操作系统可以使用一个巧妙的技巧。它可以为该共享内存页配置页表,使用写通策略。当生产者写入数据然后设置一个“就绪”标志时,写通策略确保这些写入立即传播到主存。消费者核心在适当的内存栅栏(memory fences)的帮助下强制执行顺序,然后就可以可靠地看到这些变化。在这里,缓存策略成为了操作系统协调进程之间安全、连贯对话的工具 ([@problem-id:3620253])。
在具有非统一内存访问(NUMA)的大型多插槽服务器中,挑战被放大了,因为访问连接到不同插槽的内存要慢得多。如果“插槽B”上的一个线程反复写入一个物理上位于“插槽A”的内存页,写通策略会产生持续且昂贵的跨插槽流量。在这种情况下,写回策略要智能得多。它只支付一次高昂的跨插槽成本来获得一个缓存行的独占所有权,之后所有对该行的写入都变成了快速的、本地的缓存命中。这种权衡是如此关键,以至于操作系统会不断监控这种访问模式,并可能决定将整个内存页从插槽A迁移到插槽B,这个决策在很大程度上受到底层写策略所产生的流量模式的影响。
写通和写回的原则是如此基础,以至于它们在软件架构的更高层级被一次又一次地重新发现和实现。程序员在面临同样的安全性和性能权衡时,凭直觉也得出了相同的解决方案。
只需看看保护你硬盘数据的日志文件系统(journaling file system)即可。为防止突然断电造成的数据损坏,这些系统极其小心地处理对其内部结构——描述文件和目录的元数据——的更新。它们通常采用一种类似于写通缓存的策略:元数据的更改会同步地写入一个特殊的日志(journal)中,之后才进行其他操作。这确保了文件系统的结构总是可以被修复。相比之下,对实际文件数据的写入通常以写回的方式处理:它们被缓存在内存中,稍后以高效的批处理方式写入磁盘。文件系统本质上创建了自己的混合缓存,对安全关键信息使用“写通”策略,对大块数据使用“写回”策略以追求性能。
这个类比完美地延伸到了数据库系统的世界。当一个事务提交时,数据库如何保证其持久性?
最后,考虑构建一个可靠的 RAID 存储阵列的挑战,它能防止磁盘故障。RAID 5 中一个臭名昭著的问题是“写漏洞(write hole)”:如果在更新一个数据块及其对应的奇偶校验块的过程中发生电源故障,阵列会处于一种损坏的、不一致的状态。高端 RAID 控制器通过内置一小部分由电池备份单元(BBU)保护的写回缓存来解决这个问题。这使得缓存成为非易失性的。控制器可以接受整个写操作,立即确认它,并利用电池电力保证即使主电源丢失,也能完成对所有磁盘的写入。这种非易失性写回缓存使一个非原子操作(写入多个磁盘)看起来是原子的。然而,一个没有 BBU 的廉价控制器则制造了一个可怕的陷阱。它可能有一个写回缓存,但是是易失性的。它对操作系统撒谎,在写入持久化之前就确认写入,从而造成一个危险的“双重缓存”问题,可能导致静默数据损坏。
从处理器的核心到数据中心的架构,写通和写回之间的选择不仅仅是一个技术细节。它是一种基本的设计哲学,是现在与未来、安全与速度之间的持续对话。它是一个绝佳的例证,说明一个单一、简单的概念如何能够穿透层层抽象,塑造整个数字世界的性能、可靠性和复杂性。