try ai
科普
编辑
分享
反馈
  • 写回缓存

写回缓存

SciencePedia玻尔百科
核心要点
  • 写回缓存通过延迟对主存的写入来提升性能,仅在修改过的(“脏”)缓存行被驱逐时才更新主存。
  • 这种延迟写入的方法会造成暂时的​​数据不一致,因此需要缓存刷新和内存屏障等机制来维护顺序和正确性。
  • 写回缓存的原理从根本上影响了设备驱动程序、文件系统、数据库和虚拟化软件的设计。
  • 通过在易失性缓存中为近期数据创建单一真实来源,写回缓存引入了故障时数据丢失的风险,并可能产生网络安全侧信道。

引言

在对计算速度不懈追求的过程中,快如闪电的处理器与相对缓慢的主存之间存在着一道根本性的鸿沟。缓存策略是跨越这道鸿沟的桥梁,其中功能最强大的策略之一便是​​写回缓存(write-back caching)​​——一种基于一个简单而深刻的承诺运行的高性能方法:稍后将数据写回内存。这种延迟操作释放了惊人的速度,但却为开发者和系统设计者带来了关键的知识鸿沟,因为它在缓存和主存之间造成了暂时的、故意的​​不一致性。理解如何管理这一鸿沟是构建快速、可靠和安全系统的关键。

本文将深入剖析写回缓存的复杂性。首先,在​​“原理与机制”​​一章中,我们将探讨“脏”位、写分配和驱逐等核心概念,揭示硬件为维持其承诺而执行的复杂舞蹈。接着,在​​“应用与跨学科联系”​​一章中,我们将看到这一个架构决策如何在整个软件栈中掀起波澜,塑造从设备驱动程序、文件系统到虚拟机和网络安全防御的方方面面。

原理与机制

现代高性能计算的核心在于一个简单而深刻的权衡——处理器与主存之间达成的一项协议。要理解缓存的世界,特别是​​写回缓存​​这一优雅而复杂的策略,我们必须首先理解这项协议。这是一个承诺,一项职责的延迟,为我们换来了惊人的速度,但它也伴随着一套自己的规则和风险。

图书管理员的困境:稍后写入的承诺

想象一个巨大的图书馆,主存就是书架上无尽的书籍。处理器是一位勤奋的研究员,需要频繁地更新这些书籍。通往书架的路既漫长又缓慢。

一种策略被称为​​写通(write-through)​​,即研究员每次需要更改时,都起身走到正确的书架,找到书,写下更改,然后放回去。这种方法安全、简单,并确保书架上的书永远是最新版本。但它非常缓慢,特别是当研究员需要进行许多小修改时。

现在考虑另一种策略:​​写回(write-back)​​。研究员将最常用页面的副本保存在桌上一个可快速取用的小活页夹里——即​​缓存​​。当需要更改时,研究员只需在活页夹的页面上潦草地写下更新,并贴上一张小小的便签,将其标记为“​​脏(dirty)​​”。此时,书架上的书已经过时,或称为​​陈旧(stale)​​。研究员做出了一个承诺:“我会……稍后……更新那本大书。”

这就是写回缓存的精髓。处理器将写操作执行到快速的本地缓存中,只有在绝对必要时才更新缓慢的主存。这非常高效。如果研究员十次编辑同一个句子,写通方法意味着十次缓慢地往返于书架。而写回方法则意味着在桌上进行十次快速的涂改,之后只需一次性地前往书架,用最终版本更新书籍。这种将多个小写操作合并为一个更大的延迟写操作的强大能力被称为​​写合并(write coalescing)​​,这也是写回缓存能如此有效地节省内存带宽的主要原因。

承诺的机制:分配与驱逐

这个基于承诺的系统需要严格的规则才能运作。当研究员需要编辑一个不在桌上的页面时会发生什么?这是一个​​写未命中(write miss)​​。他不能只是将更新信息丢向图书馆,然后期望它能找到正确的书。他必须首先获取上下文。

这就引出了​​写分配(write-allocate)​​策略,它是写回缓存不可分割的伙伴。在发生写未命中时,系统首先从主存中检索包含目标地址的整个数据块(一个完整的缓存行,可能是 646464 字节),并将其放入缓存。只有这样,写操作才会在这个新缓存的副本上执行。仔细观察底层的微操作,会发现一个精密的舞蹈:首先,从CPU锁存地址和数据;然后,为整个块发起一次内存读取;在等待数据到达的同时,用来自内存的字(word)填充缓存行;最后,将CPU的写操作合并到特定的字中,更新缓存行的标签以匹配新地址,并将该行标记为有效和脏。顺序至关重要;在一个缓存行被完全填充之前就将其标记为有效,将会引发混乱,让系统的其他部分读到垃圾数据。

但是,当研究员的桌面空间不足时会发生什么?他必须通过移除一个旧页面来为新页面腾出空间。这就是​​驱逐(eviction)​​。如果被驱逐的页面是“干净”的(即未被标记为脏),那么它就是主存中书籍的完美副本,可以直接丢弃。但如果页面是脏的,那么承诺就必须兑现。研究员必须前往书架,用他桌上的副本更新主存中的书籍,然后才能驱逐该页面。这个更新主存的行为本身就是“写回”。

整个过程的效率取决于一个名为​​局部性(locality)​​的特性。当一个程序顺序地写入内存时(例如,填充一个数组),它会在同一个缓存行内执行许多小的写操作。每次写入都很快,最终写回的成本被分摊到所有这些写操作上。每次存储操作的平均写流量变得和存储本身一样小。然而,如果一个程序向随机位置写入,每次写入可能都针对不同的缓存行。这是最坏的情况:每次小写入都迫使系统从内存中取回整个块,只是为了弄脏它,并安排稍后进行整个块的写回。写流量被放大,性能也随之下降。这就是为什么一些系统对没有局部性的数据流使用​​非写分配(write-no-allocate)​​策略——直接将写操作发送到内存,完全不费事去获取数据块到缓存中,这样做可能更快。

承诺的风险:游走于一致性的边缘

写回缓存的速度是以代价换来的:在一段时间内,系统的状态是分裂的。 “真相”——数据的最新版本——只存在于易失性的缓存中,而主存则持有一个谎言。这种故意造成的不一致性是一种强大的优化,但它给系统的可靠性和正确性带来了深远的挑战。

考虑尝试保存一个正在运行的计算机的状态,比如为了休眠一个虚拟机。使用写通缓存,你可以简单地将主存内容复制到磁盘,并确信这是一个真实快照。而使用写回缓存,这将是一场灾难。真实的状态分散在数千个脏缓存行中。在你能够获取一个一致的快照之前,你必须强制系统履行所有未完成的承诺。这通过​​缓存刷新(cache flush)​​来完成,这是一个命令所有核心将其脏数据写回内存的操作。这个过程不是瞬时的;刷新数十兆字节的脏数据会引入一个明显的停顿,这是延迟写入协议的直接后果。

当硬件发生故障时,这种风险变得更加严峻。现代内存系统使用纠错码(Error Correcting Codes, ECC)来防止数据损坏。想象一下,一个宇宙射线击中一个缓存行并翻转了两个比特——一个不可纠正的错误。如果这发生在一个写通系统中,这只是个小麻烦;损坏的数据被丢弃,正确的版本从主存中重新获取。但如果这发生在一个写回缓存的脏行上,后果是灾难性的。那个脏行持有全宇宙唯一的正确数据副本。随着它的损坏,最新的数据就永远丢失了。主存持有的是一个过时的版本,而且无法恢复。写回的性能是以牺牲最新数据为代价,创造了一个单一、脆弱的故障点。

即使在正常操作期间,写回操作本身也会消耗资源。这些写回操作在处理器需要加载数据时,占用了相同的内存总线。一次驱逐的爆发会造成交通堵塞,从而使CPU停顿。加载操作被停顿的概率与这些后台写回操作对总线的利用率成正比。

终极挑战:在混乱中强加秩序

也许写回缓存最深层的挑战是在一个“稍后”不仅是延迟的,而且是不可预测的世界里,管理操作的顺序。写回操作是异步的;硬件可能会为了优化内存总线使用而重新排序它们。虽然这对性能很有利,但对于依赖特定事件序列来保证正确性的软件来说,这可能会造成严重破坏。

这是文件系统设计中的一个核心问题。考虑截断一个文件——即让它变小。这需要两个步骤:首先,使被截断部分的缓存数据无效,以确保它永远不会被写入磁盘;其次,更新磁盘上文件的元数据(其大小)。如果这两个步骤的顺序错了会怎样?如果元数据先被更新,磁盘上的块就被标记为空闲。但一个并发的后台写回线程,与截断操作竞争,可能仍会将缓存中陈旧的脏数据写入那些“空闲”块之一。如果系统崩溃,而那个块后来被分配给一个新文件,旧数据就会神秘地重新出现。为了防止这种情况,需要进行一系列复杂的操作:清除脏标志,使用​​内存屏障(memory barriers)​​来确保跨CPU核心的可见性,并等待任何进行中的I/O完成——所有这些都必须在敢于更新磁盘上的元数据之前完成。

这场为秩序而战的斗争在​​持久内存(persistent memory)​​的世界里达到了顶峰,在这里,内存本身是非易失性的,并且必须在系统崩溃后保持一致。确保这一点的一个经典技术是预写日志(Write-Ahead Log, WAL)。要提交一个事务,你必须首先写入事务的数据,然后才能写入一个验证它的提交记录。在使用写回缓存的情况下,仅仅按程序顺序发出这些写操作是不够的。硬件可以自由地对异步的写回操作进行重新排序,可能会在它本应验证的数据之前就将提交记录持久化!

解决方案是一个强大的指令:​​存储栅栏(store fence, SFENCE)​​。栅栏对处理器来说是一条不可逾越的界线。当它遇到一个栅栏时,它必须暂停并确保所有之前的写操作都已完全完成并被持久化到内存中,然后才被允许执行任何后续的写操作。正确的序列——写数据、栅栏、写提交记录——是为持久内存编程的基石。这是一种完全为了驯服写回缓存那美丽但狂野的异步性而存在的软件模式。即使在拥有像 MOESI 这样高级一致性协议的多核系统中,一个核心可以是唯一脏副本的“所有者”,但如果该所有者核心突然崩溃,数据仍会丢失,除非它已被显式地写回到持久域。

从一个简单的“稍后写入”的承诺,展开了一个充满复杂性的宇宙。写回缓存是计算机架构师智慧的证明——一个在性能与风险之间走钢丝的精美优化系统,迫使我们直面并发、可靠性和正确性的最深层挑战。

应用与跨学科联系

在窥探了写回缓存的复杂机制后,我们可能很容易将其视为一种巧妙但自成一体的性能技巧。但事实远非如此。实际上,延迟写入的决定——即允许 CPU 生活在一个与主存略有不同的现实中——在整个计算机系统的设计中掀起了涟漪。这是一个根本性的选择,其后果回响在计算机科学的几乎每个领域,从你的电脑与打印机的通信方式,到网络服务器保存数据的方式,再到以网络安全名义进行的秘密战斗。这不仅仅是一种优化;它是现代计算故事中的一个核心角色。

与设备的对话:驯服 I/O

让我们从最基本的交互开始:CPU 如何与外部世界对话。想象一下,CPU 需要网络卡来发送一个数据包。CPU 在内存中精心准备好数据包,然后通过写入一个特殊地址来“按门铃”,告诉网络卡:“开始!”但这里有一个微妙的陷阱。由于我们的写回缓存,那个“准备好”的数据包可能仍以脏数据的形式存在于 CPU 的私有缓存中,尚未进入网络卡读取的主存。CPU 按下门铃,网络卡尽职地通过直接内存访问(Direct Memory Access, DMA)获取数据包,结果却从主存中读取了陈旧或垃圾数据。

为了防止这种情况,操作系统必须扮演一个一丝不苟的编排者角色。在按门铃之前,它必须发出明确指令,强制 CPU“清理”其缓存,将与数据包相关的任何脏数据写回到主存。然后,在设备完成其工作——比如将一个传入的数据包写入内存——之后,操作系统必须做相反的事情。新数据现在位于主存中,但 CPU 的缓存可能仍然持有该内存区域的旧版本,即陈旧版本。操作系统随后必须“使无效”(invalidate)那些缓存行,告诉 CPU:“忘掉你以前对这些数据的认知;下次你需要它时,从源头重新获取”。

这种“设备写入前清理,设备读取后作废”的舞蹈是每个设备驱动程序的基石。但事情变得更加复杂。数据仅仅在内存中可见是不够的;它必须在按门铃之前就可见。现代 CPU 是为性能而重排操作的大师。CPU 可能决定在缓存刷新完成之前就执行按门铃的写操作!为了防止这种竞争条件,程序员必须使用“内存栅栏”——一个像 sfence 这样的指令,它充当一个屏障。它命令 CPU:“在所有先前的内存操作全局可见之前,不要继续执行任何后续的内存操作。”因此,正确的顺序是:写入数据,刷新缓存以确保可见性,建立一个栅栏以确保顺序,然后才按门铃。这个谨慎的序列将潜在的错误杂音转变为 CPU 与广阔 I/O 设备世界之间的可靠对话。

对永久性的追求:缓存与持久化存储

当我们考虑那些必须在断电后依然存在的数据时,写回缓存的后果变得更加深远。当你点击“保存”一份文档或在网络应用上发布一条消息时,你期望的是持久性。但写回缓存,就其本质而言,阻碍了这一点。

考虑一个简单的网络应用,它会立即确认你的帖子。为了追求速度,它可能会使用“写后”缓存,简单地在内存中记下你的帖子,然后告诉你“成功!”,并计划稍后将其写入文件。如果一秒钟后突然断电,你的帖子,那个只存在于 RAM 和 CPU 缓存这个易失性领域的东西,将永远消失。这个应用撒了谎。为了说实话,应用必须采用“写通”策略:它必须将你的帖子写入操作系统的文件缓冲区,然后发出一个特殊命令,如 [fsync](/sciencepedia/feynman/keyword/fsync),这个命令是对操作系统的指令:“直到这些数据物理地存到磁盘上才返回。”只有在 [fsync](/sciencepedia/feynman/keyword/fsync) 完成后,应用才能安全地告诉你你的帖子已保存。

同样的原则也支配着像 RAID 阵列这类复杂存储系统的可靠性。RAID-5 中的一个常见问题是“写漏洞(write hole)”:更新一个数据块需要同时向不同的磁盘写入新数据和新的奇偶校验块。如果在数据写入后、奇偶校验写入前发生断电,阵列将处于不一致、已损坏的状态。高端 RAID 控制器通过其自带的写回缓存解决了这个问题,但增加了一个关键部件:电池备份单元(Battery Backup Unit, BBU)。当操作系统发出写操作时,控制器将其完整、一致的更新(数据和奇偶校验)存储在 BBU 支持的缓存中,并确认完成。从操作系统的角度看,这次写入是原子且即时的。如果断电,电池会保持缓存的活性,控制器在重启后会完成对磁盘的写入,从而完全弥补了写漏洞。相反,一个没有电池的硬件缓存则是一个威胁,因为它在持久性问题上对操作系统撒谎,制造了一个危险的“双重缓存”问题,这会加剧静默数据损坏的风险。

随着持久内存(persistent memory, PM),如 NVRAM 的出现,内存和存储之间的界限变得模糊,情况也变得更加复杂。在这里,“主存”本身就是持久的。持久性的责任从操作系统([fsync](/sciencepedia/feynman/keyword/fsync))直接转移到了应用程序。当应用程序写入 PM 时,数据会落入 CPU 的易失性缓存中。为了使其持久化,应用程序现在必须直接使用 CPU 指令。它必须首先发出一个缓存行写回指令(例如,clwb)将数据从易失性缓存推送到持久内存控制器。然后,它必须使用一个内存栅栏(sfence)来等待,直到该写入被确认为完成。

这种直接控制允许构建极其高效的事务系统。例如,数据库或文件系统日志必须保证数据记录在最终的“提交”记录被持久化之前就已持久化。使用持久内存,这可以通过一个精确的序列来实现:写入数据块,用 clwb 刷新它们,发出一个 sfence 以确保它们是持久的,然后才写入提交记录并对其重复 clwb/sfence 过程。写回缓存的底层机制成为了数据完整性最高级别保证的基本构建块。

现实的幻象:虚拟世界中的缓存

如果管理一个现实是复杂的,那么想象一下管理成千上万个。这是虚拟机监控器(hypervisor 或 Virtual Machine Monitor, VMM)的日常工作,VMM是创建虚拟机(Virtual Machines, VMs)的软件。一个虚拟机相信它拥有自己的私有硬件,包括可以管理自己缓存的 CPU。当一个虚拟机内的客户操作系统试图与其(被模拟的)网络卡通信,并发出像 WBINVD(写回并作废缓存)这样的强大指令时,会发生什么?

Hypervisor 不能允许这个指令原生运行,因为它会刷新物理主机 CPU 的缓存,从而干扰其他虚拟机和 hypervisor 本身。相反,该指令会陷入(trap)到 VMM 中,VMM 现在必须表演一个宏伟的幻术。它必须在客户机虚拟世界的范围内完美地模拟该指令的效果。

这种模拟是我们所讨论的所有挑战的一个缩影。VMM 必须:暂停虚拟机的所有虚拟 CPU 以确保原子性;识别哪些主机缓存行对应于客户机的内存并将其刷新到主机 RAM;使模拟的网络设备静默以使其状态与现在一致的内存同步;并且,如果虚拟机正在实时迁移到另一台物理机,它甚至必须与迁移过程协调,以确保一致的状态被转移。Hypervisor 利用其对主机写回缓存架构的深刻理解,为其客户机构建一个令人信服、隔离且正确的现实。

缓存的阴暗面:泄露与责任

一个为性能而设计的机制,往往会产生意想不到的、有时甚至是险恶的副作用。写回缓存也不例外。因为它只在脏行被驱逐时才将数据写入主存,所以写回这个行为本身就创造了一个信号。这个信号可以被利用。

想象一个加密算法,它根据一个秘密密钥的比特位,要么修改一个数据块,要么只是读取它。攻击者可以运行这个算法,然后强制算法接触过的所有缓存行被驱逐。如果秘密密钥位导致了写操作,一个缓存行将是脏的,驱逐将触发内存总线上的一阵写流量。如果该位只导致了读操作,该行将是干净的,其驱逐将是无声的。通过监视内存总线上的写回流量——甚至仅仅通过测量电磁辐射——攻击者就可以逐位地获知秘密密钥的值。性能优化变成了一个侧信道,一个秘密信息的微妙泄露。

缓存的非本地性也给安全带来了责任。假设你需要通过用零覆盖来安全地从内存中擦除一个敏感文件。你可能会勤奋地向整个内存区域写入零。但是,如果在另一个 CPU 核心上,一个包含一部分旧敏感数据的脏缓存行正在潜伏呢?你的覆盖操作将使该行无效。但稍后,如果那个核心需要在其缓存中腾出空间,它可能会自主地决定将其旧的、脏的数据写回内存,从而复活了你试图销毁的数据!因此,一个真正安全的擦除指令必须做的不仅仅是写入;它必须首先发出一个全局命令,在系统中所有核心上找到并使目标内存范围的任何缓存副本无效,在执行覆盖之前中和这些潜伏的幽灵。

结论:架构师的困境

写回缓存是架构师根本性权衡的体现:速度与简单性。通过允许 CPU 维持自己与现实略微不同步的版本,我们释放了巨大的性能。但这样做,我们引入了一个分布式状态问题,使与外部世界的每一次交互都变得复杂。

从设备驱动程序到文件系统,从数据库到 hypervisor,从可靠性工程到网络安全,核心挑战始终如一:如何管理 CPU 所知与系统其他部分所见的真相之间的差距。解决方案——一套由刷新、栅栏和协议组成的精妙编排——揭示了计算机科学深刻而美丽的统一性,即硬件设计中一个单一、简单的概念决定了每一层软件的形态。写回缓存那沉默、无形的舞蹈,本质上就是计算本身隐藏的节奏。