IO 栈、页缓存与异步 IO
环境:Windows 11 x64(build 26200)、GCC 13.1.0(MinGW-w64,
D:/Qt/Tools/mingw1310_64/bin/gcc.exe,PATH 上那个是 Strawberry 4.9.2,不能用),命令行gcc -std=gnu11 -O2 -static p_io.c -o p_io.exe。i5-11400H、65 GB 内存、QueryPerformanceCounter频率 10000000 Hz(1 计数 = 100 纳秒)。探针全在%TEMP%下自己建的文件里跑(os_io_dir/os_apc/os_g6/os_h6/os_i6/os_j6),跑完删掉,不动任何系统设置。 样本:主探针p_io.c(43 KB,七段 A–G)五次运行,记作运行 1–5(10.4 秒/次);另有两个小探针p_apc.c(APC 完成例程交付,31.0 秒/次,两次)与本轮补测的p_g/p_h/p_i/p_j(0.15–10.6 秒/次;p_h/p_i/p_j各跑两次,输出逐字相同)。同机同期还有别的任务在跑,绝对值只当区间看,比值比绝对值可靠;结构性数字(字节数、错误码、落点下标、返回码)在每一次有该段输出的运行里逐字一样(A–C 段五次、E–F 段四次)。 一条必须交代的坑:主探针运行 3/4/5 在 F 段最后一个统计块里把自己写炸了——那个块对 1 MB 文件的视图触碰 2048 页 = 8 MB,越界 → 段错误(exit=139),三次都在同一位置死。那个块是后来的补丁加进去的,运行 1 用的旧二进制里没有,所以只有运行 1 跑到了 G 段;G 段数字因此取自运行 1 与本轮补测p_g(两次独立、结论一致)。F 段的单价、脏页阶梯与陷阱 1–4 都在崩溃点之前,四次运行的数字完整可用。 本机没有 Linux / WSL / 容器:O_DIRECT、io_submit/io_getevents、io_uring、epoll、/proc、dirty_ratio/dirty_background_ratio/writeback参数、fdatasync、posix_fadvise、sync_file_range、drop_caches全部未在本机实测,涉及处只讲机制并明写。主体是 Windows 侧等价物的实测:FILE_FLAG_NO_BUFFERING≈O_DIRECT、FILE_FLAG_WRITE_THROUGH≈O_SYNC、FlushFileBuffers≈fsync、CreateFileMapping/MapViewOfFile≈mmap、ReadFileEx+APC /PostQueuedCompletionStatus≈aio 完成模型。
一、从 WriteFile 到磁盘:三档写法、三本账
同样 64 MB 的数据,三种打开方式各写一个文件,写完立刻 FlushFileBuffers(四次运行取中位,单位毫秒):
| 打开方式 | 写完 64 MB | 随后 Flush | 合计 | 合计速率 |
|---|---|---|---|---|
缓冲(FILE_ATTRIBUTE_NORMAL) | 22.4 | 28.63 | 51.0 | 1255 MB/s |
FILE_FLAG_WRITE_THROUGH | 56.8 | 0.69 | 57.5 | 1113 MB/s |
FILE_FLAG_NO_BUFFERING | 20.35 | 0.54 | 20.9 | 3064 MB/s |
缓冲那一行的”写完”22.4 毫秒里一个字节都没保证下盘(≈2857 MB/s,那只是拷贝速度);下盘发生在后面那 28.63 毫秒里。直写那一行反过来:每一次 WriteFile 都等设备确认,所以写本身 56.8 毫秒、Flush 只剩 0.69 毫秒可收。无缓冲那一行绕开缓存页,自己用 1 MB 对齐块下盘,反而比”缓存 + 回写”的组合快 2.5 倍。反直觉,但账很清楚:回写路径把脏页送下盘只跑 2122–2559 MB/s(16/64/256 MB 三档),而我用 1 MB 块直写中位 3145 MB/s(单次观测 1841–3930)。慢在哪一步(写回的合并粒度、线程数、还是缓存页到设备的第二趟拷贝)本机看不出来,只能确定这笔差价真实存在。所以 NO_BUFFERING 的价值不是”绕过缓存所以快”,而是大 I/O + 自己控制下盘时机;代价是从此每次读都没有缓存可命中(第四节那 10 倍就是它付的)。
把这三行拆开看,一条 write 在本机实际经过五层,每层都能单独收费:① 陷入(80–110 纳秒,《操作系统在做什么》量过)→ ② IO 管理器构造请求 → ③ 缓存管理器的页缓存(拷进去就算”写完”,2857 MB/s 那一档)→ ④ 脏页记账 + 写回(2122–2559 MB/s,只在被要求时才发生)→ ⑤ 设备。WRITE_THROUGH 是把 ③④ 合成一步、每笔都等 ⑤ ack;NO_BUFFERING 是把 ③ 整层跳掉、用户缓冲区直接进设备(所以它连”拷贝”都省了,但也永远享受不到 ③ 的命中)。C 运行时自己还有第 0 层——《文件与流式 IO》实测过:FILE 默认全缓冲,写 3 字节不 fflush 时文件大小还是 0,setvbuf(_IONBF) 才立刻变 3。本节所有数字都绕开那一层,直接打 Win32 接口。
单笔 4 KB 的三个档位**《文件系统与 VFS》(第 6 节)已实测**(缓冲 9.7–13.6 微秒、WRITE_THROUGH 47.2–112.4、NO_BUFFERING 31.2–48.0,四次运行次序不变),本节不重复取数,只补一个”最低消费”:运行 1 与补测 p_g 各自量了 10 万次「移位 + 读 1 字节」,3517–3806 纳秒/次;同样的移位单独量是 146–161 纳秒;GetFileType(只查用户态句柄表、不进内核)120–121 纳秒。对照《操作系统在做什么》卡出来的”纯陷入 80–110 纳秒”:读 1 个命中页缓存的字节,代价是陷入底价的 32–48 倍——剩下那 3.4 微秒全是 IO 管理器、缓存管理器和一次用户态到缓存页的拷贝。这就是”高吞吐要靠大块 + 并发,不是靠省掉某一次系统调用”的量化理由。
二、FILE_FLAG_NO_BUFFERING(≈ O_DIRECT):三条约束、错误码原文、拒绝点在哪一层
五次运行的 A 段逐字一致。卷参数 GetDiskFreeSpaceA("C:\\"):每簇扇区=8、扇区字节=512 → 分配单位 4096 B、扇区 512 B;VirtualAlloc 出来的缓冲区基址对 512 与 4096 取余都是 0。原始失败/成功样本(err 后是 FormatMessage 原文):
[写·合法] 对齐缓冲+4096 B+偏移 0 WriteFile 返回=1 写=4096 B err=0(操作成功完成。)
[写·缓冲 +1 B 未对齐] WriteFile 返回=0 写=0 B err=87(参数错误。)
[写·长度 1000 B] WriteFile 返回=0 写=0 B err=87(参数错误。)
[写·长度 4600 B(非 512 整数倍)] WriteFile 返回=0 写=0 B err=87(参数错误。)
[写·长度 512 B+偏移 0] WriteFile 返回=1 写=512 B err=0(操作成功完成。)
[对照·没有 NO_BUFFERING] 未对齐缓冲+1000 B WriteFile 返回=1 写=1000 B err=0(操作成功完成。)
IOCTL_STORAGE_QUERY_PROPERTY(设备/适配器) 打在文件句柄上:失败 err=87(参数错误。)
打开裸卷 \\.\C::失败 err=5(拒绝访问。)
补测 p_j 把三条约束各自扫了一遍,结论比文档更具体(两次运行逐字相同):
- 缓冲区地址:基址 +1、+2 →
87;+4 起(4/8/16/32/64/128/256/264/448/512)全部成功。也就是说本机实际只检查 4 字节对齐,不是扇区对齐。这不能当许可——文档写的是扇区大小对齐,换一块盘/一个驱动就可能收紧,工程上按 4096 对齐(VirtualAlloc天然满足)。 - 长度:8/64/511/520/1000/4104/4600 全部
87;512/1024/4096/4608/32768 全部成功 → 必须是逻辑扇区(512 B)的整数倍,这条是硬碰硬实现的。 - 偏移:这才是本节最值钱的一条,见第三节。
- 想问设备要”对齐/最优 I/O”提示:两条
IOCTL_STORAGE_QUERY_PROPERTY打在文件句柄上都是87,\\.\C:是5(TokenElevation=0,《文件系统与 VFS》已实测过同一个入口)。本机用户态没有任何入口拿到适配器的AlignmentMask,只能自己按 512/4096 兜底。
Linux 侧 O_DIRECT 的约束(偏移、长度、缓冲区三者都按逻辑块对齐,违反时 write() 返回 EINVAL)与 fcntl 拿不到对齐提示的痛点是同一类问题,未在本机实测,只能按语义对照。
三、最值钱的一条:偏移不对齐时 WriteFile 返回成功,写的是上一个位置
主探针 A 段五次都记了 [写·偏移 100 B] WriteFile 返回=1 写=4096 B err=0(操作成功完成。)——照这一行读,“Windows 的 NO_BUFFERING 不管偏移对齐”。这个结论是错的,是探针自己的 bug:那段代码 SetFilePointerEx(...) 之后没有检查返回值。补测 p_j 把返回值、错误码、移动后的位置一起打出来:
seek 0:返回=1 err=0(NO_ERROR) → seek 后位置=0 |随后 WriteFile(4096) 返回=1 err=0|写后位置=4096
seek 1:返回=0 err=87(ERROR_INVALID_PARAMETER) → 位置仍是 4096|随后 WriteFile(4096) 返回=1 err=0|写后位置=8192
seek 100:返回=0 err=87(ERROR_INVALID_PARAMETER) → 位置仍是 12288|随后 WriteFile(4096) 返回=1 err=0|写后位置=16384
seek 512:返回=1 err=0 → seek 后位置=512 |随后 WriteFile(4096) 返回=1 err=0|写后位置=4608
seek 4196:返回=0 err=87 → 位置仍是 8192 |随后 WriteFile(4096) 返回=1 err=0|写后位置=12288
seek 4194304:返回=1 err=0 → seek 后位置=4194304 |随后 WriteFile(4096) 返回=1 err=0|写后位置=4198400
J5 相对移动 +1 B:返回=0 err=87 → 位置仍是 1048576;再 +512 B:返回=1 → 位置 1049088
偏移对齐这条约束确实存在,但它的拒绝点是 SetFilePointerEx,不是 WriteFile:seek 失败、返回 0、err=87,文件位置原地不动,紧接着的 WriteFile 完全合法(缓冲区对齐、长度 4096)于是返回成功——写的却是上一个位置。补测 p_i 用”每 512 字节填一个段号”的标签文件做了字节级取证,两次运行逐字相同:
O=1 写返回=1 写=512 B err=0|0xAA 落在 [ 0 .. 511] 共 512 B|段号 0 起
O=100 写返回=1 写=512 B err=0|0xAA 落在 [ 0 .. 511] 共 512 B|段号 0 起
O=513 写返回=1 写=512 B err=0|0xAA 落在 [ 0 .. 511] 共 512 B|段号 0 起
O=512 写返回=1 写=512 B err=0|0xAA 落在 [ 512 .. 1023]|O=1024 → [1024..1535]|O=4096 → [4096..4607]
H2 只在偏移 100 写 4096 B:返回=1 写=4096 B err=0,文件逻辑大小=4096 B(若真落在 100..4196 应为 4196),0xA5 占满 [0..4095]
反过来,把偏移塞进 OVERLAPPED 结构、根本不 seek,拒绝点就回到读写调用本身(p_j 的 J4):ov.Offset=100/513/4196 → WriteFile 返回=0 写=0 B err=87,0/512/4096 → 成功。所以本机三条约束的三个拒绝点分别是:缓冲区→Read/WriteFile、长度→Read/WriteFile、偏移→SetFilePointerEx(或 OVERLAPPED.Offset 时才回到 Read/WriteFile)。“检查每一个返回值”在 NO_BUFFERING 下不是好习惯,是唯一防线:GetLastError 也会被后续调用冲掉,必须就地取。
顺带纠正上一棒的另一处口径:A 段那句”0..4194304 是没分配数据的洞”在本机不成立。补测 p_h(两次逐字相同)只往偏移 4 MB 写一次 4096 B,再用三个接口问:
逻辑大小=4198400 B|GetCompressedFileSize=4198400 B(本文件没调过 FSCTL_SET_SPARSE)
FSCTL_QUERY_ALLOCATED_RANGES(0..EOF) 返回=1 err=0 输出=16 B → 已分配区间 1 段:[偏移 0 长 4198400 B]
读洞中间 2 MB 处 4096 B:返回=4096 字节,其中 0x00 的个数=4096
seek 过去再写,前面那 4 MB 是真分配、实占的(实占=逻辑),只是内容全零。真洞只有稀疏文件才有——《文件系统与 VFS》(第 6 节)已实测 FSCTL_SET_SPARSE 之后 67 MB 逻辑只占 131072 B。这一字之差就是”数据库/日志文件为什么不能靠 seek+写来省空间”的答案。
四、页缓存的钱:同一份文件、同一个请求尺寸,命中与绕开差 10 倍
同一个 128 MB 文件(32×4 MB 缓冲顺序写成,中位 38.9 毫秒 = 3287 MB/s;FlushFileBuffers 再花 44.6–56.4 毫秒下盘)。每个模式连读 3 遍取中位,再对五次运行取中位、给出区间:
| 项目 | 缓冲(走页缓存) | NO_BUFFERING(绕开) | 比值 |
|---|---|---|---|
| 顺序读 1 MB×128 | 中位 4435 MB/s(3089–4653) | 中位 2980 MB/s(2595–3067) | 1.46(逐次 1.10–1.79) |
| 随机 4 KB | 中位 7.19 µs/次(5.91–28.34)→ 543 MB/s | 中位 75.64 µs/次(67.71–89.98)→ 52 MB/s | 10.5x |
| 顺序 4 KB | 中位 4.51 µs/次 → 866 MB/s | 中位 48.62 µs/次 → 80 MB/s | 10.8x |
| 顺序 64 KB | 中位 12.05 µs/次 → 5185 MB/s | 中位 128.67 µs/次 → 486 MB/s | 10.7x |
| 随机/顺序(中位数之比) | 1.59(逐次 1.35–2.68) | 1.56(逐次 1.38–1.96) | — |
三件事值得单独说:
- 页缓存把 4 KB 随机读的单价砍掉一个数量级(75.64 → 7.19 微秒)。这不是”盘快”能解释的:同一个句柄、同一份文件、同样的 4096 字节,唯一的区别是绕不绕缓存。7.19 微秒里,陷入 + IO 栈 + 拷贝约 3.5 微秒(见本文第一节),剩下 3.7 微秒是跨 128 MB 地址足迹的页表/缓存代价——把偏移缩到 8 MB 内轮转,同样的读降到 3.5–4.2 微秒(
p_g的 G2/G4)。这和《虚拟内存、分页与 TLB》里 TLB 那张表是同一笔账——地址足迹一摊开,页表遍历的成本就会叠在 IO 栈头上;《内存分层与地址空间》量到的”32 MB 工作集跨 4 KB 页 9.32 纳秒/次”是同一类成本最干净的形式。 - 缓存是被读热的:同一份文件连续三遍缓冲顺序读,运行 4 是 1748 → 3708 → 4952 MB/s,运行 5 是 4578 → 4653 → 4675。第一遍慢的那一截就是”把页喂进缓存”的钱。写侧同理——写完就测,读侧全命中;所以
NO_BUFFERING读看起来”只慢 10 倍”而不是”慢到盘速”,是因为对照组是热缓存。 - 随机只比顺序慢 1.35–2.68 倍(缓冲)/1.38–1.96 倍(直读),中位都是 1.56–1.59。在 NVMe 上,“顺序远快于随机”这个直觉在 4 KB 粒度已经不成立(直读:随机 75.64 微秒 vs 顺序 48.62);真正把差距拉开的是请求大小(直读从 4 KB 到 64 KB,单价 48.62 → 128.67 微秒,但吞吐 80 → 486 MB/s)。《文件系统与 VFS》里”顺序写不比随机写快”是同一件事的写侧版本。
Linux 侧要复现这组数字得用 /proc/meminfo 的 Cached、posix_fadvise(POSIX_FADV_DONTNEED) 或 drop_caches 来分冷热,本机没有对应入口,未实测。
五、队列深度:异步 IO 买的是并发度,D=4 就吃到大半
NO_BUFFERING + FILE_FLAG_OVERLAPPED + IOCP,随机 4 KB 读 8192 笔,单线程投递+收割,深度 1/4/16/64(五次运行的中位与区间):
| 深度 D | µs/次(中位) | 区间 | 相对 D=1 | IOPS(中位) | MB/s(中位) |
|---|---|---|---|---|---|
| 1 | 68.40 | 63.79–102.10 | 1.00 | 14620 | 57.1 |
| 4 | 16.83 | 16.20–54.95 | 0.25 | 59403 | 232.0 |
| 16 | 13.16 | 10.20–51.72 | 0.19 | 76011 | 296.9 |
| 64 | 18.14 | 10.66–42.93 | 0.27 | 55130 | 215.4 |
收益的形状比绝对值可靠:从 D=1 到 D=4,单价降到 0.24–0.54 倍(五次里四次 ≤0.43);再加到 16 只多拿一点点;D=64 的中位单价 18.14 反而高于 D=16 的 13.16(五次里有三次如此:运行 5 慢 69%、运行 4 慢 31%、运行 3 慢 3.5%),因为单线程一边投一边收,深度超过 16 之后收割本身成了瓶颈,设备侧也在 7.6–9.8 万 IOPS 附近封顶。这就是异步 IO 的第一性结论:“异步”不是让一次 IO 变快,是让 N 次 IO 的等待重叠;而且 N 有个不高的上限,堆到 64 只会把成本加回软件栈。五次运行里 8192 笔全部满读(完成 8192/8192(满读 8192)),没有一笔短读或失败。
Linux 的 io_submit/io_getevents(per-file、上下文深度)与 io_uring 的 SQ/CQ 双环、注册缓冲区,量的是同一件事的不同层,未在本机实测。
六、异步交付的五种方式(六个计时档):软件栈的税都一样,坑在”交付到底来不来”
同一份 128 MB、先把 20000 个随机页全读进缓存,再拿同样的 20000 次 4 KB 随机读比五种交付方式((b2) 是 (b) 的加深版,共六个计时档;运行 3/4/5,µs/次):
| 交付方式 | 运行 3 | 运行 4 | 运行 5 | 中位 |
|---|---|---|---|---|
(a) 同步 ReadFile + 每次 SetFilePointerEx | 11.48 | 12.81 | 6.80 | 11.48 |
(b) ReadFileEx 完成例程,一问一答 | 7.89 | 9.71 | 9.44 | 9.44 |
(b2) ReadFileEx 完成例程,深度 8 | 7.45 | 17.35 | 7.43 | 7.45 |
(c) OVERLAPPED+事件+GetOverlappedResultEx | 6.97 | 14.63 | 7.35 | 7.35 |
(d) OVERLAPPED+IOCP 一问一答 | 8.80 | 12.16 | 7.01 | 8.80 |
| (e) 顺序读(每 16 次回卷首) | 9.42 | 4.46 | 13.49 | 9.42 |
六档全在 4.5–17.4 微秒这一个量级里,三次运行的排序还各不相同((b)/(a) 分别是 0.69 / 0.76 / 1.39 倍)。数据在缓存里的时候,选哪种交付机制几乎不影响单价——“aio 比同步快”是个误会,快的是并发(本文第五节),不是回调。
那”完成例程慢 75 倍”这种说法是怎么造出来的?运行 1/2 用的是改到一半的旧版探针,(b) 是 500.56 / 502.87 µs/次(运行 2 自己打印成”(a) 的 75.19 倍”)。这两个数是同一件事的两次复现:那版代码在同步句柄上投递、然后等那个永远不会来的回调,10 秒超时被 20000 次摊平(10000 ms / 20000 = 0.5 ms)。p_apc 两次运行把它钉死了:
A) 同步句柄(没开 FILE_FLAG_OVERLAPPED)+ 3 次 ReadFileEx:每次都 返回=1 err=0,回调共 1 次
(i=0 SleepEx(0,TRUE) 之后回调数=1;i=1、i=2 之后 SleepEx(0,TRUE)/SleepEx(20,TRUE) 仍是 1)
B) 同一个 OVERLAPPED 结构反复用(同步句柄,5 次):回调数停在 1,但 5 次都读到首字节=0x41
C) FILE_FLAG_OVERLAPPED 句柄 + ReadFileEx,5 次:回调 1/2/3/4/5 逐次对上,ov->Offset=0/4096/8192/12288/16384
D) SleepEx(0,TRUE):117–120 ns/次|SleepEx(1,TRUE):15246592 / 15286067 ns/次
四条都是可复现的:在同步句柄上 ReadFileEx 的完成例程会丢(投 3 来 1),而且读本身成功、数据也对——账是错的,结果是好的,这类 bug 只能靠”投递数 == 回调数”这种自证来抓(B 段那种复用同一个 OVERLAPPED 的写法同样只交付 1 次)。要 APC 必须 FILE_FLAG_OVERLAPPED。最后一行是 APC 模型最贵的坑:SleepEx(0,TRUE) 只是查一眼 APC 队列,120 纳秒;SleepEx(1,TRUE) 说”睡 1 毫秒”,真实阻塞 15.2 毫秒(约等于系统时钟节拍)。所以”投递后睡一下再收”这种写法,一步就是 15 毫秒,比任何一次 IO 都贵;而运行 1/2 那个 500 微秒的假数字,就是 10 秒超时被 20000 次摊平的结果——那两版一边投递一边用 SleepEx 等回调:一版每步 SleepEx(50,TRUE),改过之后的一版换成 SleepEx(0,TRUE) 空转轮询——两种等法撞的是同一个超时。
接口存在性(运行 1 与补测 p_g 两次逐字一致,同一模块同一地址):ReadFileEx/WriteFileEx/SetFileCompletionNotificationModes/CreateIoCompletionPort/GetQueuedCompletionStatus(Ex)/PostQueuedCompletionStatus/CancelSynchronousIo/NtQueueApcThread/NtCancelIoFileEx 都在;IoRingCreateQueue、IoRingCreateQueueEx、IoRingUtilIocpCreateRing、NtCreateRing 在 kernel32/kernelbase/ntdll 三处导出表里都取不到,而 ntdll!NtCreateIoRing=00007ffa32762400 在(两次同址)。也就是说这台 build 26200 上,Windows 版 io_uring 的内核入口存在、公开用户态包装 API 不在这三个模块里,MinGW 头文件也没声明它——“Windows io_uring 的性能”本节未实测(没有能调的入口)。TransmitFile 三处取不到只说明它不在这三个模块(它在 ws2_32.dll),是探针查找范围的限制,不是系统没有。
把这三段合起来看模型差异:同步一问一答是”投一笔等一笔”;OVERLAPPED+事件是”投一笔、可以等别的事”;IOCP 是”内核帮你把完成的排成队,线程只管收割”(本文第五节卖的就是这个);ReadFileEx+APC 是”完成时回调被塞进你的线程,但只有你进入可等待状态才执行”(本节 D 段)。Linux 侧 epoll 报的是对象就绪、还要自己再 read() 一次,io_uring 报的是操作完成、结果直接躺在 CQ 环里——这两样在本机都没有对应物,epoll/io_uring/io_submit 的性能全部未实测,只能按语义摆在这里。
七、映射视图(≈ mmap):单价、按脏页收费的 FlushViewOfFile、四个陷阱
CreateFileMapping + MapViewOfFile(长度 0 = 整个文件),32 MB 文件、512 个随机 4 KB 偏移(运行 1/3/4/5 四次):
视图内随机写 4 KB:首遍 1.72 µs/次(区间 1.31–2.18)|第二遍 0.18 µs/次(0.14–0.25)|首遍/二遍=8.77–10.07 倍
同一批偏移走 WriteFile:首遍 21.86 µs/次(14.69–28.16)|第二遍 13.25 µs/次(8.22–18.29)
映射/系统调用 = 0.06–0.10 倍(首遍)/0.01–0.02 倍(二遍)
触碰 2048 页=2.39 ms → 1167 ns/页(补测 p_g;《虚拟内存、分页与 TLB》测得文件映射视图首触碰 1006–1299 ns/页,正好同一档)
首遍/二遍差 8.77–10.1 倍是缺页的钱(《虚拟内存、分页与 TLB》已实测:映射页首触碰 1006–1299 纳秒/页、热 8 纳秒/页),二遍之后的 0.01–0.02 倍才是 mmap 的真正红利:没有陷入、没有那次用户态↔缓存页的拷贝。
FlushViewOfFile 的钱按脏页收,和 FlushFileBuffers 同一个形状(四次运行中位,单位毫秒):
脏 0 页( 0.00 MB):返回=1 0.17 ms(0.14–0.25)|紧接着再 Flush 一次 0.107 ms
脏 64 页( 0.25 MB):返回=1 0.63 ms(0.53–0.88)|再 Flush 0.205 ms
脏 512 页( 2.00 MB):返回=1 1.20 ms(1.00–1.28)|再 Flush 0.240 ms
脏 4096 页(16.00 MB):返回=1 5.11 ms(4.89–5.23)|再 Flush 0.290 ms
0 页到 4096 页,16 MB 的脏页刷掉 5.11 毫秒(≈3131 MB/s,每页约 1.25 微秒),而”再刷一次”永远在 0.09–0.46 毫秒——收费对象是脏页,不是调用。这也解释了本文第八节 FlushFileBuffers 的同一形状。
四个陷阱,错误码原文照抄(1a–1e 四次运行逐字一致):
- 视图不跟着文件长:
[陷阱1a]视图在时把文件 32→33 MB,SetEndOfFile 返回=1 err=0,可VirtualQuery的视图RegionSize扩容前 33554432 B → 扩容后仍是 33554432 B;[陷阱1b]用同一个 section 再映射偏移 32 MB 的 4 KB:失败 err=5(拒绝访问。)——section 对象在CreateFileMapping那一刻就把大小定死了,想映射新长度得重建 section。 - 缩容被挡,而且”解除视图”不等于”解除 section”:
[陷阱1c]视图在时缩到 1 MB →SetEndOfFile 返回=0 err=1224(请求的操作无法在使用用户映射区域打开的文件上执行。);[陷阱1d]UnmapViewOfFile之后、section 句柄还开着,仍然是 1224;[陷阱1e]section 也关掉才成功(最终大小 1048576 B)。要缩容/删文件,关的是 section 句柄,不是视图指针。 - 写越界视图是异常不是失败:
[陷阱2]子进程映射 64 KB 视图(文件 1 MB,VirtualQuery RegionSize=65536),写v[65536]→ 退出码 0xC0000005,四次运行都一样(《虚拟内存、分页与 TLB》测的”写只读视图”也是同一个异常码,QueryWorkingSetEx的ShareCount那套页级证据见该文,本节不复述)。用 VEH 把Rip改到恢复地址这套只成功了一次(运行 3 退出码 0x0000002A,运行 1/4/5 仍是 0xC0000005,且子进程日志显示处理器进入时恢复地址打印为 0)——这条不作结论,同时提醒一句:主探针自己在 F 段末尾就是这么炸的(对 1 MB 文件的视图触碰 2048 页 = 8 MB → exit=139)。 - 共享模式与可见性:
[陷阱4]文件已被映射时按”只允许FILE_SHARE_READ”再打开它 → **失败 err=32(另一个程序正在使用此文件,进程无法访问。)**。[陷阱3]视图里写v[0]=0xAA、**不**FlushViewOfFile,另开句柄ReadFile 返回=1 读到=0xAA 一致=1;补测p_g更进一步:写 2 字节后**直接UnmapViewOfFile(返回=1)**,重新打开读回[0]=0xBB [4096]=0xCC——**unmap 不会把这些字节丢掉**(注意:这一趟读走的是同一份系统缓存,**不等于已经落盘**,见本节末尾)。解除映射的账单也在这里:UnmapViewOfFile 0.534 ms|CloseHandle(section) 0.005 ms`,而按上面那张阶梯线性插值,刷掉 8 MB 脏页约 2–3 毫秒(插值,不是实测点)——unmap 主要是拆映射,把下盘交给写回。
最后一条要说清边界:视图写和 WriteFile 走的是同一份系统页缓存,所以”另一个句柄立刻看得见”(《文件系统与 VFS》测过 WriteFile 侧的同款现象:写完不 flush 别的句柄立刻读得到 0x7E)。这不等于落盘——进程崩溃、断电之后那些页归谁,本节没做破坏性实验,未实测。Linux mmap 的 MAP_SYNC/DAX、msync 粒度、page cache 与 mmap 读同页时的双倍记账,未在本机实测。
八、脏页与回写:第一次贵、第二次免费、闲置 3 秒不刷,本机看不到水位
FlushFileBuffers 的钱按脏字节数收(四次运行中位,单位毫秒):
| 缓冲写 | 写完 | 第 1 次 Flush | 第 2 次 Flush | 第 2 次/第 1 次 |
|---|---|---|---|---|
| 16 MB | 5.25 | 7.54(2122 MB/s) | 0.058 | 0.8% |
| 64 MB | 22.4 | 28.63(2235 MB/s) | 0.057 | 0.2% |
| 256 MB | 95.55 | 100.04(2559 MB/s) | 0.092 | 0.09% |
第二次 Flush 的中位只有 57–92 微秒(单次观测 43–725 微秒),与规模无关——所以”脏页”这个中间状态是真实存在的,尽管用户态看不到它长什么样。写 256 MB 只要 95.55 毫秒(2678 MB/s)而刷它要 100.04 毫秒(2559 MB/s),两笔账分别对应”拷贝”和”下盘”。
关掉句柄也不等于落盘:同样 64 MB,写完立刻 Flush 中位 23.77 毫秒;写完把句柄关掉、闲置 3.0 秒、再重新打开并 Flush 仍要 23.41 毫秒(返回=1,四次运行都在 20.44–27.67 毫秒之间)。3 秒的窗口里后台写回没有把这 64 MB 脏页清掉,所以那笔钱还在,只是从”这次调用”转到了”下次调用或关机”。想让数据真的在设备上,只有 FlushFileBuffers(≈fsync)这一条明示路径。
本机对”回写水位”能观测到的东西等于零:GlobalMemoryStatusEx 四次给的只有负载 44–45%、物理 35353–36056/65371 MB 空闲、提交 21661–22256/81032 MB 空闲,这张表里没有”脏页字节数”这一项(探针原话)。进程内唯一能用的”谁记了脏页”接口是《虚拟内存、分页与 TLB》测过的 MEM_WRITE_WATCH + GetWriteWatch(粒度 4096 B、能报地址、RESET 后归零),但它记的是”本进程写过哪些页”,不是”哪些页还没下盘”。Linux 侧的 /proc/meminfo 的 Dirty:/Writeback:、/proc/sys/vm/dirty_ratio、dirty_background_ratio、dirty_expire_centisecs、/sys/block/*/queue/writeback、sync_file_range、fdatasync(只刷数据不刷元数据)、posix_fadvise 提示 全部未在本机实测——不是没做,这台机器上没有 Linux/WSL/容器可以进去做。
小结
- 路径:64 MB 三档总账(中位)——缓冲写 22.4 + Flush 28.63 = 51.0 毫秒,
WRITE_THROUGH56.8 + 0.69 = 57.5,NO_BUFFERING20.35 + 0.54 = 20.9。“写完”不等于”下盘”,缓冲那一行的钱只是挪到了 Flush。一次命中缓存的ReadFile(1 B)是 3517–3806 纳秒,同尺寸移位 146–161 纳秒、纯用户态的GetFileType120–121 纳秒,陷入底价 80–110 纳秒:IO 栈的软件税是陷入的 32–48 倍。 NO_BUFFERING三条约束:长度必须 512 整数倍(1000/4600/4104 报87(参数错误。));缓冲区本机只查 4 字节(+1/+2 报 87,+4 起全成)——别把它当许可;偏移必须 512 整数倍,但拒绝点是SetFilePointerEx:返回 0、err=87、位置不动,随后WriteFile返回成功、写在上一个位置(标签法取证:O=1/100/513 的 512 B 全落在[0..511])。走OVERLAPPED.Offset时拒绝点才回到Read/WriteFile。IOCTL_STORAGE_QUERY_PROPERTY打在文件句柄上 87、\\.\C:是 5,本机没有查设备对齐提示的入口。- 纠正两处上一棒的口径:
[写·偏移 100 B] 返回=1是探针没检查 seek 返回值造成的假结论;seek 到 4 MB 再写 4096 B 不产生洞(实占 = 逻辑 = 4198400 B,FSCTL_QUERY_ALLOCATED_RANGES报 1 段覆盖 0..4198400,读中间 2 MB 得 4096 个零字节)。真洞只有稀疏文件有(《文件系统与 VFS》已实测)。 - 页缓存:4 KB 随机读 75.64 → 7.19 微秒(10.5 倍),64 KB 顺序 128.67 → 12.05 微秒(10.7 倍);同一文件连读三遍 1748→3708→4952 MB/s(缓存被读热);随机只比顺序慢 1.56–1.59 倍(中位之比),NVMe 上”顺序远快于随机”在 4 KB 粒度已经不成立。
- 队列深度:直读 4 KB 单价 68.40 → 16.83(D=4)→ 13.16 微秒(D=16),D=64 中位回落到 18.14;IOPS 中位 14620 → 76011。异步买的是重叠等待,不是单次变快,且上限不高。
- 交付模型:数据在缓存里时六种交付全在 4.5–17.4 微秒、排序三次不一致;老探针那个”(b) 500.56/502.87 微秒”是 10 秒超时 ÷ 20000 摊出来的假数字,根因是同步句柄上
ReadFileEx投 3 次只交付 1 次(复用同一个OVERLAPPED同样只 1 次,而读结果全对——账错果对);FILE_FLAG_OVERLAPPED才 5 投 5 到。SleepEx(0,TRUE)120 纳秒、SleepEx(1,TRUE)15.2 毫秒(时钟节拍),这就是 APC 模型的真实唤醒粒度。NtCreateIoRing在 ntdll(两次同址),IoRingCreateQueue*三处导出表都取不到 → Windows io_uring 未实测。 - mmap 侧:视图写 4 KB 首遍 1.72 微秒/页、二遍 0.18,同偏移
WriteFile首遍中位 21.86(14.69–28.16)→ 映射/系统调用 0.06–0.10(首遍)/0.01–0.02(二遍);FlushViewOfFile脏 0/64/512/4096 页 = 0.17/0.63/1.20/5.11 毫秒、再刷永远 0.09–0.46 毫秒;视图不随文件长(RegionSize不变、再映射新偏移 5)、缩容被 1224 挡住且关视图不够、要关 section、映射中按只读共享重开报 32;越界写视图 = 子进程 0xC0000005(四次里 VEH 恢复只成一次,不作结论);不FlushViewOfFile也能被别的句柄读到(0xAA)、unmap 后仍不丢(0xBB/0xCC)——可见 ≠ 落盘。 - 脏页/回写:
FlushFileBuffers第二次只要 0.057–0.092 毫秒(首次的 0.09–0.8%);句柄关掉、闲置 3 秒后 Flush 仍要 23.41 毫秒(≈立刻刷的 23.77);GlobalMemoryStatusEx没有脏页字段,Linux 的dirty_ratio/writeback/fdatasync/posix_fadvise//proc一律未在本机实测。