同样往文件里写 1 MiB,下面两种写法差了 13 倍:
1 MiB, os.write per byte 2071.4 ms
1 MiB, buffered file.write 159.8 ms而如果每写 4 KiB 就要求”确保落盘”,100 次在这台 Mac 上要将近半秒。文件 IO 的快慢和可靠性,取决于字节从你的变量到磁盘之间经过的那几层:用户态缓冲、文件描述符、内核页缓存、文件系统的磁盘布局、日志。这篇沿着这条路往下走。
C 例子在 macOS(Apple Silicon,APFS,Apple clang 21)上运行,Python 用 /usr/bin/python3。文件系统内部结构以 xv6 为主线,它和 Linux ext4 的思路一致、规模小得多。
一切皆文件:文件描述符
Unix 的设计选择是:普通文件、终端、管道、socket、设备,统统用同一组系统调用访问:open、read、write、lseek、close。程序拿到的不是一个复杂对象,而是一个小整数,文件描述符(fd)。每个进程启动时已经有 0、1、2 三个:标准输入、标准输出、标准错误。新打开的文件拿到当前最小的空闲编号。
这个小整数背后有三层表,理解它们就能解释几乎所有”奇怪”的 IO 行为:
- 每进程的 fd 表:下标是 fd,元素是指向打开文件表项的指针。
- 系统级的打开文件表:每次
open新建一项,记录当前偏移量、打开模式、引用计数,并指向一个 inode。 - inode 表:每个文件一份,记录文件的元数据和数据在磁盘上的位置。
关键在于偏移量放在第二层。同一个文件 open 两次,得到两个打开文件表项,各有各的偏移;而 dup(fd) 和 fork 复制的是第一层的指针,指向同一个表项,偏移量共享:
int a = open("f.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644);
int b = open("f.txt", O_WRONLY);
printf("fds: a=%d b=%d\n", a, b);
write(a, "AAAA", 4); write(b, "BB", 2); /* 两次 open:各自的偏移量 */
close(b);
int c = dup(a); write(c, "CC", 2); /* dup:共享同一个偏移量 */
if (fork() == 0) { write(a, "DD", 2); _exit(0); } /* fork 后子进程也共享 */
wait(NULL); close(a); close(c);
// 再打开读出全部内容fds: a=3 b=4
content: BBAACCDDb 的偏移从 0 开始,把 AAAA 的前两个字节覆盖成 BB;c 和子进程的 a 与父进程的 a 共享偏移,于是 CC、DD 依次接在第 4 字节之后。
这正是 shell 重定向和管道的实现方式。ls > out.txt 是:fork 出子进程,子进程 close(1) 后 open("out.txt"),新 fd 是最小的空闲号 1,再 exec("ls")。ls 照常往 1 写,字节进了文件。a | b 是先 pipe() 得到一对 fd(读端、写端),两次 fork,分别用 dup2 把写端装到第一个子进程的 1、读端装到第二个子进程的 0。这就是 进程异常与信号 里说 fork 和 exec 之间那个窗口的用途。shell 里怎么用这些语法归 Shell 与命令行环境。
把 printf 'a\nb\nc\n' | wc -l 用系统调用亲手搭一遍:
int p[2]; pipe(p);
printf("pipe fds: read=%d write=%d\n", p[0], p[1]); fflush(stdout);
if (fork() == 0) { /* 左边 */
dup2(p[1], 1); close(p[0]); close(p[1]);
execlp("printf", "printf", "a\\nb\\nc\\n", NULL);
}
if (fork() == 0) { /* 右边 */
dup2(p[0], 0); close(p[0]); close(p[1]);
execlp("wc", "wc", "-l", NULL);
}
close(p[0]); close(p[1]); /* 父进程不关写端,wc 永远等不到 EOF */
wait(NULL); wait(NULL);pipe fds: read=3 write=4
3printf 和 wc 都不知道管道的存在,一个往 1 写、一个从 0 读。最容易写错的是那几个 close:管道的读端只有在所有写端 fd 都关闭后才会读到 EOF,任何一个进程(包括父进程)多留了一个写端,wc 就会永远阻塞。fd 的引用计数在打开文件表那一层,这里又一次用上了。
read 和 write 有一个经常被忽略的语义:它们可以少读少写(short count)。读到文件末尾、从管道或 socket 读时只到达了一部分、被信号打断,返回值都可能小于请求的字节数。健壮的代码要在循环里读写直到完成,CSAPP 的 RIO 包和 Python 的 file.read() 都替你做了这个循环。网络 IO 里这一点尤其常见,见 网络:TCP 与应用层。
父进程 fd = open("log", O_WRONLY) 后 fork,父子各自 write(fd, "x", 1) 一次(中间都没有 lseek)。文件里会是什么?
fork 复制的是 fd 表,表项指向同一个打开文件表项,偏移量在那里,先写的一方把偏移推进到 1,后写的一方接着写在 1。
各自从 0 写(覆盖)是"同一文件 open 两次"的行为。偏移只有一份,不会出现两套偏移。子进程继承的 fd 完全可用。
上面的管道程序里,如果父进程忘了 close(p[1]),会发生什么?
读端只有在所有指向写端的 fd 都关闭后才返回 EOF。printf 退出关闭了它那份,但父进程还开着一份,wc 以为还会有数据,于是一直等;父进程又在等 wc 退出,谁也不动。
父进程没有读管道,不会抢数据。一个打开文件表项被多个 fd 引用是正常的,正是引用计数的用途。内核只关闭退出进程自己的 fd,父进程那份不受影响。
每次 write 都要过一次内核:用户态缓冲
回到开头的 13 倍。os.write(fd, 1 字节) 每次都是一次真正的系统调用,进程异常与信号 里测过,在这台机器上从 Python 发起一次大约要一微秒,100 万次就是两秒。而 Python 的 open() 返回的文件对象自带一个 8 KiB 的用户态缓冲区:f.write 先往缓冲区里复制,攒满了才调一次 write,系统调用次数降到约 128 次。
def raw1():
fd = os.open("o.bin", os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
for i in range(len(data)): os.write(fd, data[i:i+1]) # 每字节一次系统调用
os.close(fd)
def buffered():
with open("o.bin", "wb") as f: # 用户态缓冲,攒满 8 KiB 才 write
for i in range(len(data)): f.write(data[i:i+1])C 的 stdio(printf、fwrite)是同一个设计:stdout 接终端时行缓冲,接文件或管道时全缓冲,stderr 不缓冲。代价有两个。一是缓冲区里的数据在进程内存里,进程崩溃(或被 SIGKILL)就丢了,进程异常与信号 里 fork 复制出两行 hello 也是它。二是同一个文件混用缓冲 IO 和直接 write,顺序会乱。
用户态缓冲只是第一层。write 系统调用返回时,数据也还没到磁盘。
页缓存:内核的那层缓冲
磁盘比内存慢几个数量级(SSD 随机读几十微秒,DRAM 几十纳秒),所以内核把文件内容缓存在物理内存里,这就是页缓存(page cache;xv6 里叫 buffer cache,以块为单位)。read 先查页缓存,命中就直接复制给你;write 把数据复制进页缓存、把那一页标脏,立刻返回,内核在后台过一段时间(Linux 默认几秒到 30 秒)再把脏页写回磁盘。
这解释了很多现象:同一个大文件第二次读快得多;机器”空闲内存”很少,大部分被 cache 占着,但需要时能立刻回收;write 很快,close 也很快,但断电后最近几秒写的东西可能没了。虚拟内存 里的 mmap 文件映射,映射的正是页缓存里的这些页。
想让数据真的落盘,要显式调用 fsync(fd):它阻塞到这个文件的脏页和元数据都写到设备上。测一下代价:
def fsync_each():
fd = os.open("o.bin", os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
for i in range(100): os.write(fd, b"y" * 4096); os.fsync(fd)
os.close(fd)
# 对照组:把 os.fsync(fd) 换成 fcntl.fcntl(fd, fcntl.F_FULLFSYNC)100 x (4 KiB write + fsync) 4.4 ms
100 x (4 KiB write + F_FULLFSYNC) 434.8 ms第一行快得可疑。这是 macOS 特有的行为:它的 fsync 只把数据交给磁盘控制器,不要求控制器清空自己的写缓存;要真正落到闪存上得用 fcntl(fd, F_FULLFSYNC),慢了近 100 倍,每次约 4 毫秒。Linux 的 fsync 默认会发缓存刷新命令,行为接近后者。这一层”设备自己的缓存”提醒我们:持久化保证要一层一层往下确认,每一层都可能撒谎。
页缓存还替顺序读做了一件事:预读(readahead)。内核发现一个文件被顺序读时,会在你请求之前就把后面的若干页读进缓存,等你真去读时已经命中了。所以顺序读大文件能跑满磁盘带宽,而在大文件里随机跳着读、或者读成千上万个小文件,预读帮不上忙,每次都要等一次设备延迟。这和 内存层次与缓存 里硬件预取喜欢顺序访问是同一个规律。
多个进程往同一个日志文件追加时还有一个陷阱:“先 lseek 到末尾再 write”是两步,两个进程可能都定位到同一个末尾,互相覆盖。用 O_APPEND 打开文件,内核会在每次 write 时原子地”移到末尾并写入”,这就是 shell 的 >> 和多数日志库的做法。不过这个原子性只对本地文件系统可靠,NFS 这类网络文件系统上并不保证。
fsync 这么贵,所以数据库和日志系统想尽办法减少它的次数:把很多事务的提交攒到一起做一次(group commit),只对追加写的日志文件 fsync,数据文件稍后批量刷,见 数据库系统。训练框架存 checkpoint 时”先写临时文件、fsync、再 rename 覆盖旧文件”,也是在用下面要讲的文件系统保证。
程序调用 write(fd, buf, 4096) 成功返回后,机器立刻断电。下面哪些说法是对的?
数据从用户缓冲到页缓存再到设备缓存,最后才到介质,write 只保证到第二层。macOS 的 fsync 不清空设备写缓存,要 F_FULLFSYNC。
write 的成功只代表内核接收了数据,持久化要 fsync。
内核怎么和磁盘说话
页缓存下面是设备驱动。CPU 和设备之间有三种配合方式,正好对应三种代价。
轮询(programmed IO):CPU 往设备的控制寄存器写命令,然后循环读状态寄存器直到设备说”好了”,再一个字一个字地把数据搬过来。简单,但 CPU 在等待和搬运时什么都干不了。
中断:CPU 发完命令就去干别的(调度别的进程),设备完成时发一个中断,就是 进程异常与信号 里那种异步异常。等待的时间被利用起来了,但搬数据仍然要 CPU 一个字一个字地做。
DMA(直接内存访问):CPU 告诉设备”把第 N 块读到物理地址 P”,设备自己把数据写进内存,全部完成后才发一次中断。CPU 只付发命令和处理中断的成本。现代磁盘、网卡、GPU 都用 DMA,这也是为什么 DMA 缓冲区要用物理上连续、且不会被换出的”锁定”内存(pinned memory)。PyTorch DataLoader 的 pin_memory=True 就是为了让主机到 GPU 的拷贝能直接走 DMA,见 GPU 与 Kernel。
xv6 的磁盘驱动(virtio_disk.c)把这条路走得很完整:bread() 在 buffer cache 里找不到某块时,驱动填好一个描述符告诉虚拟磁盘”读这一块到这个缓冲区”,然后调用 sleep 让当前进程睡下;磁盘完成后触发中断,中断处理程序 virtio_disk_intr() 找到对应的缓冲区、wakeup 那个进程。这里把前几篇的机制全串起来了:系统调用进内核、发 DMA 命令、睡眠让出 CPU(并发与同步 的 sleep/wakeup)、中断唤醒、上下文切换回来、返回用户态。
DMA 也带来一个和 并发与同步 里内存可见性类似的问题:设备直接写内存,CPU cache 里可能还留着那块内存的旧内容。x86 的 DMA 通常由硬件保持 cache 一致,有些 ARM 平台则要驱动在 DMA 前后显式刷或作废 cache 行。
buffer cache 本身还承担了另一个职责:保证同一个磁盘块在内存里只有一份副本,并且同一时刻只有一个内核线程在改它。xv6 给每个缓冲区一把睡眠锁,bread 返回时已经锁住,brelse 才释放。如果允许两份副本各自被修改,写回时必有一方的修改丢失,这正是 并发与同步 开头那个丢更新问题在磁盘块上的翻版。
磁盘上的布局:inode 与目录
到了磁盘这一层,文件系统要回答:一个文件的字节放在哪些块里?文件名怎么找到文件?
xv6 把磁盘切成 1 KiB 的块,从前往后依次是:引导块、超级块(记录整个文件系统的大小和各区域位置)、日志区、inode 区、位图(每块一位,标记是否已用)、数据块。
每个文件对应一个 inode,xv6 的 struct dinode 只有 64 字节:
type:普通文件、目录、设备。nlink:有多少个目录项指向它。size:文件字节数。addrs[13]:12 个直接块号,加 1 个间接块号。间接块本身是一个数据块,里面存 256 个块号。
所以 xv6 的单个文件最大是 (12 + 256) × 1 KiB = 268 KiB。ext4 用更多级的间接(或 extent,记录”从第几块开始连续多少块”)把上限推到 TB 级,思路与 虚拟内存 的多级页表一样:小文件只用直接块,查一次就到;大文件才付多级查找的代价。
注意 inode 里没有文件名。文件名在目录里:目录也是一种文件,它的内容是一串 (名字, inode 号) 的目录项。打开 /a/b/c 的过程叫路径解析:从根目录的 inode(xv6 里固定是 1 号)开始,在它的数据里找 a 得到 inode 号,读那个 inode,在它的数据里找 b……一级一级走下去。这就是深层路径、或者一个目录下有几十万个文件时 open 会变慢的原因,内核为此缓存了目录项(Linux 的 dcache)。
目录项和 inode 本身也走页缓存(或 xv6 的 buffer cache),所以 ls 一个刚访问过的目录几乎不碰磁盘。
名字和文件分离带来了硬链接:多个目录项可以指向同一个 inode。nlink 记录有多少个名字。unlink 只是删除一个目录项、把 nlink 减一;只有 nlink 为 0 并且没有进程还开着它,inode 和数据块才真正释放:
link("f.txt", "g.txt"); fstat(r, &st); printf("after link: nlink=%d\n", st.st_nlink);
unlink("f.txt"); unlink("g.txt"); fstat(r, &st);
printf("after unlink both: nlink=%d, still readable: ", st.st_nlink);
lseek(r, 0, SEEK_SET); n = read(r, buf, 4); buf[n]=0; printf("%s\n", buf);size=8 nlink=1
after link: nlink=2
after unlink both: nlink=0, still readable: BBAA两个名字都删了,文件仍然能读,因为 fd r 还引用着它的 inode。“删了一个大日志文件,磁盘空间却没释放”就是这个原因:某个进程还开着它。符号链接则不同,它是一个内容为路径字符串的小文件,解析时把那个路径再走一遍,所以可以跨文件系统,也可以指向不存在的目标。
rename 在同一文件系统内是改目录项,不动数据,并且是原子的:要么看到旧文件,要么看到新文件。这就是”写临时文件、fsync、rename 覆盖”能安全替换文件的依据。
一个进程正在往 train.log 追加写,你执行 rm train.log,df 显示磁盘空间没有变化。最合理的解释是?
rm 调用的就是 unlink,删除名字并把 nlink 减一。文件真正被回收的条件是"没有名字并且没有打开的引用",写日志的进程仍持有 fd,所以空间还在,它还能继续往里写。
命令行 rm 不经过回收站。脏页与空间释放无关,释放的条件是引用计数。rm 就是 unlink。
崩溃一致性:日志
现在到了最难的问题:一个操作往往要改好几个磁盘块。以”往文件末尾追加一块”为例,至少要写三处:位图(标记新块已用)、inode(addrs 加上新块号、size 增大)、数据块本身。磁盘一次只能保证单个扇区写入的原子性,如果在这三次写之间断电:
- 只写了位图:块被标成已用但没人引用,空间泄漏。
- 只写了 inode:inode 指向一个位图里仍标为空闲的块,之后它可能被分给另一个文件,两个文件共用一块,数据互相覆盖。
- 只写了数据块:没有坏处,只是这次写丢了。
最朴素的修法是开机时扫描全盘修复不一致(老的 fsck),在 TB 级磁盘上要几个小时。现代文件系统几乎都用日志(journaling,也叫 write-ahead logging):
- 先把这次操作要改的所有块完整地写进磁盘上专门的日志区。
- 写一个提交记录(xv6 是日志头里的块数 n),这一次单扇区写是原子的,它就是提交点。
- 再把日志里的块逐个写到它们真正的位置(install)。
- 清除日志头。
崩溃后重启时只需看日志头:提交记录存在,就把日志里的块重放一遍(重放多次结果一样,所以重放到一半又崩溃也没关系);不存在,说明操作没提交,丢弃日志,磁盘上的正式位置一个块都没被动过。于是一个多块操作要么全部生效、要么完全没发生。
xv6 的接口是 begin_op() / end_op():一个系统调用里所有的块修改都包在这两者之间,log_write() 不立刻写盘,只在内存里记下”这一块属于本次事务”;最后一个未完成的操作 end_op 时统一 commit()。这就是 group commit:多个并发系统调用的修改合并成一次日志提交,摊薄写日志的成本。日志区大小有限(xv6 是 30 块),所以 write 大文件会被切成多个事务。
日志把每个块写了两遍。为了省一半,ext4 默认的 ordered 模式只给元数据(inode、位图、目录)记日志,数据块直接写到最终位置,但保证数据先于引用它的元数据落盘。代价是数据本身没有原子性:崩溃后文件结构一定是一致的,但最后一次写的内容可能是新旧混合的。所以应用层还是要自己 fsync 和 rename。APFS 和 ZFS、btrfs 走的是另一条路:写时复制,从不原地覆盖,新数据写到新位置,最后原子地切换根指针,和 虚拟内存 的 COW 是同一个思想。
数据库的 WAL 是同一个思想的另一个实例,它要保证的是事务而不是文件系统结构,见 数据库系统。
日志式文件系统写入一个多块事务时,在"提交记录已落盘、但还没把日志块写到正式位置"这一刻断电。重启后会怎样?
提交记录是原子写入的分界线:它落盘之后,日志里已经有完整的新内容,恢复时重放即可,重放是幂等的。
丢失只发生在提交记录写入之前。日志存在的意义正是避免全盘扫描。"只有元数据生效"描述的是 ordered 模式下数据不进日志的情形,而这里问的是日志里的块,提交后全部会被重放。
把几层连起来看
从 Python 的 f.write(b"x") 到闪存颗粒,字节要经过:
| 层 | 在哪 | 怎么推到下一层 | 断电/崩溃时 |
|---|---|---|---|
| 用户态缓冲 | 进程内存 | flush(),或缓冲区满 | 进程崩溃就丢 |
| 页缓存 | 内核内存 | 后台写回,或 fsync | 断电丢,进程崩溃不丢 |
| 文件系统日志 | 磁盘日志区 | 提交后 install | 已提交的重启后重放 |
| 设备写缓存 | 磁盘控制器 | 缓存刷新命令(F_FULLFSYNC) | 视设备是否有掉电保护 |
每一层都用”先缓存、稍后批量写”换速度,每一层也都要一个显式的操作来换取可靠性。AI 训练里最常见的 IO 问题都能在这张表里定位:DataLoader 读小文件慢,是每个文件一次 open 加路径解析加元数据读取,解法是打包成大的分片文件顺序读;checkpoint 写完任务就被杀导致文件损坏,是没有 fsync 加 rename;同一份数据第二个 epoch 快得多,是页缓存命中。
反过来,想绕过某一层也有办法:O_DIRECT(Linux)或 F_NOCACHE(macOS)让读写跳过页缓存、直接在用户缓冲区和设备之间 DMA,数据库自己管理缓存时会这么做,代价是要按设备块大小对齐、失去预读和写合并。绝大多数程序不该碰它,先确认瓶颈真在页缓存这一层再说。
记住这三件事
- 文件描述符是一个下标,背后是 fd 表、打开文件表(偏移量在这)、inode 三层;dup 和 fork 共享偏移,所以重定向和管道只需在 fork 与 exec 之间换 fd。
write返回只说明数据进了页缓存,持久化要fsync,而在 macOS 上还要F_FULLFSYNC;用户态缓冲减少系统调用次数,但进程崩溃会丢。- inode 存数据位置、目录存名字;多块更新靠日志做到”要么全做要么全不做”,提交记录的那次原子写就是分界线。
延伸阅读
- Bryant & O’Hallaron, Computer Systems: A Programmer’s Perspective, 3rd ed., 第 10 章 System-Level I/O
- Russ Cox, Frans Kaashoek, Robert Morris, xv6 book 第 8 章 File system;MIT 6.1810 的 fs lab
- Remzi & Andrea Arpaci-Dusseau, OSTEP, Persistence 部分(File System Implementation、Crash Consistency: FSCK and Journaling)
- Pillai et al., All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications, OSDI 2014
- Apple Developer Documentation:
fsync与F_FULLFSYNC的说明(man fsync、man fcntl)