卷 IV · 万物CH 18深度 18/24

从 read() 到磁盘的一路

你写了一行 read(fd, buf, 4096)。它有时候花 1 微秒,有时候花 3 毫秒——同一行代码,差三个数量级。这一章走完这条路上的每一层,看看那三个数量级藏在哪一步。

VFSpage cache块层fsync

四层,一层一个抽象

从你的 read() 到磁盘上的磁性颗粒或闪存单元,中间隔着四层,每一层解决一个具体问题:

它解决什么问题它对上面隐藏了什么
VFS(虚拟文件系统)让 ext4、XFS、NFS、tmpfs、procfs 用同一套接口你在用哪种文件系统
page cache把读过的数据留在内存里,下次不用读盘★ 这次到底有没有真的读盘
块层把请求排队、合并、重排,让磁盘少动请求的实际顺序和数量
驱动 + 硬件真的跟设备说话它是 NVMe、SATA SSD 还是机械盘

第二层是那三个数量级的来源。

▶ 动手 · IO 路径

下面这台机器把整条路径画在那道边界的两侧。关键操作:在「page cache 命中」和「未命中」之间来回切,看总耗时跳几个数量级。然后打开 fsync 看看它有多贵。

page cache:你机器上最大的一块内存

这是本章最重要的一层。规则很简单:

◆ 内核会把每一个读过或写过的文件页留在内存里

不是「可以配置的缓存」,是默认行为。所有空闲内存都会被拿来做 page cache,直到有人真的需要内存为止。

于是:

  • 第一次读一个文件:真的去读盘,约 100 μs(NVMe)到 8 ms(机械盘)。
  • 第二次读同一个文件:直接从内存拷给你,约 1 μs根本没碰磁盘。

这就是「第二次跑同一个命令快得多」的全部原因——不是磁盘变快了,是压根没读磁盘。

⚠ free -m 骗过所有人
$ free -m
              total   used   free   shared  buff/cache   available
Mem:          15884  2103    412     235      13368        13201
                            ↑                    ↑            ↑
                       只剩 412 MB?        这一大块        真正可用的
                                          是 page cache    13.2 GB

free 那一列显示只剩 412 MB,看起来快没内存了。但那 13 GB 的 buff/cache 是 page cache——它随时可以被回收

要看的是最后那列 available它是内核估算的「不引发换页的前提下还能给你多少」,这才是你真正关心的数字。

老一辈的口号是 "Linux ate my RAM"——空闲内存被拿去做缓存不是浪费,让内存闲着才是浪费。这个设计一直很正确,只是那个 free 列的名字起得太糟糕了。

写:默认是骗你的

读的路径讲完了,写更值得警惕。

当你 write() 成功返回时,数据在哪里

◆ 在内存里。仅此而已。

write() 返回只意味着:数据已经拷进了 page cache,并且那些页被标成了「脏」。

此刻如果掉电,你的数据全没了。而 write() 明明返回了成功。

真正的落盘是由内核的回写线程(pdflush / flusher)在后台异步完成的,触发条件是:脏页超过阈值(vm.dirty_ratio),或者脏页够老了(vm.dirty_expire_centisecs,默认 30 秒)。

所以默认情况下,你的数据最多可能有 30 秒是「写成功了但没落盘」的状态。

这就是 fsync() 存在的理由:它逼着内核把这个文件的脏页立刻写下去,并且等硬件确认

只 writewrite + fsync
返回时数据在page cache(内存)磁盘
掉电会丢吗★ 会不会
耗时约 1 μs约 1 ms——慢 1000 倍

一千倍。这就是「持久化」的标价。

⚠ fsync 的两个坑

坑一:fsync 文件还不够,目录也要 fsync。

你创建了一个新文件、写入、fsync。掉电后重启——文件可能不存在。因为「这个目录里多了一个文件」这条目录项的修改(第 17 章)也是脏数据,也需要落盘:

int fd = open("data.tmp", O_WRONLY | O_CREAT, 0644);
write(fd, buf, len);
fsync(fd);                    // 文件内容落盘了
close(fd);
rename("data.tmp", "data");   // 原子替换
int dfd = open(".", O_RDONLY);
fsync(dfd);                   // ★ 这一步最容易漏:让 rename 也落盘
close(dfd);

坑二:fsync 失败之后,不能重试。

2018 年爆出的所谓 fsyncgate:Linux 上 fsync 返回错误后,内核已经把那些脏页的错误标记清掉了。你再调一次 fsync,它会返回成功——但数据其实没写下去。

PostgreSQL 因此改成了遇到 fsync 失败直接 panic 重启,从 WAL 恢复。这件事当年在数据库圈引起了不小的震动,因为大家发现自己依赖了三十年的一个保证,其语义从来没被真正说清楚过。

拷贝了几次

一次普通的「读文件、发到网络」,数据被拷贝了四次

磁盘 ──DMA──> page cache ──拷贝 1──> 你的 buf
                                        │
                                     拷贝 2
                                        ↓
              socket 缓冲区 <──────────┘
                    │
                  DMA
                    ↓
                  网卡

中间那两次拷贝(拷贝 1 和 2)完全是多余的——数据只是从内核内存搬到用户内存,再搬回内核内存,内容一个字节都没变。

这就是零拷贝要省的东西:

做法拷贝次数过线次数适用
read + write44你需要处理数据
mmap + write34要读但不改
sendfile2(全是 DMA)2原样转发文件
splice22在两个 fd 之间倒数据

Nginx 发静态文件、Kafka 把日志段发给消费者,用的都是 sendfile但注意前提:你不能碰数据。要压缩、要加密、要改内容,就必须把它拷到用户态——零拷贝就用不上了。这也是为什么 Kafka 一开启 SSL,吞吐会明显下降。

¤ 价目表 · 本章几行
  • page cache 命中的 read:约 1 μs
  • NVMe 随机读 4 KB:约 100 μs——比命中慢 100 倍
  • 机械盘一次寻道:约 8 ms——比 NVMe 慢 80 倍
  • 一次 fsync:约 1 ms——比普通 write 慢 1000 倍

注意机械盘那一行是随机访问的代价。顺序读机械盘能到 100+ MB/s,随机读只有几百 IOPS——差三个数量级。这就是为什么「顺序写日志」这个模式在数据库、文件系统、消息队列里无处不在。SSD 缩小了这个差距,但没有消除它。

↑ 回到应用层

「Android 上 SharedPreferences 的 apply() 和 commit() 差在哪?」

正好就是这一章的分界线:

  • commit()同步写盘并 fsync,返回值告诉你成没成功。阻塞调用线程约 1 ms 甚至更多。在主线程上调它,就是在给自己制造掉帧(第 4 章的 16.6 ms 预算)。
  • apply()立刻更新内存里的值并返回,落盘扔给后台线程。不阻塞,没有返回值——因为它没法告诉你成没成。

所以默认用 apply()。但要知道你买的是什么:进程如果在落盘前被杀(第 15 章说了随时可能),这次修改就丢了。

还有个更隐蔽的坑:apply() 虽然不阻塞你,但它的写入任务会被加入一个队列,并在 Activity onPause 或 Service 生命周期切换时被强制等待完成QueuedWork.waitToFinish())。所以如果你在 onPause 前连续 apply 了几百次,那个等待会实打实地卡在主线程上——ANR 就是这么来的。

这也是 Google 推荐迁移到 DataStore 的原因之一:它基于协程和 Flow,把这件事明确地变成了异步的、有错误处理的操作,而不是一个假装无痛的 apply()

这一章的一句话

一次 read 要穿过 VFS、page cache、块层和驱动,而 page cache 决定了这次是 1 微秒还是 3 毫秒——同一行代码,差三个数量级。写则默认是骗你的:write 返回只代表进了内存,真正的持久化要 fsync,而那要多付一千倍的钱。

下一章:如果一个进程要同时盯着一万个 fd,怎么办?这个问题在 2000 年前后卡住了整个行业,而它的解法改变了服务器的写法。