更新记录(仅首版,2026年07月29日 发布)
- 首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22175331)
以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。
Go Komura(2026)。《Windows I/O 底层原理(第 4 篇)——缓存管理器:你的 WriteFile 何时才真正写入磁盘》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/windows-cache-manager-writefile-disk/
- DOI(已登记存档)
- 10.5281/zenodo.22175331
- DOI(上次登记版本)
- 10.5281/zenodo.22175332
WriteFile 返回了成功。那么,数据现在在哪里?
在普通的启用缓存的写入中,数据首先被交给操作系统内存中的缓存。WriteFile 成功的含义是“已交给操作系统”,而不是“已持久化到磁盘”。 写入磁盘由操作系统在之后完成。1
知道这个区别,就能解释“明明保存过却因为断电丢了”“文件复制的测量值比磁盘性能还快”这类现象。数据库用 fsync 之类的调用显式写出,也是为了区分接收写入和完成持久化。
本文是系列“Windows I/O 底层原理”的第 4 篇。本篇以位于应用与磁盘之间的缓存管理器为主题,依次梳理缓存的机制、写出的时机,以及不能丢失的数据该怎么保存。
同时还会说明第 2 篇提到的“数据在缓存里时异步 I/O 也会同步完成”的原因,以及第 1 篇提到的“不生成 IRP 的捷径——快速 I/O”。
1. 先说结论
- 默认的写入中,复制到缓存和写入磁盘是两件事。 Windows 采用写回方式,由 lazy writer 在之后写出。交给操作系统缓存的数据,在只有应用崩溃时会保留下来;但断电或操作系统崩溃时,尚未写出的脏页会丢失。1
- 对不能丢失的数据,要选择与保存粒度相符的方法。 如果在关键节点确定下来,基本用
FlushFileBuffers(.NET 中是FileStream.Flush(true));如果要消除每次写入的延迟,基本用FILE_FLAG_WRITE_THROUGH。FILE_FLAG_NO_BUFFERING是绕开系统缓存的另一种工具,带有对齐要求,而且仍需顾及设备内部缓存和元数据。对于频繁的持久化,官方给出的做法是同时使用 NO_BUFFERING 和 WRITE_THROUGH。231 - 读取的速度和 I/O 路径,同样可以从缓存的机制来理解。 缓存的实体就是文件映射,读取时预读会发挥作用。映射视图的一致性和持久化要分开考虑,快速 I/O 则理解成“可以省掉 IRP 时的捷径”。456
可以按照自己的目的,从下面对应的章节读起。
| 想了解的内容 | 阅读的章节 |
|---|---|
| 缓存的实体、可用内存变少的原因、预读 | 第 2 章、第 3 章 |
| WriteFile 成功之后,故障会让什么丢失 | 第 4 章 |
| Flush、WRITE_THROUGH、NO_BUFFERING 的取舍与对齐要求 | 第 5 章 |
| 映射视图的一致性,以及两段式刷新 | 第 6 章 |
| 缓存命中与快速 I/O 的区别 | 第 7 章 |
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 32 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 缓存究竟是什么——文件被映射到内存
2.1. 256KB 的槽位与内存复制
缓存的实体,是把文件的一部分映射到内存上的视图。缓存管理器把文件以 256KB 为单位的区间映射到系统地址空间的“槽位”上。启用缓存的读写,是以这个槽位与应用缓冲区之间的内存复制的形式执行的。1
flowchart TB
subgraph U["应用(用户模式)"]
BUF["应用的缓冲区<br/>(传给 ReadFile/WriteFile 的区域)"]
end
subgraph S["系统地址空间"]
SLOT["系统文件缓存<br/>映射了文件 256KB 区间的槽位"]
end
DISK[("磁盘上的文件")]
BUF <-->|"ReadFile/WriteFile =<br/>与槽位之间的内存复制"| SLOT
SLOT <-->|"首次访问时的读入与<br/>随后的写回都以页为单位"| DISK
图1:启用缓存的 I/O 的真实形态。从应用看到的“文件读写”,多数情况下只是内存复制
256KB 不是读写磁盘的单位
256KB 是视图(映射)的粒度,并不意味着磁盘 I/O 总是以 256KB 为单位。 槽位内的页面按需读入,实际的 I/O 量会随请求大小和访问模式变化。
如果是第一次读的区间,就会为了填充缓存而产生磁盘 I/O,第 1 篇中看到的 IRP 会向下走进存储栈。如果数据已经在缓存里,读取只靠内存复制就能完成。
即使以异步方式发出,也可能当场就处理完
第 2 篇第 5 章提到的“缓存命中时即使以异步方式发出也会同步完成”,说的就是能立刻给出结果的请求当场完成的行为。
反过来,即使启用了缓存,如果页面不在内存里,由于缺页处理没有异步机制,异步读取有时也会被同步处理。缓存命中带来的立即完成,与因为页面不在而变成同步处理,要分开理解。7
2.2. “可用内存变少了”到底是怎么回事
区分每次打开各自的设置和被共享的数据
是否使用缓存、预读处于什么状态,都是按每次打开(以文件对象为单位)管理的。1 另一方面,被缓存的数据本体是按文件(流)为单位共享的。同一个文件打开多少次,也不会生成各自独立的缓存。各个句柄看到同一份缓存内容,正是第 6 章要讨论的一致性的基础。
只要 Windows 在运行,缓存管理器就会持续管理缓存。1 复制大文件或大量读写时,空闲的物理内存会被用作缓存。其中大部分是处于备用状态的内存,应用一申请内存就能比较迅速地转用。
因此,可用内存变少和内存不够用并不是同一件事。 基于这一区别的实际观测做法,在“在 .NET 中区分 GC 等待与内存泄漏”中也有讨论。
在界面上要看“备用”和“可用”的变化
在任务管理器的“性能 > 内存”中,下方的“内存构成”条会显示 使用中 / 已修改 / 备用 / 可用。文件缓存的大部分进入“备用”,在右侧的列表里被合计为“已缓存”。在资源监视器的“内存”选项卡中,可以带着容量数值确认同样的分类。
可以复制一个几 GB 的文件,对比前后的情况。“备用”增加、“可用”减少,而“使用中”几乎不变——从这个变化就能确认,原本空闲的内存被用作了缓存。
3. 预读——对读取的投机
缓存管理器会根据过去的访问模式,提前读入接下来可能被读到的区间。这就是预读(read-ahead)。
从文件开头顺序读取时,如果在下一个请求发出之前后续数据已经进入缓存,读取就会相应变快。预读的量不是固定的,会随检测到的模式和请求大小变化。
flowchart LR
A["应用读取请求的历史<br/>正在从开头顺序读取"]
D{"缓存管理器<br/>检测到访问模式"}
R["预读,在被请求之前<br/>先把后续区间读进来<br/>(量随模式和请求大小变化)"]
H1["提示 FILE_FLAG_SEQUENTIAL_SCAN<br/>= 积极进行预读"]
H2["提示 FILE_FLAG_RANDOM_ACCESS<br/>= 预读会白费,因此加以抑制"]
A --> D
D --> R
H1 -.-> D
H2 -.-> D
图2:预读。除了检测访问模式,还可以通过 CreateFile 的标志给出提示
第 1 篇的对照表里列出的 FileOptions.SequentialScan / RandomAccess,就是给这个预读的提示。它把应用已经知道的访问计划告诉操作系统。
| 访问计划 | Win32 的标志 | .NET 的指定 | 对预读的提示 |
|---|---|---|---|
| 顺序读取整个文件的批处理 | FILE_FLAG_SEQUENTIAL_SCAN |
FileOptions.SequentialScan |
积极进行预读 |
| 像顺着索引跳转那样的不规则访问 | FILE_FLAG_RANDOM_ACCESS |
FileOptions.RandomAccess |
抑制容易白费的预读 |
关键是选择与处理内容相符的提示,这两个标志都不是把预读量固定下来的指定。
4. 延迟写入——WriteFile 的“成功”是什么意思
4.1. lazy writer 每秒都会来一次
默认的写入中,WriteFile 在把数据复制到缓存槽位的那一刻就返回成功,写入磁盘被推到后面。这种写回缓存的策略被称为延迟写入(lazy writing)。1
承担写入磁盘的,是缓存管理器每秒启动一次的 lazy writer。它把最近没有刷新过的页面的八分之一放进队列,如果要写的数据多,还会继续追加。1
这里说的“每秒”是处理的启动间隔。不能把它读成“每一次写入都会在 1 秒内完成持久化”的保证。
临时文件属性是抑制写回的提示
带 FILE_ATTRIBUTE_TEMPORARY 属性的临时文件,被假定会很快被删除,因此被排除在 lazy writer 的刷新对象之外。1 不过,属性说到底只是提示。内存紧张时它仍可能被写回,而且只是名字看起来像临时文件并不会让这个属性生效。
sequenceDiagram
participant App as 应用
participant C as 系统缓存
participant LW as lazy writer(每秒启动)
participant D as 磁盘
App->>C: WriteFile(数据)
Note over C: 复制到槽位并把页面<br/>标记为脏(尚未写入)
C-->>App: 立即返回 TRUE
Note over App,C: 从这里到写回之前是“危险的窗口”<br/>断电或操作系统崩溃时这些数据就消失
LW->>C: 选出脏页的 1/8
LW->>D: 一起写回
Note over D: 到这里才第一次完成持久化
图3:延迟写入。WriteFile 成功的含义是“已交给操作系统”,而不是“已持久化”
4.2. 发生什么事时,会丢到什么程度
从 WriteFile 成功到写回结束之间,存在一段数据还留在缓存里的时间。这段时间里的数据会怎样,取决于只有应用停了,还是整个操作系统停了。
flowchart TB
W["WriteFile 成功后的数据<br/>(缓存上的脏页)"]
Q{"发生了什么"}
A1["应用进程<br/>崩溃或被强制结束"]
A2["整个操作系统停止<br/>(断电、蓝屏)"]
S["数据仍会保留<br/>缓存属于操作系统<br/>lazy writer 会按计划写回"]
L["脏页丢失<br/>只剩下已经到达磁盘的部分"]
W --> Q
Q --> A1
Q --> A2
A1 --> S
A2 --> L
图4:故障类型与存亡的分界。缓存不是“进程的东西”,而是“操作系统的东西”
| 故障 | 交给操作系统缓存的数据会怎样 |
|---|---|
| 应用崩溃、被强制结束 | 缓存属于操作系统,只要操作系统还在运行,之后就会被写回 |
| 断电、操作系统崩溃 | 尚未写回的脏页丢失,只剩下已经到达磁盘的部分 |
“保存之后应用马上就崩了,文件却完好无损”,是因为数据复制进缓存之后由操作系统持有。另一方面,官方文档也明确写着,突然断电时尚未写出的缓存数据会丢失。刷新的频率,是作为性能与可靠性之间的取舍来调节的。1
设计时要先定下来的是:“这份数据,在断电的那一瞬间丢掉是否可以接受”。日志最近几秒的内容或许可以接受,但订单数据的确定记录大概不能丢。对不能丢失的写入,使用下一章的方法。
5. 打造“确实写入了”的工具箱
本章要区分三种方法:把现有缓冲区写出去、消除写入的延迟、绕开系统缓存。在最后的 5.4 节,会按可靠性要求和能付出的成本,总结选择的顺序。
5.1. FlushFileBuffers——现在就全部写完
FlushFileBuffers 会把指定文件已缓冲的数据写出到设备。由于文件系统的元数据始终会被缓存,要把元数据也送到位,就需要刷新,或者需要 WRITE_THROUGH。12
在 .NET 中要区分 Flush() 和 Flush(true)
| 调用 | 写出的范围 |
|---|---|
FileStream.Flush() |
把 .NET 内部的缓冲区交给操作系统,不请求刷新操作系统的缓存 |
FileStream.Flush(true) |
除 .NET 内部之外,还会刷新操作系统等中间文件缓冲区 |
在 Windows 上与 FlushFileBuffers 相当的指定,是 FileStream.Flush(true)。请注意它与只调用 Flush() 时的区别。8
也要考虑每次都刷新的成本
官方文档指出,在大量写入中每次都调用 FlushFileBuffers 的做法效率低下。对于写入频繁、且每次都需要持久化的情况,文档给出的做法是后面要讲的同时使用 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 是把 Windows 的系统缓存从读写路径上去掉的指定。读取和写入都不经过缓存,直接成为对磁盘设备的 I/O。1
不过,设备内部的写入缓存是另一层。NO_BUFFERING 并不能绕到那一层,因此如果需要抗断电,仍要继续考虑配合 WRITE_THROUGH 或调用 FlushFileBuffers。
它适合大批量数据的整体传输,以及自行管理缓冲区的数据库引擎;另一方面,应用侧必须满足下面的对齐要求。3
- 读写的大小和文件偏移量必须是卷扇区大小的整数倍(512 字节扇区的话就是 512、1024、1536……)。
- 缓冲区地址也要与物理扇区大小对齐(还需要顾及物理扇区为 4096 字节的“Advanced Format”磁盘)。
- 即使如此,元数据仍会继续被缓存,所以要做到完整的持久化,需要配合 WRITE_THROUGH 或调用
FlushFileBuffers。12
把大小、偏移量、地址这三点都对齐
只是加上标志,并不能切换到 NO_BUFFERING。不遵守对齐要求的读写,会以 ERROR_INVALID_PARAMETER(87)失败。 要确认的是下面三点。3
| 要对齐的对象 | 条件 | 如何满足 |
|---|---|---|
| 读写的大小 | 卷扇区大小的整数倍 | 取得 GetDiskFreeSpace 的 lpBytesPerSector,向它的倍数取整 |
| 文件偏移量 | 同上(用 OVERLAPPED 的 Offset 指定时也一样) |
每次按扇区大小的倍数前进 |
| 缓冲区地址 | 与物理扇区大小对齐 | 用 VirtualAlloc 分配(返回按页边界,通常是 4096 字节对齐的区域) |
用最小的 C++ 例子确认缓冲区的对齐
最容易被忽略的是第三点,缓冲区地址。malloc、new 以及 C# 的数组返回的地址,并不保证与扇区边界对齐。
下面的例子用 VirtualAlloc 分配按页边界对齐的区域。如果是通常的 4096 字节页边界,那么物理扇区为 4096 字节的 Advanced Format 磁盘的要求也能满足。大小和偏移量则按取得的扇区大小的倍数前进。
// C++ / Win32。错误处理做了最简化
DWORD sectorsPerCluster = 0, bytesPerSector = 0, freeClusters = 0, totalClusters = 0;
if (!GetDiskFreeSpaceW(L"C:\\", §orsPerCluster, &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 中也不能省掉对齐的管理
.NET 的 FileOptions 里没有与 FILE_FLAG_NO_BUFFERING 相对应的值。需要时就直接调用 CreateFile,但那种情况下对齐要求同样得自己遵守。
考虑的顺序是:不是“想更快所以用 NO_BUFFERING”,而是“因为自己管理缓冲区所以用 NO_BUFFERING”。
5.4. 取舍的梳理
flowchart TB
A["应用的缓冲区"]
B["系统文件缓存<br/>(脏页)"]
C["磁盘设备内部的缓存"]
D[("非易失的记录介质")]
A -->|"默认的 WriteFile,到这里就返回成功"| B
B -->|"lazy writer(每秒)/ WRITE_THROUGH(立即)"| C
C -->|"设备自身的时机 /<br/>FlushFileBuffers 要求全部写完"| D
A -.->|"NO_BUFFERING 跳过缓存直达"| C
图5:数据的层级,以及各个工具能把数据推到哪一层。“磁盘设备内部的缓存”这最后一层也要注意
| 方法 | 会发生什么 | 适合的场景 |
|---|---|---|
| 默认(启用缓存) | 复制到缓存就算完成,写入磁盘交给 lazy writer | 绝大多数文件 I/O |
FlushFileBuffers / Flush(true) |
把当时的数据加元数据全部写完 | 在关键节点确定下来(事务提交等) |
FILE_FLAG_WRITE_THROUGH |
每次写入都立即写入磁盘(读取仍走缓存) | 不断产生不能丢失的写入的日志与 journal |
FILE_FLAG_NO_BUFFERING(+WRITE_THROUGH) |
不经过缓存,带有对齐要求 | 自行管理缓冲区、大批量整体 I/O |
先定可靠性,再确认成本
首先定下 “断电时最多可以丢几条”。在此基础上,再区分不能丢的是交易确定这样的“关键节点”,还是“每一次写入”。
接着确认:为此可以接受慢多少,能不能自己处理缓冲区管理和对齐。不要从方法的名字出发去选,而要顺着下面的分支来考虑。
flowchart TB
S["正打算写这份数据"]
Q1{"在断电、蓝屏的瞬间<br/>丢掉是否可以接受"}
A0["保持默认(启用缓存)<br/>最快。绝大多数 I/O 都在这里"]
Q2{"不能丢的是<br/>“关键节点”还是“每一条”"}
A1["在关键节点调用 FlushFileBuffers<br/>.NET 中用 Flush(true)<br/>成本只有关键节点上的等待"]
Q3{"能否自行管理缓冲区<br/>并满足 5.3 的对齐要求"}
A2["FILE_FLAG_WRITE_THROUGH<br/>每次写入都立即写入磁盘<br/>读取仍走缓存,依然很快"]
A3["FILE_FLAG_NO_BUFFERING<br/>+ FILE_FLAG_WRITE_THROUGH<br/>官方给出的“频繁持久化”的形式"]
S --> Q1
Q1 -->|"可以接受<br/>(最近几秒的日志等)"| A0
Q1 -->|"不可以接受"| Q2
Q2 -->|"关键节点<br/>(交易的确定等)"| A1
Q2 -->|"每一条"| Q3
Q3 -->|"否(普通应用)"| A2
Q3 -->|"是(数据库引擎等)"| A3
图6:工具的选法。第一层分支是可靠性要求,第二层是能付出的成本。之所以没有“每一条都调用 FlushFileBuffers”这条路,是因为正如 5.1 中看到的,官方文档认为那样效率低下
落实到保存的设计与性能测量上
在实际工作中,把它与下面三种模式联系起来考虑,会更容易做选择。
- “写入临时文件 → 刷新 → 重命名”是不留下写坏一半的文件的常规做法。先把内容写完,再用名字确定下来——这种原子性的交接,在“文件集成互斥控制基础知识”中有详细讨论。
- 交给数据库也是一种像样的设计。SQLite 如何用 WAL 和刷新做出持久性,请参见“在 C# 业务应用中使用 SQLite”。“不自己写刷新策略”这个选项始终存在。
- 做基准测试时要怀疑缓存。“读取快得离谱”的测量结果,多半测的是第二次以后的缓存命中。测量的规范做法,汇总在“在 Windows 上正确比较不同版本程序运行速度的方法”中。
把设备内部的缓存也一并考虑进来
图5 的最后还留着磁盘设备内部的缓存。FlushFileBuffers 要求把这一层也包含在内全部写完。
在 U 盘和外置磁盘上,还会涉及设备侧的写入缓存策略,即“快速删除”和“更好的性能”。可移动设备的处理,另见“在 Windows 应用中处理 USB 设备的方法”。
6. 与内存映射文件的一致性
6.1. 映射视图和缓存看到的是同一份数据
既然缓存的实体就是文件映射,那么自己用 MapViewOfFile 创建的视图,与 ReadFile / WriteFile 所用缓存的内容之间会是什么关系呢。
普通的启用缓存的 I/O 与映射视图,共享由同一个文件支撑的数据。文件映射对象的页面被换出时,更改会被写回文件。多个进程从同一个文件映射对象为同一个本地文件创建视图时,看到的内容也是一致的(coherent)。4
flowchart TB
subgraph P1["进程 A 的地址空间"]
V1["MapViewOfFile 创建的视图"]
end
subgraph SYS["系统地址空间"]
SC["缓存管理器的视图<br/>(ReadFile/WriteFile 使用的槽位)"]
end
PAGES["同一组物理页面<br/>(由文件支撑的内存)"]
DISK[("磁盘上的文件")]
V1 --> PAGES
SC --> PAGES
PAGES --> DISK
NB["FILE_FLAG_NO_BUFFERING 的 I/O<br/>处在这种共享之外(直接到磁盘)"]
NB -.-> DISK
图7:映射视图和缓存看的都是同一批“由文件支撑的页面”。处在圈外的只有 NO_BUFFERING
6.2. 一致性与持久化要分别确认
NO_BUFFERING 的 I/O 处在这种一致性的范围之外。 不经过缓存的读写,与映射视图或经由缓存的内容之间不会互相对齐。如果要混用,就需要由应用侧自己保证一致。
另外,更改在映射视图中已经可见,和该更改已经持久化到磁盘,是两件事。映射视图的持久化,要按下面的顺序进行。5
| 顺序 | 调用 | 作用与注意事项 |
|---|---|---|
| 1 | FlushViewOfFile |
开始写出范围内的脏页。不写元数据,也不等待从设备内部缓存完成物理写入 |
| 2 | FlushFileBuffers |
要求把文件的元数据和设备内部缓存都包含在内全部写完 |
把文件映射当作共享内存来用的实务(命名共享、同步、踩坑模式),在“共享内存的陷阱与实务最佳实践”中有讨论。
7. 快速 I/O——回收第 1 篇留下的问题
快速 I/O 是对已缓存文件的同步读写,不生成 IRP 就完成处理的捷径。IRP 指的是“I/O 请求包”,是装载内核交给驱动程序的请求的结构。6
7.1. 能省掉 IRP 的情况,与退回常规路径的情况
第 1 篇 5.2 节中“并不是所有 I/O 都会变成 IRP”的说明,指的就是这条路径。
如果读写只靠与缓存之间的内存复制就能处理完,就不必组装 IRP 再送进设备栈。在快速 I/O 中,会直接调用文件系统的入口点,与缓存管理器交换数据。6
不过,如果因为数据不在缓存里、涉及锁、有过滤器介入等原因用不了快速 I/O,就会退回常规的 IRP 路径。
还要注意,并不是“缓存命中就一定走快速 I/O”。对异步(FILE_FLAG_OVERLAPPED)句柄的操作,即使能从缓存当场完成,也可能走 IRP 路径处理。第 2 篇第 5 章的“是否当场完成”,与本章的“是否省掉 IRP”是两回事。
flowchart TB
REQ["对启用缓存的句柄进行的同步读写"]
Q{"能否用快速 I/O 处理<br/>(数据在缓存里等)"}
FAST["快速 I/O<br/>不生成 IRP,直接与缓存复制<br/>在 Procmon 中显示为 FASTIO_"]
IRP["常规路径<br/>组装 IRP 送进设备栈<br/>(第 1 篇图6 的世界)"]
REQ --> Q
Q -->|可以| FAST
Q -->|不可以| IRP
图8:快速 I/O 的分支。在 Procmon 中会看到 FASTIO_READ 和 IRP_MJ_READ 混在一起,原因就在这里
7.2. 观察时要区分同一个读取所走的路径
第 1 篇第 7 章的 Procmon 观察里混着 FASTIO_ 的行,是因为同样的读取所经过的路径不同。FASTIO_READ 和 IRP_MJ_READ 要作为快速 I/O 与常规 IRP 路径的区别来分别解读。
这条捷径也与第 6 篇要讨论的过滤器驱动程序有关。迷你过滤器不仅能介入常规的 IRP 路径,也能介入快速 I/O。
8. 小结
按机制、故障、设计判断的顺序回顾全文。
| 视角 | 要抓住的要点 |
|---|---|
| 缓存的实体 | 映射了文件 256KB 区间的视图。启用缓存的读写是与槽位之间的内存复制,256KB 不是磁盘 I/O 的固定大小 |
| 读取 | 会预读下一个区间。SequentialScan / RandomAccess 是传达访问模式的提示 |
| 写入与故障 | 默认由 lazy writer 在之后写出。交给操作系统缓存的数据在只有应用崩溃时会保留,但断电、操作系统崩溃时尚未写出的脏页会丢失 |
| 保存方法的选择 | 在关键节点确定下来用 FlushFileBuffers,要消除每次写入的延迟就用 WRITE_THROUGH。NO_BUFFERING 带有对齐要求,频繁持久化时要考虑与 WRITE_THROUGH 配合使用 |
| 一致性与持久化 | 映射视图与普通的缓存 I/O 共享数据。NO_BUFFERING 处在范围之外,映射的持久化要在 FlushViewOfFile 之后再用 FlushFileBuffers |
| I/O 路径 | 快速 I/O 是在对已缓存文件的同步 I/O 中省掉 IRP 的路径。但缓存命中并不总是走快速 I/O |
在理解写回、预读和 lazy writer 的基础上,请先决定 “这份数据在断电时丢掉是否可以接受”。然后再把刷新的成本、始终被缓存的元数据、设备内部缓存都考虑进去,选择保存方法。123
映射视图的一致性与写出的完成、缓存命中与省掉 IRP,也都是各自独立的判断。这种区分,会成为排查保存故障和快得不自然的基准测试结果的线索。456
接下来是第 5 篇“NTFS 的内部结构——从 MFT 理解文件系统”。到本篇为止,我们一直把文件当作“偏移量与字节序列”来处理,而在它背后 NTFS 是怎么摆放数据的——MFT、多数据流、日志、硬链接——下一篇会往下走到磁盘上的静态结构。
相关文章
- Windows I/O 底层原理(第 1 篇)——所有读写都会变成 IRP:I/O 系统全貌
- Windows I/O 底层原理(第 2 篇)——同步 I/O 与异步 I/O:OVERLAPPED 的真正含义
- Windows I/O 底层原理(第 3 篇)——I/O 完成端口(IOCP)与 .NET 线程池:async/await 的地下室
- 文件集成互斥控制基础知识 - 文件锁与原子 claim 的最佳实践
- 共享内存的陷阱与实务最佳实践
- 在 C# 业务应用中使用 SQLite——WAL 模式、排他控制、防损坏对策、与 EF Core 的取舍
- 在 Windows 上正确比较不同版本程序运行速度的方法
- 在 Windows 应用中处理 USB 设备的方法——虚拟 COM、HID、WinUSB、专用 SDK 该如何选择
相关咨询领域
合同会社小村软件承接“本应保存好的数据丢失了”“文件写入太慢 / 快得可疑”这类 Windows 业务应用文件 I/O 的设计与故障排查。
参考链接
-
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
-
Microsoft Learn,FlushFileBuffers function。关于 WriteFile 通常写入内部缓冲区、由操作系统定期写出到磁盘;FlushFileBuffers 会把指定文件已缓冲的全部信息写出到设备;在大量写入中每次都调用它效率低下,写入频繁且需要持久化重要数据的应用应当使用基于 FILE_FLAG_NO_BUFFERING 与 FILE_FLAG_WRITE_THROUGH 的非缓冲 I/O;对卷句柄调用它(以管理员权限)可以刷新该卷上所有已打开的文件等内容。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn,File Buffering。关于访问用 FILE_FLAG_NO_BUFFERING 打开的文件时的要求:读写的大小和文件偏移量(包括用 OVERLAPPED 指定的情况)必须是卷扇区大小的整数倍;读写缓冲区的地址应与物理扇区大小对齐;需要顾及物理扇区为 4,096 字节的 Advanced Format 设备等内容。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn,File Mapping。关于文件映射对象由磁盘上的文件支撑,页面被换出时表现为把更改内容写入文件;多个进程从同一个文件映射对象为同一个本地文件创建视图时,数据是一致的(与磁盘上的文件内容相同)等内容。 ↩ ↩2 ↩3
-
Microsoft Learn,FlushViewOfFile function。关于 FlushViewOfFile 会开始把映射视图范围内的脏页写入磁盘;该函数不刷新文件元数据,也不等待从硬件磁盘缓存完成物理写入;要把脏页和元数据全部物理写完,应当在 FlushViewOfFile 之后调用 FlushFileBuffers 等内容。 ↩ ↩2 ↩3
-
Microsoft Learn,IRPs Are Different From Fast I/O。关于快速 I/O 是不生成 IRP、直接调用文件系统或缓存管理器入口点的、面向已缓存文件的同步 I/O 快速路径;数据会从缓存直接传输到用户缓冲区(或反向传输);在快速 I/O 无法处理时会改用基于 IRP 的常规路径等内容。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn,Asynchronous disk I/O appears as synchronous on Windows。关于数据在缓存中时请求会当场完成并返回 TRUE;Windows 的缓存是用文件映射实现的,由于没有针对页面不在内存时的异步缺页机制,启用缓存的异步读取有时会被同步处理等内容。 ↩
-
Microsoft Learn,FileStream.Flush method (.NET)。关于 Flush() 会把流的内部缓冲区写出到操作系统,而指定 Flush(true) 时还会在此之外刷新所有中间文件缓冲区(操作系统的缓冲区)等内容。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows I/O 底层原理(第 2 篇)——同步 I/O 与异步 I/O:OVERLAPPED 的真正含义
本文是图解讲解 Windows 同步 I/O 与异步 I/O(重叠 I/O)的系列第 2 篇。梳理 FILE_FLAG_OVERLAPPED 的含义、完成通知的 4 种方式、本应异步却同步完成的条件、取消的正确做法,以及与 .NET 的对应关系。
Windows I/O 底层原理(第 1 篇)——所有读写最终都会变成 IRP:I/O 系统全貌
本文是从底层讲解 Windows I/O 系统的系列第 1 篇。用图解梳理对象管理器的命名空间,驱动程序、设备、文件这三种对象,IRP 的生命周期,直到 CloseHandle 背后的机制。
Windows I/O 底层原理(第 5 篇)——NTFS 的内部结构:从 MFT 理解文件系统
本文是图解 NTFS 内部结构的系列第 5 篇。从开发者视角梳理 MFT 与文件记录、多数据流(Zone.Identifier)、硬链接与 8.3 短名、重解析点、两种日志,以及稀疏与压缩。
Windows I/O 底层原理(第 3 篇)——I/O 完成端口(IOCP)与 .NET 线程池:async/await 的地下室
本文是图解讲解 I/O 完成端口(IOCP)的系列第 3 篇。梳理把完成队列与线程数控制融为一体的设计、并发值与 LIFO 释放,以及 .NET 线程池和 async/await 续体的执行线程。
Windows I/O 底层原理(第 6 篇·完结篇)——迷你过滤器的机制与用 Procmon 排查延迟
讲解迷你过滤器监视和控制文件 I/O 的机制。梳理 FltMgr、高度、pre/post 回调与 fltmc 的读法,汇总用 Procmon 定位慢操作的步骤,以及排除项设置和 Dev Drive 的注意事项。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
常见问题
汇总了咨询这一主题时常见的问题。
- WriteFile 返回成功的那一刻,数据已经写入磁盘了吗?
- 默认情况下还没有写入。Windows 的文件缓存采用写回方式,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 会积极地把空闲的物理内存用作文件缓存,复制大文件或进行大量读写时,缓存会随之膨胀,内存使用量看起来就变多了。不过缓存占用的页面大多属于应用一申请内存就能比较迅速转用的那一类,需要与“内存被吃光、真的不够用”的状态区分开。怀疑内存不足时,实务上不能只看表面的可用容量,还要看已提交内存、硬缺页错误的频率这类指标。
- 用内存映射文件和 ReadFile/WriteFile 操作同一个文件,内容会不一致吗?
- 与普通的启用缓存的 I/O 之间不会不一致。Windows 的缓存本身就是用文件映射实现的,针对同一个本地文件的映射视图与缓存共享同一份数据,因此一方的更改另一方也能看到。多个进程从同一个文件映射对象创建视图时,数据同样是一致的(coherent)。不过,用 FILE_FLAG_NO_BUFFERING 打开的句柄所做的读写不经过缓存,因此处在这种一致性的范围之外。此外,要把映射视图的更改可靠地写入磁盘,只调用 FlushViewOfFile 是不够的——它不写元数据,也不等待硬件缓存完成写入,因此需要在 FlushViewOfFile 之后再调用 FlushFileBuffers。