Windows I/O 的深层(第4回) ── 缓存管理器:你的 WriteFile 何时才能到达磁盘

· · Windows, Win32, I/O, 缓存, 内核, 文件系统, .NET, C#

WriteFile 返回了成功。那么,数据现在在哪里?

答案几乎可以肯定是:它还没有到磁盘上,只是被复制到了内存中的缓存里。这就是为什么会出现「明明保存了,断电重启后却消失了」,为什么文件复制的基准测试会跑出物理上不可能的速度,也是为什么数据库要老老实实地调用 fsync

系列「Windows I/O 的深层」的第 4 回,要讲的正是介于两者之间的缓存管理器第 2 回中曾写道「只要命中缓存,异步 I/O 也会同步完成」,第 1 回里则把「不生成 IRP 的捷径(快速 I/O)」留作了伏笔。这一回把所有伏笔一并回收。

1. 先说结论

  • Windows 的文件缓存采用写回(write-back)方式。读取首先从系统文件缓存中进行,写入也首先写入缓存,反映到磁盘的动作由操作系统之后再完成。1
  • 缓存的实体是文件映射。缓存管理器会把文件以 256KB 为单位的区间映射到系统地址空间,读写就变成了「与该视图之间的内存复制」(第 2 章)。1
  • 写入由每秒运行一次的延迟写入(lazy writer)事后补做反映。应用崩溃不会导致数据丢失,但断电或系统崩溃会导致脏缓存丢失(第 4 章)。1
  • 「确实写入」的工具有三种。FlushFileBuffers(相当于 .NET 的 Flush(true))、FILE_FLAG_WRITE_THROUGHFILE_FLAG_NO_BUFFERING。频繁写入时每次都刷新效率低下,官方文档给出的做法是同时使用 NO_BUFFERING 和 WRITE_THROUGH(第 5 章)。21
  • NO_BUFFERING 附带对齐要求。大小和偏移量必须是扇区大小的整数倍,缓冲区地址也要与物理扇区边界对齐。而且即便使用 NO_BUFFERING,元数据依然会持续被缓存(第 5.3 节)。31
  • 映射视图与缓存共享同一份数据。内存映射文件与普通的缓存 I/O 是一致的,映射的持久化需要 FlushViewOfFile + FlushFileBuffers 两步(第 6 章)。45
  • 命中缓存的同步读写,有时甚至连 IRP 都不会生成。名为快速 I/O 的捷径会直接通往缓存管理器——这正是第 1 回留下的伏笔的答案(第 7 章)。6

2. 缓存的真面目 ── 文件被映射到内存

2.1. 256KB 的槽位与内存复制

如果把 Windows 的文件缓存想象成「磁盘块的容器」,很多行为就无法解释了。正确的图景是这样的——缓存管理器会把文件以 256KB 为单位的区间映射到系统地址空间中的「槽位」(slot),启用缓存的读写会作为该槽位与应用缓冲区之间的内存复制来执行1

系统地址空间应用-用户模式ReadFile/WriteFile 等同于与槽位之间的内存复制首次访问时的读入,与事后的写回都是按页进行系统文件缓存映射文件 256KB 区间的槽位应用的缓冲区传递给 ReadFile/WriteFile 的区域磁盘上的文件

图1:启用缓存 I/O 的真实面貌。从应用的视角看到的「文件读写」,多数情况下只是单纯的内存复制

有一点容易被误解。256KB 是视图(映射)的粒度,并不意味着磁盘 I/O 总是以 256KB 为单位进行。槽位内的页面会按需读入,真正飞向磁盘的 I/O 量会随请求大小和访问模式而变化。如果是第一次读取的区间,就会为了填充槽位而产生磁盘 I/O(此时第 1 回中的 IRP 就会飞向下层的存储栈)。如果已经在缓存中,读取只需复制即可完成第 2 回第 5 章中看到的「命中缓存时即便以异步方式发出请求也会同步完成」,正是这种「能够立即应答的请求就地完成」动作的体现。反过来,在启用缓存但页面不在内存中的情况下,由于页面错误处理没有异步机制,异步读取有时会被同步处理——这个陷阱在第 2 回中也提到过。7

2.2. 「可用内存变少了」的真相

是否使用缓存、先读的状态是按每次打开方式(即每个文件对象)来管理的1,但被缓存的数据本体是按文件(流)单位共享的。同一个文件被多次打开,并不会产生各自独立的缓存,任何句柄看到的都是同一份缓存内容(这也是第 6 章一致性的基础)。只要 Windows 在运行,缓存就会一直处于缓存管理器的掌控之下运作。1 复制大文件或进行大量读写时,空闲的物理内存会不断被转用为缓存。任务管理器里看到的可用内存变少,其中大部分其实是「应用一旦提出请求就会被迅速让出,正处于有价值的待命状态的内存」。为了在诊断内存不足时不至于弄错这个区别,观测方面的实务做法在《在 .NET 中区分 GC 等待与内存泄漏》一文中也有涉及。

这个现象可以在屏幕上直接确认。打开任务管理器 > 性能 > 内存,下方的「内存构成」条会分成 使用中 / 已修改 / 待机 / 可用 几个部分。文件缓存的大部分会落入这个待机区域,在右侧列表中以「已缓存」的形式汇总显示。想看得更细致,可以打开资源监视器 > 内存 标签,同样的分类会附带具体容量并列展示。复制一个几 GB 的文件之后再回头查看,会发现待机增加、可用减少,而「使用中」几乎没有变化——由此可以亲眼确认,这并不是「内存被吃光了」,而是「原本空闲的内存被用作了缓存」。

3. 先读 ── 读取的投机

缓存管理器会根据过去的访问模式,提前读入接下来很可能会被读取的区间(先读,read-ahead)。如果是按顺序读取的文件,在应用提出请求之前,后续的数据往往已经进入了缓存——这就是顺序读取速度快的秘密所在。先读的数据量并非固定,会随检测到的模式和请求大小而变化。

应用的读取请求历史从头开始按顺序读取缓存管理器检测到访问模式先读 - 在被请求之前预先读入后续区间数据量随模式与请求大小而变化提示 FILE_FLAG_SEQUENTIAL_SCAN- 积极地先读提示 FILE_FLAG_RANDOM_ACCESS- 先读会白费,因此加以抑制

图2:先读。除了检测访问模式之外,还可以通过 CreateFile 的标志给出提示

第 1 回对照表中列出的 FileOptions.SequentialScan / RandomAccess,正是给这个先读引擎的提示。「从头扫到尾」的批处理适合用前者,沿着索引跳转的访问适合用后者——只要把它们理解为用来把只有应用自己才知道的未来,告诉操作系统的标志,使用场景就一目了然了。

4. 延迟写入 ── WriteFile 的「成功」意味着什么

4.1. lazy writer 每秒都会到来

写入这一侧使用的是写回缓存WriteFile 在把数据复制到槽位的那一刻就会返回成功,反映到磁盘的动作则被推迟。这种「延迟写入」的方针就是延迟写入(lazy writing)1

负责这一反映动作的,是缓存管理器每秒启动一次的 lazy writer。它会把最近没有被刷新过的页面中的八分之一排入队列写出,如果待写入的数据较多,还会追加更多页面。另外,带有 FILE_ATTRIBUTE_TEMPORARY 属性创建的临时文件,会被排除在 lazy writer 的刷新对象之外——因为对反正很快就会被删除的东西进行写入本身就是浪费。1 不过这只是由属性给出的提示,一旦内存吃紧,仍然可能被写回;而且它只对带有该属性的文件生效,对「名字看起来像临时文件」的文件并不适用。

磁盘lazy writer(每秒启动)系统缓存应用磁盘lazy writer(每秒启动)系统缓存应用复制到槽位并将页面标记为脏(尚未写入)从这里到写回为止是「危险窗口」断电或系统崩溃会导致这段数据消失到这里才真正完成持久化WriteFile(数据)立即返回TRUE选取脏页面的1/8汇总后写回

图3:延迟写入。WriteFile 的成功意味着「已交给操作系统」,而不是「已持久化」

4.2. 发生了什么,会丢失到什么程度

先准确说明「危险窗口」的含义。命运会因故障的种类而分道扬镳。

WriteFile成功后的数据缓存上的脏页面发生了什么应用进程崩溃/强制终止整个操作系统停止断电・蓝屏数据得以保留缓存归操作系统所有lazy writer会按计划写回脏页面会丢失只有已经写到磁盘的部分会保留

图4:故障类型与存亡的分界线。缓存不是「进程的所有物」,而是「操作系统的所有物」

  • 应用死掉,数据也不会消失。一旦数据复制进了缓存,数据的所有者就变成了操作系统。「保存之后应用立刻崩溃,但文件却安然无恙」正是拜此所赐。
  • 整个操作系统死掉,脏数据部分就会消失。刷新频率是作为性能与可靠性之间的权衡来调节的,官方文档也明确写道:「一旦发生突然断电,已缓存的数据就会丢失」。1

换句话说,业务应用设计中要问的问题是:「这份数据,在断电的瞬间丢失是否可以接受」。如果是日志中数秒钟的内容,或许可以接受;如果是已确定的订单数据记录,恐怕就无法接受了。只对不能接受丢失的数据,才使用下一章的工具。

5. 打造「确实已写入」的工具箱

5.1. FlushFileBuffers ── 立即写完

FlushFileBuffers 会把指定文件已缓冲的数据彻底写入设备。由于文件系统的元数据始终会被缓存,要确保元数据也被可靠送达,同样需要刷新(或 WRITE_THROUGH),这一点也是需要把握的要点。12 在 .NET 中,FileStream.Flush(true) 与之相当(仅调用 Flush() 只会把 .NET 内部的缓冲区交给操作系统,操作系统自身的缓存维持不变)。8

不过官方文档也明确敲了警钟——每次写入都调用它效率低下。如果有大量写入且每次都需要持久化,应当使用后文介绍的 NO_BUFFERING + WRITE_THROUGH 组合。2

5.2. FILE_FLAG_WRITE_THROUGH ── 只去掉延迟

FILE_FLAG_WRITE_THROUGH 打开文件后,写入会同时写进缓存,并且不等待 lazy writer,立即也写入磁盘1 关键在于读取依然能够享受缓存带来的好处,这是对「读取保持快速,只想去掉写入的延迟」这一诉求最直接的回答。

5.3. FILE_FLAG_NO_BUFFERING ── 不经过缓存

FILE_FLAG_NO_BUFFERING 会把系统缓存本身从读写中彻底移除。所有读写都不经过缓存,每次都会成为对磁盘设备的 I/O。1 但能够绕过的仅限于 Windows 的系统缓存,如图 5 所示,设备内部的写入缓存是另外一层。如果还要求具备抗断电能力,仍然需要配合使用 WRITE_THROUGHFlushFileBuffers。这是为大量数据的批量传输、以及自行管理缓冲区的数据库引擎准备的工具,但附带着严格的约定。3

  • 读写的大小和文件偏移量必须是卷扇区大小的整数倍(如果是 512 字节扇区,则为 512、1024、1536……)。
  • 缓冲区的地址也必须与物理扇区大小对齐(对于物理扇区为 4096 字节的「Advanced Format」磁盘,也需要加以考虑)。
  • 即便如此,元数据依然会持续被缓存,因此要实现完全的持久化,仍需配合使用 WRITE_THROUGH 或 FlushFileBuffers12

这条「约定」正是那些以为「只要加上标志就够了」的人最先栽跟头的地方。如果不遵守对齐要求就进行读写,会以 ERROR_INVALID_PARAMETER(87)失败。下面整理需要遵守的三点。3

需要对齐的对象 条件 如何满足
读写的大小 卷扇区大小的整数倍 获取 GetDiskFreeSpacelpBytesPerSector,按其倍数取整
文件偏移量 同上(通过 OVERLAPPEDOffset 指定时也相同) 按扇区大小的倍数递增
缓冲区的地址 与物理扇区大小对齐 VirtualAlloc 分配(返回的区域会对齐到页边界,通常为 4096 字节)

第三点尤其容易被忽略。mallocnew,以及 C# 数组返回的地址,都不保证与扇区边界对齐。使用能够按页边界分配内存的 VirtualAlloc,就能同时满足物理扇区为 4096 字节的「Advanced Format」磁盘的要求。最简形式如下所示。

// C++ / Win32。错误处理已简化到最低限度
DWORD sectorsPerCluster = 0, bytesPerSector = 0, freeClusters = 0, totalClusters = 0;
if (!GetDiskFreeSpaceW(L"C:\\", &sectorsPerCluster, &bytesPerSector,
                       &freeClusters, &totalClusters))
{
    return GetLastError();
}

// 把读写单位设为扇区大小的整数倍(这里相当于 1MiB)
const DWORD chunk = (1024 * 1024 / bytesPerSector) * bytesPerSector;

// 缓冲区取用与页边界对齐的区域(malloc/new 无法保证这一点)
BYTE* buffer = static_cast<BYTE*>(
    VirtualAlloc(nullptr, chunk, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
if (buffer == nullptr) { return GetLastError(); }

HANDLE h = CreateFileW(L"C:\\temp\\big.bin", GENERIC_READ, FILE_SHARE_READ, nullptr,
                       OPEN_EXISTING, FILE_FLAG_NO_BUFFERING, nullptr);
if (h == INVALID_HANDLE_VALUE)
{
    // GetLastError 返回的是「上一次 Win32 调用」的结果。如果先调用 VirtualFree,
    // CreateFileW 的失败原因(访问被拒绝、路径不存在等)就会被
    // 清理操作的结果覆盖,最终只能拿到一个查不出原因的代码
    const DWORD err = GetLastError();
    VirtualFree(buffer, 0, MEM_RELEASE);
    return err;
}

// 每次都以 chunk 字节为单位推进,因此大小和偏移量都能保持对齐
DWORD read = 0;
while (ReadFile(h, buffer, chunk, &read, nullptr) && read > 0)
{
    // 处理 buffer 开头的 read 字节
    // (文件末尾处 read 会小于 chunk。这是正常现象)
}

CloseHandle(h);
VirtualFree(buffer, 0, MEM_RELEASE);

另外,.NET 的 FileOptions 中没有与 FILE_FLAG_NO_BUFFERING 对应的值。如果确实需要,就只能直接调用 CreateFile,这种情况下也必须自行遵守上述的对齐要求。请按照「因为要自己管理缓冲区,所以用 NO_BUFFERING」,而不是「想加速,所以用 NO_BUFFERING」这样的顺序来考虑。

5.4. 使用场景整理

默认的WriteFile,到这里为止就会返回成功lazy writer-每秒 / WRITE_THROUGH-即时设备自身的时机 /FlushFileBuffers要求彻底写完NO_BUFFERING跳过缓存直达应用的缓冲区系统文件缓存脏页面磁盘设备内部的缓存非易失性存储介质

图5:数据的层级,以及各种工具能把数据推进到哪一层。也请留意「磁盘设备内部的缓存」这最后一层

方法 会发生什么 适用场景
默认(启用缓存) 通过缓存复制完成。反映交给 lazy writer 绝大多数文件 I/O
FlushFileBuffers / Flush(true) 把那一刻的数据 + 元数据彻底写完 关键节点的确认(如事务提交等)
FILE_FLAG_WRITE_THROUGH 每次写入都立即写入磁盘(读取仍走缓存) 不能丢失的连续写入,如日志・日志文件(journal)
FILE_FLAG_NO_BUFFERING(+WRITE_THROUGH) 不经过缓存。有对齐要求 自行管理缓冲区・大量批量 I/O

选择的顺序分两步:先决定「断电时最多可以丢失几件」,再确认「为此可以接受多慢」。请不要只是从上往下浏览表格,而要沿着下面这个分支走一遍。

可以接受如最近数秒的日志等不可以接受关键节点如交易确认等每一条否,普通应用是,数据库引擎等打算写入这份数据断电・蓝屏的瞬间丢失是否可以接受维持默认设置,启用缓存最快,绝大多数 I/O 都是这个不能丢失的是“关键节点”还是“每一条”关键节点调用 FlushFileBuffers.NET 中改用 Flush,参数传 true成本,只有关键节点处的等待是否自行管理缓冲区并满足 5.3 节的对齐要求FILE_FLAG_WRITE_THROUGH每次写入都立即写入磁盘读取仍走缓存,依然很快FILE_FLAG_NO_BUFFERING+ FILE_FLAG_WRITE_THROUGH官方给出的“频繁持久化”方案

图6:工具的选择方式。第一层分支是可靠性要求,第二层是能够承受的成本。之所以没有“每一条都调用 FlushFileBuffers”这条路,是因为正如 5.1 节所见,官方文档认为那样做效率低下

下面再举出三种实务中的常见模式。

  1. “先写入临时文件→刷新→重命名”是不会留下写坏文件的定式。先把内容彻底写完,再用改名的方式一锤定音——这种原子化的移交,在《文件集成互斥控制基础知识 - 文件锁与原子 claim 的最佳实践》中有详细说明。
  2. 把这件事交给数据库来做,同样是出色的设计。关于 SQLite 如何用 WAL 与刷新机制打造耐久性,请参见《在 C# 业务应用中使用 SQLite》一文。“不自己编写刷新策略”这个选项始终存在。
  3. 做基准测试时要怀疑缓存。“读取速度快得离谱”的测量结果,多半测的是第二次及以后的缓存命中。测量方法的规范整理在《在 Windows 上正确比较不同版本程序运行速度的方法》一文中。

另外,也不要忘记图 5 中的最后一层——磁盘设备内部的缓存FlushFileBuffers 要求把这一层也一并写完,但对 U 盘或外置硬盘来说,还牵涉到设备一侧的写入缓存策略(“快速删除”与“更好的性能”)。移动设备的处理方式也请参见《在 Windows 应用中处理 USB 设备的方法》。

6. 与内存映射文件的一致性

在第 1 回听到“缓存的实体就是文件映射”时,想必有人会产生这样的疑问——那么自己用 MapViewOfFile 创建的视图,和 ReadFile/WriteFile 使用的缓存之间,会不会互相打架?

不会。因为它们建立在同一套机制之上。文件映射对象是以文件为后盾的,页面被换出时会以写回文件的形式进行。即便多个进程针对同一个本地文件各自创建视图,看到的内容也是一致的(coherent)4

系统地址空间进程A的地址空间缓存管理器的视图ReadFile/WriteFile 使用的槽位MapViewOfFile的视图同一组物理页面以文件为后盾的内存磁盘上的文件FILE_FLAG_NO_BUFFERING 的 I/O处于这种共享范围之外,直接通往磁盘

图7:无论是映射视图还是缓存,看到的都是同一批“以文件为后盾的页面”。处于范围之外的只有 NO_BUFFERING

有两点需要注意。

  • FILE_FLAG_NO_BUFFERING 的 I/O 处于这种一致性的范围之外。不经过缓存的读写,与经由映射视图/缓存的内容不会互相核对。如果要混用,就需要自己负责保证一致性。
  • 映射视图的持久化分两步走。FlushViewOfFile 会开始写出范围内的脏页面,但不会写入元数据,也不会等待磁盘设备缓存完成物理写入。要确保数据真正送达,需要在 FlushViewOfFile 之后再调用 FlushFileBuffers5

作为共享内存使用的文件映射的实务内容(命名共享、同步、事故模式),在《共享内存的陷阱与实务最佳实践》一文中有详细说明。

7. 快速 I/O ── 回收第 1 回的伏笔

先用两句话概括。快速 I/O 是为命中缓存的文件同步读写而准备的一条捷径,它不组装 IRP(I/O 请求包,内核传递给驱动程序的请求容器),而是直接与缓存交换数据。Procmon 的 Operation 列中会混杂出现 IRP_MJ_READFASTIO_READ,其原因就在于同样是“读取”,走的究竟是常规路径还是这条捷径。

第 1 回第 5.2 节中写过“并非所有 I/O 都会变成 IRP”,现在就来对答案。

我们已经知道,对命中缓存的文件进行读写,根本不必组装 IRP 并让它流经设备栈,仅靠与缓存之间的内存复制就足够了。于是 Windows 为针对已缓存文件的同步 I/O 准备了名为快速 I/O 的捷径——不生成 IRP,直接调用文件系统的“快速 I/O 入口点”,走一条从缓存管理器直接复制的路径。6 如果快速 I/O 无法处理(不在缓存中、涉及锁、有过滤器插入等情况),就会折回常规的 IRP 路径。需要注意的是,这是为同步请求准备的高速路径,并非“命中缓存 = 总是走快速 I/O”。对异步(FILE_FLAG_OVERLAPPED)句柄的操作,即便能从缓存就地完成(第 2 回第 5 章),有时仍会走 IRP 路径处理。

不能对启用缓存的句柄进行同步读写能否用快速 I/O 处理例如是否已在缓存中快速 I/O不生成 IRP,直接与缓存复制Procmon 中显示为 FASTIO_常规路径组装 IRP 后流经设备栈第 1 回图 6 的世界

图8:快速 I/O 的分支。在 Procmon 中会看到 FASTIO_READIRP_MJ_READ 混杂出现,原因正在于此

这样一来,第 1 回第 7 章在 Procmon 观察中出现 FASTIO_ 行的原因就能解释清楚了。命中缓存的读取,就连 IRP 都是一种奢侈品。这条路径的存在,也会影响到第 6 回将要讲解的过滤驱动程序——迷你过滤器同样能够插入到快速 I/O 中。

8. 总结

  • Windows 的文件缓存是写回式的,其实体是文件 256KB 区间的映射。启用缓存的读写会变成与槽位之间的内存复制。1
  • 读取由先读进行投机,SequentialScan/RandomAccess 就是给它的提示。1
  • 写入由每秒运行的 lazy writer 事后追加完成。应用死掉数据依然会保留,但整个操作系统死掉时只有脏数据部分会消失。设计上要问的问题是“这份数据在断电瞬间丢失是否可以接受”。1
  • 确保写入的工具有 FlushFileBuffers(关键节点确认)/WRITE_THROUGH(每次写入)/NO_BUFFERING(不经过缓存+对齐要求)。每次都刷新效率低下,频繁持久化时官方推荐同时使用 NO_BUFFERING 与 WRITE_THROUGH。也要留意元数据始终会被缓存这一点。231
  • 映射视图与缓存共享同一批页面,二者是一致的。唯一处于范围之外的是 NO_BUFFERING。映射的持久化需要 FlushViewOfFile + FlushFileBuffers 两步。45
  • 命中缓存的同步读写会通过快速 I/O 连 IRP 都省掉。这就是第 1 回在 Procmon 中看到的 FASTIO_ 的真面目。6

下一篇是第 5 回《NTFS 的内部结构 ── 从 MFT 理解文件系统》。到目前为止,我们一直把文件当作“偏移量与字节序列”来处理,但从这一回开始,将深入到 NTFS 在背后如何排布数据——MFT、多数据流、日志、硬链接——降落到磁盘之上那静态的结构。

相关文章

相关咨询领域

合同会社小村软件承接“明明保存了的数据却消失了”“文件写入速度过慢/快得可疑”之类 Windows 业务应用文件 I/O 的设计与故障排查工作。

参考链接

  1. Microsoft Learn, File Caching。关于 Windows 默认会缓存文件数据,读取来自系统文件缓存、写入也写入缓存的这种写回缓存方式,缓存按文件对象单位管理并处于缓存管理器掌控之下运作,把写入磁盘的动作延后并保留在缓存中的方针被称为延迟写入(lazy writing),文件读取时 256KB 的区间会被读入系统地址空间的 256KB 槽位、用户进程与该槽位之间进行数据复制,缓存管理器每秒启动一次 lazy writer、把最近未被刷新的页面中的八分之一排入磁盘写入队列、必要时还会追加更多,临时文件不会被刷新,一旦发生断电之类的突发系统故障未写入的缓存数据就会丢失,即便用 FILE_FLAG_NO_BUFFERING 禁用缓存文件元数据仍可能被缓存,FILE_FLAG_WRITE_THROUGH 会在写入缓存的同时不经过 lazy writer 的延迟立即写入磁盘,文件系统元数据始终会被缓存因此元数据的持久化需要刷新或 FILE_FLAG_WRITE_THROUGH 等内容。  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19

  2. Microsoft Learn, FlushFileBuffers function。关于 WriteFile 通常写入内部缓冲区、操作系统会定期写入磁盘,FlushFileBuffers 会把指定文件已缓冲的信息全部写入设备,对大量写入每次都调用效率低下、需要在频繁写入中确保重要数据持久化的应用应当使用 FILE_FLAG_NO_BUFFERING 与 FILE_FLAG_WRITE_THROUGH 的非缓冲 I/O,对卷句柄调用(需要管理员权限)可以刷新该卷上所有已打开文件等内容。  2 3 4 5

  3. Microsoft Learn, File Buffering。关于以 FILE_FLAG_NO_BUFFERING 打开的文件的访问要求:读写的大小与文件偏移量(包括通过 OVERLAPPED 指定的情况)必须是卷扇区大小的整数倍,读写缓冲区的地址应当与物理扇区大小对齐,需要考虑物理扇区为 4,096 字节的 Advanced Format 设备等内容。  2 3 4

  4. Microsoft Learn, File Mapping。关于文件映射对象以磁盘上的文件为后盾、页面换出会以把变更内容写入文件的形式进行,多个进程从同一个文件映射对象为本地文件创建视图时数据是一致的(coherent,与磁盘上的文件内容相同)等内容。  2 3

  5. Microsoft Learn, FlushViewOfFile function。关于 FlushViewOfFile 会开始把映射视图范围内的脏页面写入磁盘、该函数不会刷新文件元数据也不会等待硬件磁盘缓存完成物理写入,要把脏页面与元数据全部物理写完需要在 FlushViewOfFile 之后调用 FlushFileBuffers 等内容。  2 3

  6. Microsoft Learn, IRPs Are Different From Fast I/O。关于快速 I/O 不生成 IRP、而是直接调用文件系统或缓存管理器入口点,是面向已缓存文件的同步 I/O 高速路径,数据会直接在缓存与用户缓冲区之间(双向)传输,快速 I/O 无法处理时会使用基于 IRP 的常规路径等内容。  2 3

  7. Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows。关于数据已在缓存中时请求会就地完成并返回 TRUE,Windows 的缓存是用文件映射实现的、由于页面缺失时没有异步的页面错误处理机制,启用缓存的异步读取有时会被同步处理等内容。 

  8. Microsoft Learn, FileStream.Flush method (.NET)。关于 Flush() 会把流的内部缓冲区写入操作系统,指定 Flush(true) 时还会额外刷新所有中间文件缓冲区(操作系统的缓冲区)等内容。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

WriteFile 返回成功时,数据是否已经写入磁盘了?
默认情况下并没有写入。Windows 的文件缓存采用写回(write-back)方式,WriteFile 在把数据复制到系统文件缓存的那一刻就会返回成功。写入磁盘的动作,是由缓存管理器每秒启动一次的延迟写入(lazy writer)在之后完成的。需要注意的是,不同故障类型之间存在差异:即使应用进程崩溃,只要操作系统还活着,已经进入缓存的数据之后仍会被写入,不会丢失。但如果是整个操作系统崩溃的故障(断电、蓝屏),尚未写入的脏缓存就会丢失。准确的理解不是「WriteFile 成功 = 已持久化」,而是「成功 = 已交给操作系统」。
怎样才能确保数据一定被写入磁盘?
有三种工具。第一是 FlushFileBuffers,它会把该文件已缓冲的数据和元数据彻底写入设备(在 .NET 中相当于 FileStream.Flush(true))。第二是 FILE_FLAG_WRITE_THROUGH,每次写入都会同时写入缓存并立即写入磁盘。第三是 FILE_FLAG_NO_BUFFERING,完全不经过缓存本身。微软的官方文档指出,每次写入都调用 FlushFileBuffers 效率低下,如果需要在频繁写入的同时确保可靠的持久化,应当同时使用 FILE_FLAG_NO_BUFFERING 和 FILE_FLAG_WRITE_THROUGH。这些手段都是以放弃缓存带来的好处为代价换取速度变慢,因此实务上的要点不是「全部都加上」,而是只对不能丢失的数据的写入使用它们。
FILE_FLAG_WRITE_THROUGH 和 FILE_FLAG_NO_BUFFERING 有什么区别?
WRITE_THROUGH 是「照常写入缓存,但在完成前也会写入磁盘」。读取依然能享受缓存带来的好处,它只是去掉了延迟写入所带来的延迟。NO_BUFFERING 则是「读写都不经过系统缓存」,无论读还是写,每次都会成为对磁盘设备的 I/O(不过它绕过的只是 Windows 的缓存,并不会跳过设备内部的写入缓存)。作为交换,它附带了严格的限制:读写的大小和文件偏移量必须是卷扇区大小的整数倍,缓冲区地址也必须与物理扇区边界对齐。而且即便使用 NO_BUFFERING,文件系统的元数据依然会被缓存,因此要确保元数据也被可靠写入,还需要配合使用 FlushFileBuffers 或 WRITE_THROUGH。典型的使用者是数据库引擎这类自行管理缓冲区的软件,普通应用通常应该优先考虑 WRITE_THROUGH 或 FlushFileBuffers。
任务管理器里看到可用内存很少,是文件缓存造成的吗?
多数情况下确实如此,而且这是正常现象。Windows 会积极地把空闲的物理内存用作文件缓存,进行大文件复制或大量读写时,缓存会随之膨胀,看起来内存使用量在增加。不过缓存所占用的大部分页面,在应用程序需要内存时会比较迅速地被让出,需要与「内存被耗尽、真的不够用」的状态区分开来。怀疑内存不足时,实务上应该关注已提交内存、硬故障(hard fault)频率等指标,而不仅仅是表面上的可用容量。
内存映射文件与 ReadFile/WriteFile 同时操作同一个文件,内容会不一致吗?
与普通的启用缓存的 I/O 之间不会不一致。Windows 的缓存本身就是用文件映射实现的,针对同一个本地文件的映射视图与缓存共享同一份数据,因此一方的更改另一方也能看到。多个进程从同一个文件映射对象创建视图时,数据同样是一致的(coherent)。不过,用 FILE_FLAG_NO_BUFFERING 打开的句柄进行的读写不经过缓存,因此处于这种一致性的范围之外。此外,要确保映射视图的更改被可靠写入磁盘,仅调用 FlushViewOfFile 是不够的——它不会写入元数据,也不会等待硬件缓存完成写入,因此需要在 FlushViewOfFile 之后再调用 FlushFileBuffers。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表