Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.12 VFS 与文件系统

本章回答

打开 /etc/hosts 这个动作,内核走了哪些对象、哪些锁?为什么 ls -l /proc 里同一路径每次都能立刻找到,靠的是哪层缓存?write() 返回成功之后,数据在哪,什么时候才真的上磁盘?ext4 的日志(journal)到底记了什么,为什么它能保证掉电后文件系统还能挂上?

读完你会

画出 super_block / inode / dentry / file 四对象与五个 ops 表的关系;说清路径解析的 RCU-walk 与 ref-walk 两条路径;解释 page cache 与 writeback 的完整链路;用 debugfs/filefrag/strace 验证 ext4 的 extent 与日志行为;读懂 /proc/self/mountinfo。

3.11 讲完了锁。现在把这些工具放进一个真实子系统:文件系统。VFS(Virtual File System)是内核里最成功的抽象之一——它让 read() 不必知道背后是 ext4、XFS、NFS 还是 tmpfs,也让「一切皆文件」真的成立。

VFS 的核心是四类对象 + 五张方法表。所有具体文件系统(ext4、xfs、btrfs、proc、sysfs、overlayfs)都实现同一组接口,VFS 只负责「按路径找到对象、调用对应方法」。理解了这层,你就能解释:为什么 open 要经过 dcache、为什么 write 之后数据在 page cache、为什么 fsync 比 write 慢两个数量级。

本章路线:四对象模型 → 路径解析与 dcache → open 的完整路径 → page cache 与 writeback → ext4 布局与日志 → 挂载与命名空间 → 观测实验。母本:OSTEP 第 39–44 章、奔跑吧 2.7、Kerrisk 第 14–18 章,李治军第 8 章的「文件视图」作为对照。

3.12.1 四类对象:超块、inode、dentry、file

                    struct super_block(一个挂载的文件系统实例)
                    ├── s_type ──────► struct file_system_type(ext4/xfs/…)
                    ├── s_root ──────► dentry "/"
                    ├── s_inodes ────► 所有 inode 的链表
                    ├── s_op ────────► super_operations(alloc_inode/write_inode/sync_fs)
                    └── s_fs_info ───► 具体文件系统的私有数据(ext4_sb_info)
                              │
        ┌─────────────────────┴─────────────────────┐
        ▼                                           ▼
  struct inode(一个文件/目录的元数据)        struct dentry(一个目录项/名字)
  ├── i_ino / i_mode / i_size / i_nlink      ├── d_name(qstr:名字+长度+hash)
  ├── i_mapping ──► address_space(页缓存)   ├── d_inode ──► inode(可能为 NULL)
  ├── i_op ───────► inode_operations          ├── d_parent ──► 父 dentry
  ├── i_fop ──────► file_operations           ├── d_op ─────► dentry_operations
  ├── i_sb ───────► super_block               └── d_subdirs / d_child(目录树)
  └── i_count / i_state
                              ▲
                              │ f_inode / f_path.dentry
                    struct file(一次打开会话)
                    ├── f_op ──────► file_operations
                    ├── f_pos / f_flags / f_mode
                    ├── f_count(引用计数)
                    └── f_path ────► {mnt, dentry}

四类对象的分工:

对象生命周期粒度关键方法表
super_block一次 mount 到 umount一个文件系统实例super_operations
inode文件存在期间(可缓存)一个文件/目录inode_operations、file_operations
dentry路径查找时创建,可回收一个名字(路径分量)dentry_operations
file一次 open 到 close一次打开会话file_operations

五张方法表(外加 address_space_operations):

/* include/linux/fs.h —— 6.8 节选,已简化 */
struct file_operations {
	struct module *owner;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
	ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
	int (*mmap) (struct file *, struct vm_area_struct *);
	int (*open) (struct inode *, struct file *);
	int (*release) (struct inode *, struct file *);
	int (*fsync) (struct file *, loff_t, loff_t, int datasync);
	__poll_t (*poll) (struct file *, struct poll_table_struct *);
	long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
	...
};

inode_operations 管「名字与元数据」:lookup、create、link、unlink、rename、mkdir、permission、getattr、setattr。file_operations 管「打开之后的数据读写」。super_operations 管「整个文件系统」:alloc_inode、destroy_inode、write_inode、sync_fs、statfs。address_space_operations 管「页缓存与磁盘的交换」:read_folio、writepages、dirty_folio、write_begin、write_end。

定义 3.12.1 · inode 与 dentry 的分工

inode 是「文件本身」:类型、权限、大小、数据块位置、时间戳。一个文件只有一个 inode,即使它有多个硬链接。dentry 是「一个名字到 inode 的绑定」:/etc/hosts 里的 hosts 是一个 dentry,它的 d_inode 指向该文件的 inode。硬链接就是「多个 dentry 指向同一个 inode」;dentry 可以随时从内存回收(它只是缓存),inode 在文件被删除且无人打开后才释放。

3.12.2 路径解析:RCU-walk 与 dcache

open("/etc/hosts") 的第一件事是把路径拆成一个个分量(etc、hosts),逐个在 dcache 里查。路径解析的入口是 fs/namei.c: path_openat():

/* fs/namei.c: path_openat() —— 6.8 节选,已简化 */
static struct file *path_openat(struct nameidata *nd,
				const struct open_flags *op, unsigned flags)
{
	struct file *file;

	file = alloc_empty_file(op->open_flag, current_cred());
	...
	if (unlikely(file->f_flags & __O_TMPFILE)) {
		...
	} else {
		file = do_last(nd, file, op);
	}
	...
}

do_last() 之前,path_init() 设定起点(绝对路径从 nd->root 或 current->fs->root 开始),link_path_walk() 逐分量走:

  • RCU-walk(LOOKUP_RCU):不拿 rename_lock/mount_lock,用 rcu_dereference() 读 dentry,遇到需要睡眠或不确定的情况就退回 ref-walk;
  • ref-walk:拿引用计数、可能拿 i_rwsem,慢但能做任何事。

walk_component() 先 lookup_fast():

/* fs/namei.c: lookup_fast() —— 6.8 节选,已简化 */
static struct dentry *lookup_fast(struct nameidata *nd)
{
	struct dentry *dentry, *parent = nd->path.dentry;

	if (nd->flags & LOOKUP_RCU) {
		/* RCU 模式:无锁查 dcache,返回前要确认 seq 没变 */
		dentry = __d_lookup_rcu(parent, &nd->last, &nd->seq);
		if (!dentry)
			return ERR_PTR(-ECHILD);   /* 退回 ref-walk */
		...
	} else {
		dentry = d_lookup(parent, &nd->last);
		if (!dentry)
			return ERR_PTR(-ENOENT);
	}
	return dentry;
}

dcache 的查找是「哈希表 + 每个 dentry 一个 hash 链」:d_hash()(文件系统可自定义)把 (parent, name) 映射到桶,d_compare() 比较名字(大小写不敏感的文件系统会覆盖它)。命中后还要 d_revalidate()——网络文件系统(NFS)用它确认「缓存的 dentry 在服务器上还有效」。

缓存未命中动作
lookup_fast() 返回 -ECHILD退回 ref-walk,拿锁重试
ref-walk 也没命中lookup_slow() → inode->i_op->lookup(),由具体文件系统读目录
找到的是符号链接step_into() → pick_link(),展开并继续(有 MAXSYMLINKS 限制)
找到的是挂载点handle_mounts() 跟进到挂载的根
目录项不存在且要创建lookup_open() → i_op->create()

/proc/sys/fs/dentry-state 给出 dcache 的状态:

1234567 234567 45 0 0 0
│       │      │  │ │ └─ want_pages(回收器想回收的页数)
│       │      │  │ └─── (保留)
│       │      │  └───── age_limit(秒)
│       │      └──────── nr_unused(未使用的 dentry)
│       └─────────────── nr_dentry(总数)

dentries 是纯缓存,内存紧张时会被 dentry_shrinker 回收;nr_unused 高不是问题,nr_dentry 持续增长且 nr_unused 低才说明有泄漏(如某个文件系统不释放 dentry)。

路径解析的五个误解
  1. 「open 会立刻读文件内容」:open 只解析路径、分配 struct file、调 ->open(),不读数据。数据在第一次 read 时才进 page cache。
  2. 「dcache 命中就不用碰磁盘」:命中只保证「名字到 inode 的映射」在内存里,inode 的元数据可能还要读(iget 未命中时)。
  3. 「路径越深越慢」:每层一次 dcache 查找,命中是 O(1) 哈希;慢在未命中的目录读取与符号链接展开。
  4. 「.. 就是父目录」:.. 经过挂载点时会跨过挂载边界(follow_dotdot),在 chroot/namespace 里还受 nd->root 限制。
  5. 「RCU-walk 一定更快」:它在「热 dcache + 无符号链接 + 无挂载点」时快;一旦退回 ref-walk,反而多花一次重试。strace -c 里 openat 的耗时分布能看出。

3.12.3 open 的完整路径:从 syscall 到 file

把 3.12.2 的解析接上 open 的其余部分:

sys_openat(AT_FDCWD, "/etc/hosts", O_RDONLY)
  → do_sys_openat2()
      → getname_flags()              把用户态路径拷进内核(PATH_MAX 4096)
      → do_filp_open()
          → path_openat()
              → path_init() / link_path_walk()   解析到最后一个分量
              → do_last() → lookup_open()        查/建 dentry
                  → vfs_open()
                      → do_dentry_open()
                          → file->f_op = inode->i_fop
                          → f_op->open(inode, file)   ← ext4_file_open
      → fd_install(fd, file)          把 file 挂到 fd 表

struct file 是「一次打开会话」的状态:

/* include/linux/fs.h —— 6.8 节选,已简化 */
struct file {
	union {
		struct llist_node	fu_llist;
		struct rcu_head 	fu_rcuhead;
	} f_u;
	struct path		f_path;		/* {mnt, dentry} */
	struct inode		*f_inode;
	const struct file_operations	*f_op;
	spinlock_t		f_lock;
	atomic_long_t		f_count;
	unsigned int		f_flags;	/* O_RDONLY/O_NONBLOCK/... */
	fmode_t			f_mode;		/* FMODE_READ/WRITE/EXEC/PATH */
	struct mutex		f_pos_lock;
	loff_t			f_pos;
	...
};

f_mode 与 f_flags 容易混:f_mode 是「内核解析后的访问模式」(FMODE_READ、FMODE_WRITE、FMODE_PATH 表示 O_PATH),f_flags 是「用户传的原始标志」(含 O_CLOEXEC、O_NONBLOCK)。O_PATH 打开的文件不能读写,只能用于 fstat、openat 的 dirfd、*at 系列——它的 f_op 是空的 empty_fops,这是常见的「read 返回 EBADF」的原因。

fd_install() 把 struct file 写进 files_struct->fdtable。close() 走 filp_close() → fput();fput() 不总是立刻释放——如果当前在中断上下文或持有锁,会延迟到 task_work 里执行 ____fput(),避免在原子上下文做可能睡眠的 ->release()。

3.12.4 page cache 与 writeback:写成功不等于落盘

文件 I/O 的默认路径是 buffered I/O:读写都经过 page cache。address_space 是「一个 inode 的页缓存」:

/* include/linux/fs.h —— 6.8 节选 */
struct address_space {
	struct inode		*host;
	struct xarray		i_pages;	/* 页缓存:文件内页偏移 → folio */
	struct rb_root_cached	i_mmap;		/* 映射该文件的 VMA(RMAP 用) */
	unsigned long		nrpages;
	const struct address_space_operations *a_ops;
	...
};

写路径:

/* mm/filemap.c: generic_perform_write() —— 6.8 节选,已简化 */
ssize_t generic_perform_write(struct kiocb *iocb, struct iov_iter *i)
{
	struct file *file = iocb->ki_filp;
	struct address_space *mapping = file->f_mapping;
	ssize_t written = 0;

	do {
		struct folio *folio;
		size_t offset, bytes;
		...
		status = a_ops->write_begin(file, mapping, pos, bytes, &folio, &fsdata);
		if (unlikely(status < 0))
			break;
		offset = offset_in_folio(folio, pos);
		copied = copy_page_from_iter_atomic(&folio->page, offset, bytes, i);
		status = a_ops->write_end(file, mapping, pos, bytes, copied, folio, fsdata);
		...
		balance_dirty_pages_ratelimited(mapping);
	} while (iov_iter_count(i));

	return written ? written : status;
}

关键点:

  1. write() 只把数据拷进 page cache 并标脏,不碰磁盘。返回成功只代表「内核接下了」。
  2. balance_dirty_pages_ratelimited() 在脏页超过阈值时主动限流(让写者睡眠),防止脏页把内存吃光。阈值由 vm.dirty_background_ratio(默认 10%)与 vm.dirty_ratio(默认 20%)控制。
  3. 写回由内核线程 kworker/wb_workfn 异步做:writeback 每隔 vm.dirty_writeback_centisecs(默认 5 秒)检查一次,超过 vm.dirty_expire_centisecs(默认 30 秒)的脏页被写回;脏页比例超过 dirty_background_ratio 时也会被提前写回。

fsync 才是「落盘」:

fsync(fd)
  → vfs_fsync_range(file, start, end, datasync=0)
      → file->f_op->fsync()            ← ext4_sync_file
          → filemap_write_and_wait_range()   把该文件的脏页写回
          → jbd2_complete_transaction()      等日志事务提交
          → blkdev_issue_flush()             发 cache flush 给设备

三件事缺一不可:数据页写回、元数据日志提交、设备缓存刷出。很多「fsync 之后掉电还是丢数据」的 bug 出在第三件——磁盘/SSD 的写缓存没刷,或虚拟化层吞掉了 flush。

调用保证
write()数据进了 page cache,进程崩溃不丢,掉电可能丢
fdatasync()数据 + 必要的元数据(大小)落盘,不保证 mtime 等
fsync()数据 + 全部元数据落盘
sync()所有文件系统刷盘(全局,很慢)
syncfs(fd)该 fd 所在文件系统刷盘
O_SYNC每次 write 都等价 fsync(性能极差)
sync_file_range()只提示回写某段,不保证落盘
page cache 的五个误解
  1. 「write 返回就安全了」:只保证进了 page cache。进程崩溃不丢(内核还活着),机器掉电会丢。
  2. 「fsync 一定比 fdatasync 慢」:通常是的(多刷元数据),但某些文件系统上差别很小;关键看是否需要 mtime/size 的持久化。
  3. 「O_DIRECT 绕过 page cache 就一定快」:它绕过缓存,但要求对齐(地址、偏移、长度按块大小),且失去预读与合并,随机小 I/O 反而更慢。
  4. 「读文件一定经过 page cache」:O_DIRECT 读绕过;mmap 读也走 page cache(通过缺页),但 MAP_POPULATE 会预读。
  5. 「删除文件就释放空间」:还有进程持有 fd 时,inode 与数据块不释放,df 显示空间没变——lsof +L1 能看到 (deleted)。

3.12.5 ext4:块组、extent 与日志

ext4 把磁盘分成块组(block group),每组有超级块副本、组描述符、inode 表、数据块位图、inode 位图:

ext4 磁盘布局(4 KiB 块)
┌────────────┬────────────┬──────────────────────────────────────────────┐
│ boot (1 KiB)│ superblock │ 块组 0:GDT | 块位图 | inode 位图 | inode 表 | 数据块
│            │ @1024      │ 块组 1:GDT | 块位图 | inode 位图 | inode 表 | 数据块
│            │ magic EF53 │ ...                                          │
└────────────┴────────────┴──────────────────────────────────────────────┘
inode(默认 256 B):
  i_mode i_uid i_size i_atime/i_ctime/i_mtime i_links_count i_blocks
  i_block[15] ──► extent 树根(不再是 12 直接块 + 3 间接块)
extent 树:
  ext4_extent_header { magic=0xF30A, entries, max, depth, generation }  12 B
  ext4_extent       { ee_block, ee_len, ee_start_hi, ee_start_lo }       12 B

ext4 的 inode 用 extent 树替代了 ext2/ext3 的「12 个直接块 + 一级/二级/三级间接块」。一个 extent 描述「一段连续的物理块」,ee_len 最多 32768 块(128 MiB);文件很大时 extent 树加深(eh_depth > 0),中间节点是 ext4_extent_idx。

debugfs 是看 ext4 内部结构的利器:

# 看超级块摘要
dumpe2fs -h /dev/vda1 | head -30

# 看某个文件的 inode 与 extent
debugfs -R 'stat /etc/hosts' /dev/vda1
debugfs -R 'dump_extents /etc/hosts' /dev/vda1

# 看块组信息
dumpe2fs /dev/vda1 | sed -n '/Group 0/,/Group 1/p'

日志(journal) 是 ext4 保证崩溃一致性的核心。默认 data=ordered 模式:

  1. 事务开始:jbd2_journal_start() 拿一个 handle_t;
  2. 修改元数据前:jbd2_journal_get_write_access() 告诉 JBD2「这个块要改」;
  3. 改完元数据:jbd2_journal_dirty_metadata() 把块标记为脏(JBD2 会把它写进日志);
  4. 事务提交:jbd2_journal_stop(),JBD2 把日志块写盘、发 flush、写 commit record;
  5. 检查点:日志空间回收时,元数据块被写到最终位置。

data=ordered 的「ordered」指:数据块先写盘,元数据才提交。这样崩溃后即使元数据指向新块,块里也已经是完整数据(不会出现「文件大小变了但内容是旧的/垃圾」)。另外两种模式:data=journal(数据也进日志,最安全最慢)、data=writeback(只保证元数据一致,数据可能旧)。

# 看当前挂载的日志模式
findmnt -no OPTIONS / | tr ',' '\n' | grep data=
# 看日志大小与事务
dumpe2fs -h /dev/vda1 | grep -i journal
为什么 ext4 不需要 fsck 也能挂上

因为日志里记录了「未完成的事务」。挂载时 jbd2_journal_load() 重放已提交但未检查点的事务,丢弃未提交的,文件系统就回到一致状态。fsck 只在日志损坏或元数据仍有矛盾时才需要。这也是「日志文件系统」与「fsck 文件系统」的本质区别:前者保证元数据一致性,后者负责修复。

日志保证「崩溃后元数据一致」,但不保证「读到的块没被静默改坏」。位翻转、掉电半写、固件或链路错误都可能让数据变了而校验没发现。ext4 从 e2fsprogs 1.43 起默认开启 metadata_csum,用 CRC32c 校验超级块、组描述符、inode、extent 树、目录块和位图;Btrfs/ZFS 进一步给数据块算校验和。后台定期重读全盘、用校验和发现「静默坏块」的过程叫擦净(scrubbing):btrfs scrub/zpool scrub 会限速扫描,发现坏块时用副本或 RAID 重建,把「读到时才报错」提前到「定期发现」,代价是占用带宽与 I/O。更强的端到端保护是 T10-PI(DIF):校验和随数据穿过 HBA、RAID 卡到达磁盘,链路或控制器改坏数据也能发现。校验和只负责「发现」,修复仍要靠副本、RAID 或备份。

写错位置与丢失写:校验和之外的两类故障

misdirected write(写错位置):文件系统/驱动把块写到错误的 LBA,旧数据被覆盖。校验和能发现「读到的内容和校验和不符」,但如果文件系统把校验和也一起写到了错误位置,单靠块内校验和不够;需要父指针(backpointer,磁盘上「块→属主」的指针)或上层校验(如 ZFS 的 block pointer 树)确认「这个块确实属于这个文件」。Linux 的 RMAP 是内存里的页反向映射(谁映射了这页),不是磁盘上的 backpointer,两者不要混用。

lost write(丢失写):设备返回「写成功」但数据没落盘,或者旧数据覆盖了新数据。flush/FUA 只保证「调用返回前数据到介质」,不能检测「设备静默丢弃」。对策是写后读校验、REQ_FUA + 校验和、RAID 卡的写缓存电池/超级电容,以及应用层 fsync 后重读关键块。

校验和的成本:CRC32c 有 CPU 指令(SSE4.2 crc32),吞吐很高;端到端校验(T10-PI)要额外 8 字节/扇区,且 HBA/RAID 卡要支持。不是所有场景都值得:临时文件、可重算的缓存可以不校验;元数据、备份、归档必须校验。

故障现象发现手段修复
静默坏块读到旧/坏数据,无报错块校验和、scrub副本/RAID/备份
写错位置别的文件被覆盖父指针、上层校验、fsck从备份恢复
丢失写返回成功但数据旧写后读、fsync+重读重写、换设备

3.12.6 挂载、命名空间与文件系统类型

mount() 把一个文件系统实例挂到某个目录上。内核里的对象是 struct mount(挂载点)与 struct vfsmount(挂载的文件系统):

/* fs/mount.h —— 6.8 节选,已简化 */
struct mount {
	struct hlist_node mnt_hash;
	struct mount *mnt_parent;	/* 父挂载点 */
	struct dentry *mnt_mountpoint;	/* 挂在父的哪个 dentry 上 */
	struct vfsmount mnt;		/* 指向 super_block 与根 dentry */
	struct rcu_head mnt_rcu;
	...
};

每个进程的 fs_struct 里有 root 与 pwd,nsproxy->mnt_ns 指向挂载命名空间。挂载命名空间让容器拥有独立的挂载树:unshare(CLONE_NEWNS) 之后 mount 只影响自己。

命令/文件作用
mount -t ext4 /dev/vda1 /mnt挂载
mount --bind /a /b绑定挂载(同一 super_block,不同挂载点)
mount --move /a /b移动挂载点
findmnt / /proc/self/mountinfo查看挂载树(含 peer group、propagation)
/proc/filesystems已注册的文件系统类型
statfs/statvfs空间统计
unshare -m + pivot_root容器根切换

struct file_system_type 是「文件系统驱动」的注册项:

/* include/linux/fs.h —— 6.8 节选 */
struct file_system_type {
	const char *name;
	int fs_flags;
	int (*init_fs_context)(struct fs_context *fc);
	const struct fs_parameter_spec *parameters;
	struct dentry *(*mount) (struct file_system_type *, int,
				 const char *, void *);
	void (*kill_sb) (struct super_block *);
	struct module *owner;
	struct file_system_type *next;
	struct list_head fs_supers;
	...
};

register_filesystem() 把它挂进全局链表,mount 时按 name 查找。fs_context(5.2+)是新式挂载 API,把「解析参数」与「创建 super_block」分开,支持 fsconfig()/fsmount() 系统调用——容器运行时用它做无特权挂载。

3.12.7 实验:看 VFS 对象与 fsync 的代价

模块用 kern_path() 解析一个路径,打印 inode 与 super_block 的关键字段,验证「VFS 四对象」的存在:

// vfs_demo.c — 打印一个路径对应的 VFS 对象(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/namei.h>
#include <linux/fs.h>
#include <linux/dcache.h>

static char *path = "/etc/hosts";
module_param(path, charp, 0444);
MODULE_PARM_DESC(path, "path to inspect");

static int __init vfs_demo_init(void)
{
	struct path p;
	struct inode *inode;
	struct dentry *dentry;
	int err;

	err = kern_path(path, LOOKUP_FOLLOW, &p);
	if (err) {
		pr_err("vfs_demo: kern_path(%s) failed: %d\n", path, err);
		return 0;
	}

	dentry = p.dentry;
	inode = d_inode(dentry);
	pr_info("vfs_demo: path=%s mount=%s\n", path, p.mnt->mnt_sb->s_type->name);
	pr_info("vfs_demo: dentry name=%s inode=%lu mode=%#o size=%lld nlink=%u\n",
		dentry->d_name.name, inode->i_ino, inode->i_mode,
		(long long)i_size_read(inode), inode->i_nlink);
	pr_info("vfs_demo: superblock blocksize=%u s_flags=%#lx\n",
		p.mnt->mnt_sb->s_blocksize, p.mnt->mnt_sb->s_flags);
	path_put(&p);
	return 0;
}

static void __exit vfs_demo_exit(void) { }

module_init(vfs_demo_init);
module_exit(vfs_demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("inspect VFS objects for a path");

kern_path() 内部就是 3.12.2 的路径解析(filename_lookup()),path_put() 释放引用。这个模块只读,安全。

用户态程序:比较 write、fdatasync、fsync 的耗时,验证「写成功不等于落盘」:

/* sync_cost.c — write/fdatasync/fsync 的耗时对比
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o sync_cost sync_cost.c
 * 运行: ./sync_cost /tmp/testfile 200
 */
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>

static double ms_since(struct timespec *t0)
{
	struct timespec t1;
	clock_gettime(CLOCK_MONOTONIC, &t1);
	return (t1.tv_sec - t0->tv_sec) * 1000.0 +
	       (t1.tv_nsec - t0->tv_nsec) / 1e6;
}

int main(int argc, char *argv[])
{
	const char *path = argc > 1 ? argv[1] : "/tmp/testfile";
	int n = argc > 2 ? atoi(argv[2]) : 100;
	char buf[4096];
	struct timespec t0;
	double w = 0, d = 0, f = 0;

	memset(buf, 0x5a, sizeof buf);
	int fd = open(path, O_CREAT | O_TRUNC | O_WRONLY, 0644);
	if (fd == -1) { perror("open"); return 1; }

	for (int i = 0; i < n; i++) {
		clock_gettime(CLOCK_MONOTONIC, &t0);
		if (write(fd, buf, sizeof buf) != sizeof buf) { perror("write"); break; }
		w += ms_since(&t0);

		clock_gettime(CLOCK_MONOTONIC, &t0);
		if (fdatasync(fd) == -1) { perror("fdatasync"); break; }
		d += ms_since(&t0);

		clock_gettime(CLOCK_MONOTONIC, &t0);
		if (fsync(fd) == -1) { perror("fsync"); break; }
		f += ms_since(&t0);
	}
	printf("ops=%d  write=%.3f ms  fdatasync=%.3f ms  fsync=%.3f ms\n",
	       n, w / n, d / n, f / n);
	close(fd);
	return 0;
}

典型结果(QEMU + virtio-blk):write 约 0.005 ms(只进 page cache),fdatasync 约 0.5–2 ms(等设备确认),fsync 略高。两者相差两个数量级——这就是「批量写 + 一次 fsync」比「每条记录 fsync」快的原因。

strace 验证路径:

strace -e trace=openat,write,fdatasync,fsync,rename ./sync_cost /tmp/t 10 2>&1 | tail -20

openat 的参数里有 O_CLOEXEC(如果程序加了),write 返回 4096,fdatasync/fsync 返回 0。

3.12.8 网络文件系统:NFS、AFS 与 VFS 的边界

VFS 把「文件系统」抽象成 super_block/inode/dentry/file 四类对象与两组操作表。NFS 客户端正是实现这组操作表的一个「远端后端」:open() 仍然走 VFS,read() 最终变成 RPC。理解这一点,就能解释为什么 NFS 上 fsync、rename、文件锁的语义与本地盘不同。

3.12.8.1 NFS:无状态 v3 与有状态 v4

项NFSv3NFSv4
状态无状态(服务器不记客户端打开的文件)有状态(open/close、锁、委托)
传输UDP/TCP仅 TCP
文件句柄file handle + fh复合操作 + current filehandle
锁单独 NLM 协议内建
挂载mountd内建
身份AUTH_SYS(UID/GID 明文)RPCSEC_GSS/Kerberos

一次 read() 的路径:

vfs_read → nfs_file_read → nfs_readpage → rpc_call
  → XDR 编码 READ 请求(filehandle, offset, count)
  → 服务器 VFS → 本地文件系统
  → 返回数据 + 属性(attr)

客户端缓存三样东西:页缓存(数据)、属性缓存(inode 的 size/mtime)、目录项缓存。缓存一致性靠「关闭时刷新 + 属性超时」,不是强一致:

  • close-to-open:close() 刷脏页,下一次 open() 重新取属性;两个客户端同时写同一文件仍可能互相覆盖。
  • actimeo/acregmin/acdirmin 控制属性缓存时间;noac 关闭缓存(性能骤降)。
  • hard 挂载会无限重试,soft 超时返回 EIO;生产环境一般用 hard,intr(可被信号打断)。
  • ESTALE:服务器端的文件句柄失效(文件被删/导出变更),客户端只能重新 open。

观测:

nfsstat -c              # 客户端 RPC 统计、重传
mount | grep nfs        # 挂载选项
cat /proc/self/mountstats

3.12.8.2 AFS:整文件缓存与回调

AFS(Andrew File System)走另一条路:整文件缓存 + 服务器回调(callback)。客户端打开文件时把整个文件取到本地,服务器记录「谁缓存了它」;文件变更时服务器主动回调失效。优点是读性能极好、离线可用;缺点是首次打开大文件慢、服务器要维护回调状态。

Linux 主线没有 AFS 内核客户端;OpenAFS 以模块/用户态提供。考试或面试问 AFS,通常考的是「整文件缓存 vs 页缓存」「回调 vs 超时」这两组取舍,而不是代码。

3.12.8.3 分布式文件系统给 VFS 带来的三个边界

  1. 错误语义:本地盘的 EIO 很少见,NFS 的 ETIMEDOUT/ESTALE/EJUKEBOX 是常态;应用必须能重试/重开,不能假设 read 一次成功。
  2. 持久化语义:fsync() 到 NFS 只保证「服务器已接收并落盘」(取决于导出选项 sync/async);O_DIRECT、mmap 的语义更弱。
  3. 锁与 rename:NFSv3 的 rename 跨目录不原子;文件锁要 NLM;NFSv4 有委托(delegation),但冲突时会被召回。跨主机共享写同一文件,仍然需要应用层锁或单写者。

与 OSTEP 的对应:OSTEP 第 46–50 章讲分布式系统、NFS、AFS,核心不是协议细节,而是「远程访问把本地文件系统的假设打破了」。读这一节时,把「VFS 操作表」当成接口,把 NFS/AFS 当成两个实现,就能把分布式文件系统放回你已经有的 VFS 地图里。

3.12.9 inode 的一生:iget、I_NEW 与 evict

这一节回答:磁盘上的 inode 什么时候被读进内存、内存里的 inode 被谁引用、iput() 之后它为什么不一定立刻消失。

3.12.1 把 inode 说成「文件本身」,那是磁盘视角。内存里的 struct inode 还多了一层生命周期管理:一份引用计数、一组状态位、一个哈希表位置、一条 LRU 链、一把读写信号量。文件系统的 ->alloc_inode()/->destroy_inode() 只负责分配和释放自己那份派生结构(如 ext4_inode_info),通用字段由 VFS 的 inode_init_always() 初始化。

字段:哪些来自磁盘,哪些只存在于内存

字段类型(6.8)含义谁维护 / 何时变
i_modeumode_t文件类型 + 权限位(S_IFREG、0777)磁盘 inode;chmod/chown 时改
i_uid / i_gidkuid_t / kgid_t所有者(内核内部 uid,不是用户态 uid)chown;用户命名空间映射后可能「看起来」不同
i_flagsunsigned int用户可见标志:FS_IMMUTABLE_FL、FS_APPEND_FL、FS_NODUMP_FLioctl(FS_IOC_SETFLAGS)/chattr
i_inounsigned longinode 号(文件系统内唯一)创建时定,不变
i_sizeloff_t文件字节数write/truncate;读用 i_size_read()
i_blocksblkcnt_t已分配块数(512 字节为单位)分配/释放块时变;stat.st_blocks
i_atime/i_mtime/i_ctimestruct timespec64访问/修改/元数据变更时间读、写、chmod;relatime/lazytime 影响落盘时机
i_nlinkunsigned int硬链接数(磁盘目录项计数)link/unlink
i_opconst struct inode_operations *名字与元数据操作(lookup/create/unlink/rename)文件系统初始化时定
i_fopconst struct file_operations *默认的打开后操作表open 时复制到 file->f_op
i_sbstruct super_block *所属文件系统实例创建时定
i_mappingstruct address_space *页缓存宿主,通常指向 &i_data创建时定;块设备可能指向 bdev->bd_mapping
i_datastruct address_space内嵌的 address_space同上
i_countatomic_t内存引用计数(不是链接数)igrab/ihold/iput
i_stateunsigned longVFS 内部状态位(见下表)VFS 在 iget/回写/evict 时改
i_rwsemstruct rw_semaphore串行化 ->lookup、->create、truncate 等VFS 拿,文件系统不必自己再加锁
i_hashstruct hlist_nodeinode 哈希链节点insert_inode_hash/remove_inode_hash
i_sb_liststruct list_head挂进 sb->s_inodes分配/销毁时
i_lrustruct list_headinode LRU(可回收 inode)inode_add_lru/inode_lru_isolate
i_io_liststruct list_head回写链表(b_dirty/b_io/b_more_io)脏 inode 排队时
i_writecountatomic_t写者计数;deny_write_access() 用它阻止「正在执行的文件被写」open(O_WRONLY)/mmap(PROT_WRITE)

i_count、i_nlink、i_writecount 三个计数经常被混为一谈,实际含义完全不同:

计数增加减少归零意味着
i_countigrab()、ihold()、dcache 引用iput()没有任何内核对象引用它,可以进 LRU 或 evict
i_nlinklink() 创建目录项unlink() 删除目录项没有名字指向它;若还有 fd,文件仍可读写((deleted))
i_writecount以写方式 open、写映射close、解除映射可以重新以写方式打开,或允许 execve

i_state 是 VFS 的内部状态机,和用户可见的 i_flags 不是一回事:

状态位值(6.8)含义
I_DIRTY_SYNC1<<0元数据脏,fsync 要同步
I_DIRTY_DATASYNC1<<1数据 + 必要元数据脏(fdatasync 关心)
I_DIRTY_PAGES1<<2有脏页
I_NEW1<<3正在构造,尚未就绪;其他访问者必须等待
I_WILL_FREE1<<4已决定释放,拒绝新引用
I_FREEING1<<5正在 evict(),不能再 igrab()
I_SYNC1<<7正在回写;回写代码用它串行化同一个 inode
I_REFERENCED1<<8LRU 二次机会标记
I_DIRTY_TIME1<<11lazytime 模式下「只脏了时间戳」
I_WB_SWITCH1<<13正在切换 cgroup 回写归属

从 iget 到 unlock:一个新 inode 的诞生

iget_locked(sb, ino) 是文件系统在 ->lookup() 里最常用的入口。它先查 inode 哈希表:

/* fs/inode.c: iget_locked() —— 6.8 节选,已简化 */
struct inode *iget_locked(struct super_block *sb, unsigned long ino)
{
	struct hlist_head *head = inode_hashtable + hash(sb, ino);
	struct inode *inode;

	inode = find_inode_fast(sb, head, ino);
	if (inode) {
		if (IS_ERR(inode))
			return NULL;
		wait_on_inode(inode);      /* 若别的线程正在读磁盘,等 I_NEW 清掉 */
		if (unlikely(inode_unhashed(inode))) {
			/* 等待期间被 evict,重试 */
			iput(inode);
			goto again;
		}
		return inode;
	}

	inode = alloc_inode(sb);
	if (inode) {
		spin_lock(&inode->i_lock);
		inode->i_state = I_NEW;
		hlist_add_head(&inode->i_hash, head);
		spin_unlock(&inode->i_lock);
		inode_sb_list_add(inode);
	}
	return inode;
}

返回时分两种情况:

  • 命中:引用计数已经加一,inode 可直接用;
  • 未命中:返回一个 I_NEW 的 inode,调用者必须去磁盘读元数据、填字段,然后调用 unlock_new_inode()。忘记 unlock 是最常见的挂死原因:其他线程在 wait_on_inode() 里以 TASK_UNINTERRUPTIBLE 睡眠,ps 里看到一串 D 状态,栈上是 wait_on_bit。

unlock_new_inode() 只做两件事:

/* fs/inode.c —— 6.8 节选 */
void unlock_new_inode(struct inode *inode)
{
	...
	spin_lock(&inode->i_lock);
	inode->i_state &= ~I_NEW;
	smp_mb();
	wake_up_bit(&inode->i_state, __I_NEW);
	spin_unlock(&inode->i_lock);
}

wake_up_bit() 唤醒的是 i_state 这个地址上的等待队列(位等待队列按地址哈希),所以 unlock_new_inode() 不需要知道谁在等。

引用从哪来:dentry 才是大头

一个打开的 struct file 并不直接给 inode 加 i_count;它通过 f_path.dentry 持有 dentry 引用,而 dentry 的 d_inode 指向 inode。真正让 inode 长期驻留的是:

  1. dcache 中的正 dentry:每个正 dentry 都让 inode 的 i_count 至少为 1;
  2. 打开的文件:file->f_path 持有 dentry,dentry 又持有 inode;
  3. 内核子系统的显式引用:igrab()(如 exportfs、fsnotify、fanotify);
  4. 正在回写:i_io_list 上的 inode 由回写代码临时持有引用。

硬链接不影响 i_count:/a 和 /b 指向同一个 inode,dcache 里两个 dentry,inode 的 i_count 至少是 2(如果都被缓存)。

释放:iput 的三条分支

iput() 在 i_count 归零时进入 iput_final():

/* fs/inode.c: iput_final() —— 6.8 节选,已简化 */
static void iput_final(struct inode *inode)
{
	struct super_block *sb = inode->i_sb;
	const struct super_operations *op = sb->s_op;
	int drop;

	WARN_ON(inode->i_state & I_NEW);

	if (op->drop_inode)
		drop = op->drop_inode(inode);
	else
		drop = generic_drop_inode(inode);   /* !i_nlink || unhashed */

	if (!drop && !(inode->i_state & I_WILL_FREE)) {
		write_inode_now(inode, 1);          /* 脏 inode 先回写 */
		if (inode->i_state & I_DIRTY)
			return;                     /* 回写失败?留在 LRU 再说 */
	}

	spin_lock(&inode->i_lock);
	if (drop) {
		inode->i_state |= I_FREEING;
		...
		evict(inode);                        /* remove_inode_hash + truncate pages + destroy_inode */
	} else {
		inode_add_lru(inode);                /* 留在内存,等内存压力回收 */
	}
	spin_unlock(&inode->i_lock);
}

三条分支对应三种语义:

条件动作典型场景
i_nlink == 0 或 inode 已不在哈希evict(),立刻释放文件被删除且最后一个 fd 关闭
仍有链接,但 inode 脏write_inode_now() 后可能留在 LRU普通文件被 close,元数据还没落盘
仍有链接且干净inode_add_lru(),进入可回收 LRU缓存中的 inode,内存紧张时被 prune_icache() 回收

evict() 的顺序值得记住:

evict(inode)
  → remove_inode_hash(inode)            从哈希摘除,新的 iget 找不到它
  → truncate_inode_pages_final(mapping) 丢弃该文件所有页缓存
  → fsnotify_inode_delete()             通知 inotify/fanotify
  → destroy_inode(inode)                调 sb->s_op->destroy_inode / RCU 延迟释放

truncate_inode_pages_final() 必须在 destroy_inode() 之前:页缓存里的 folio 仍然通过 folio->mapping 反指 address_space,如果先释放 inode,回写或缺页路径会踩到已释放内存。这也是 iput() 不能在持有 spinlock 时随便调用的原因——truncate_inode_pages_final() 可能睡眠。

失败路径与边界

现象原因正确处理
iget_locked() 返回 NULLalloc_inode() 内存不足返回 -ENOMEM,不要解引用
新 inode 没 unlock_new_inode()其他线程永久 D 状态所有「新建」分支(含错误分支)都要 unlock 或 iget_failed()
insert_inode_locked() 返回 -EBUSY同一 inode 号已被别人插入(并发 lookup)用 iget5_locked() 的 test/set 回调,或丢掉自己分配的 inode 重查
d_instantiate() 之前 dentry 已可见并发 lookup 看到 d_inode == NULL 的负 dentryVFS 用 DCACHE_PAR_LOOKUP 挡住,见 3.12.10
inode 释放后仍被访问只持有裸指针,没有 igrab()任何跨越睡眠点的保存都必须 igrab(),用完 iput()
i_nlink 为 0 但 df 不降还有 fd 打开lsof +L1;等最后一个 close()
inode 的五个常见误解
  1. 「i_count 就是硬链接数」:i_count 是内存引用,i_nlink 是磁盘链接数。ls -l 的链接数看 i_nlink,/proc/slabinfo 的 ext4_inode_cache 活跃对象更接近 i_count 的总体规模。
  2. 「iput() 一定释放 inode」:只有 i_count 归零、且 drop_inode() 判定可丢弃时才 evict();否则进 LRU,等 prune_icache()。
  3. 「inode 号在文件系统内永远唯一,所以可以当全局 ID」:只在一个 super_block 内唯一;不同文件系统、不同挂载命名空间里的同号 inode 毫无关系。跨文件系统识别要用 statx() 的 stx_mnt_id + stx_ino(或 name_to_handle_at() 的 handle)。
  4. 「inode 缓存越大越好」:inode 缓存持有页缓存与 dentry,内存紧张时先回收它们;echo 2 > /proc/sys/vm/drop_caches 会清掉可回收的 dentry/inode,但不会碰脏页。
  5. 「iget_locked() 内部会读磁盘」:它只查哈希、分配空壳;读磁盘是调用者(ext4_iget() 等)的事。I_NEW 就是「空壳已建,内容未就绪」的标志。

3.12.10 dentry 缓存:哈希、并发 lookup 与回收

这一节回答:dcache 命中为什么是 O(1)、两个进程同时 open 同一个不存在的路径时谁去读磁盘、负 dentry 有什么用、dentry 什么时候被回收。

字段:一个名字的全部状态

字段类型(6.8)含义谁维护 / 何时变
d_flagsunsigned int缓存行为标志(见下表)文件系统/VFS 按 ops 设置
d_seqseqcount_spinlock_tRCU 查找的序列号;rename 时递增d_rehash/__d_move/d_instantiate
d_hashstruct hlist_bl_node父目录哈希桶中的节点__d_add/__d_drop
d_parentstruct dentry *父 dentry(RCU 下可能瞬间变化)rename 时改
d_namestruct qstr名字:name 指针 + len + hash创建时定;rename 换名字
d_inodestruct inode *指向 inode;负 dentry 为 NULLd_instantiate/d_delete
d_lockrefstruct lockref自旋锁 + 引用计数合体dget/dput/d_lock
d_opconst struct dentry_operations *文件系统自定义:hash/compare/revalidate/delete/prune文件系统初始化
d_sbstruct super_block *所属文件系统创建时定
d_lrustruct list_headdentry LRU 链d_lru_add/d_lru_del
d_childstruct list_head挂在父的 d_subdirs 上分配/销毁
d_subdirsstruct list_head所有子 dentry 链同上
d_uuniond_alias(inode 别名链)/d_in_lookup_hash/d_rcu按状态复用

d_flags 只列常用的几位:

标志值含义
DCACHE_OP_HASH0x1使用 d_op->d_hash(大小写不敏感等)
DCACHE_OP_COMPARE0x2使用 d_op->d_compare(大小写不敏感、Unicode 归一化)
DCACHE_OP_REVALIDATE0x4使用 d_op->d_revalidate(NFS 等网络文件系统)
DCACHE_OP_DELETE0x8使用 d_op->d_delete
DCACHE_OP_PRUNE0x10使用 d_op->d_prune
DCACHE_DISCONNECTED0x20暂无父目录(NFS 文件句柄打开、fsck 恢复)
DCACHE_WHITEOUT0x80overlayfs 的白out 项
DCACHE_MOUNTED0x10000该 dentry 是某个挂载点的根
DCACHE_NEED_AUTOMOUNT0x20000需要触发自动挂载(autofs)
DCACHE_MANAGE_TRANSIT0x40000文件系统要接管跨挂载点解析
DCACHE_PAR_LOOKUPBIT(28) / 0x10000000正在做并行 lookup(见下)

哈希查找:(parent, name) 定位桶

dcache 的索引键是「父 dentry + 名字」,不是完整路径。d_hash(parent, hash) 取全局 dentry_hashtable 的一个桶(每桶一把 bit_spinlock),桶内是 hlist_bl 链。查找时先比 d_parent,再比名字:

/* fs/dcache.c: __d_lookup_rcu() —— 6.8 节选,已简化 */
struct dentry *__d_lookup_rcu(const struct dentry *parent,
			      const struct qstr *name, unsigned *seqp)
{
	u64 hashlen = name->hash_len;
	const unsigned char *str = name->name;
	struct hlist_bl_head *b = d_hash(parent, hashlen_hash(hashlen));
	struct hlist_bl_node *node;
	struct dentry *dentry;

	hlist_bl_for_each_entry_rcu(dentry, node, b, d_hash) {
		unsigned seq;

		if (dentry->d_parent != parent)
			continue;
		seq = raw_seqcount_begin(&dentry->d_seq);
		if (!dentry_cmp(dentry, str, hashlen_len(hashlen)))
			continue;
		if (d_unhashed(dentry))
			continue;
		*seqp = seq;
		return dentry;                  /* 调用者之后用 d_seq 校验 */
	}
	return NULL;
}

RCU 版本不拿桶锁、不增加引用计数,返回的 dentry 可能在下一秒被 rename 或释放。调用者(lookup_fast())拿到 seq 后继续走下一层,最后用 read_seqcount_retry(&dentry->d_seq, seq) 统一校验;任何一步发现序列变了就返回 -ECHILD,退回 ref-walk 重新来一遍。这就是 3.12.2 里那条「RCU-walk 会退回」的微观机制。

d_compare 决定「什么算同名」:FAT/NTFS 的大小写不敏感、casefold 目录的 Unicode 归一化都在这里实现。d_hash 则让「大小写不同但等价」的名字落进同一个桶。两者必须成对实现,否则 Lookup 和 lookup 会进不同桶,缓存直接失效。

并发 lookup:DCACHE_PAR_LOOKUP 与 d_alloc_parallel

两个进程同时 open("/tmp/newfile"),dcache 未命中,谁去调用文件系统的 ->lookup()?如果都去,就重复读目录、重复分配 inode,甚至创建出两个 dentry。VFS 的解法是并行 lookup:

lookup_slow(parent, name)
  → d_alloc_parallel(parent, name, &wq)
      在父目录的 d_in_lookup_hash 里查「正在 lookup 的同名 dentry」
      命中:等 d_wait 上的等待队列(TASK_UNINTERRUPTIBLE)
      未命中:分配 dentry,置 DCACHE_PAR_LOOKUP,插入 d_in_lookup_hash
  → 赢家调用 inode->i_op->lookup() 读目录
  → d_lookup_done(dentry):清 DCACHE_PAR_LOOKUP、移出 d_in_lookup_hash、唤醒等待者
  → 输家醒来重查 dcache,拿到赢家填好的 dentry

这条路径解决了三个老问题:重复的磁盘 I/O、同名 dentry 重复分配、以及「lookup 返回 -ENOENT 后要不要缓存负 dentry」的竞争。d_alloc_parallel() 等待时用的是 d_wait 指针(与 d_lru 共用 union),因此一个正在 lookup 的 dentry 不能同时在 LRU 上。

正、负、断开:dentry 的四种状态

状态判据含义典型来源
正(positive)d_inode != NULL名字已绑定 inode正常查找命中
负(negative)d_inode == NULL名字确定不存在,缓存「不存在」lookup 返回 -ENOENT 后由 VFS 缓存
断开(disconnected)DCACHE_DISCONNECTED有 inode 但没有到根的路径NFS 文件句柄、open_by_handle_at()
白out(whiteout)DCACHE_WHITEOUToverlayfs 用来表示「上层删除了下层文件」overlayfs

负 dentry 是 dcache 最容易被低估的部分:它让「反复 stat 一个不存在的路径」不必每次都读目录。但负 dentry 也有代价——创建文件时必须先把它失效,否则 open(O_CREAT) 会看到「已缓存的不存在」。lookup_open() 里的 d_lookup + d_invalidate 就是处理这件事。

回收:LRU、shrinker 与 dput

dentry 的引用计数在 d_lockref.count。dput() 减一,归零后:

  1. 若 DCACHE_OP_DELETE 且 d_op->d_delete() 返回 1,直接 dentry_free();
  2. 否则挂到 sb->s_dentry_lru(d_lru_add()),等待内存压力;
  3. dentry_shrinker 注册在 sb->s_shrink 上,shrink_dentry_list() 从 LRU 尾部摘取,检查引用计数仍为 0、没有子 dentry、不是挂载点,才 dentry_kill();
  4. dentry_kill() 做 __d_drop()(从哈希摘除)、dentry_unlink_inode()(iput inode)、dentry_free()(RCU 延迟释放,因为 RCU-walk 可能还在读)。

retain_dentry() 是「二次机会」:DCACHE_REFERENCED 或仍有子 dentry 时把它放回 LRU 头部。/proc/sys/fs/dentry-state 的 nr_unused 就是 LRU 上可回收的数量。

为什么 dentry 释放要用 RCU

RCU-walk 期间没有任何锁,读者可能正拿着 d_parent 指针往上走。如果 dentry_free() 立刻 kfree(),读者就踩空。VFS 用 call_rcu(&dentry->d_u.d_rcu, __d_free) 延迟到宽限期之后释放;d_seq 则在 rename 时递增,让读者发现「路径变了」并退回慢路径。这两个机制配合,才有了「读路径零锁」。

3.12.11 page cache 的索引:xarray、folio 与回写队列

这一节回答:一个 4 KiB 的文件页在内存里怎么被找到、预读凭什么知道下一页在哪、脏页从「标记为脏」到「写进 bio」经过哪些队列。

3.12.4 讲了 write() 只进 page cache。真正的问题是规模:一个 1 TiB 的文件、上百万个页,read() 怎么在 O(1) 里定位到某一页?答案是 address_space 里的 xarray——一棵按「文件内页号」索引的压缩基数树。

address_space:一个 inode 的页缓存

字段类型(6.8)含义
hoststruct inode *宿主 inode(块设备的 mapping 指向 bdev)
i_pagesstruct xarray页号 → folio 的映射;带 DIRTY/WRITEBACK/TOWRITE 三个标记
i_mmapstruct rb_root_cached所有映射该文件的 VMA,按虚拟地址排序(RMAP 用)
i_mmap_writableatomic_t可写映射计数(ETXTBSY 判断的一部分)
nrpagesunsigned long缓存页数(不含被换出的)
writeback_indexpgoff_t回写扫描的起点,避免每次都从 0 开始
a_opsconst struct address_space_operations *read_folio/writepages/dirty_folio/write_begin/write_end
flagsunsigned longAS_UNEVICTABLE、AS_EXITING、AS_NO_WRITEBACK_TAGS 等
i_mmap_rwsemstruct rw_semaphore保护 i_mmap 与缺页路径
invalidate_lockstruct rw_semaphore保护 truncate 与 page fault 的竞争(5.17+)
private_lock/private_listspinlock_t/list_head文件系统私有(如 ext4 的 i_es_tree 不在这里)
wb_errerrseq_t最近一次回写错误,fsync 用它报告延迟错误

xarray 的每个条目除了指针,还有三个标记位:

标记含义谁设置
PAGECACHE_TAG_DIRTY该页有未写回的数据__folio_mark_dirty()
PAGECACHE_TAG_WRITEBACK正在写回folio_start_writeback()
PAGECACHE_TAG_TOWRITE本轮 writepages 要写的页tag_pages_for_writeback()

find_get_page()/filemap_get_folio() 在 xarray 里按 pgoff_t index 查找;filemap_get_pages() 一次取一批(folio_batch,默认 15 个),批量处理减少 xarray 锁竞争。6.8 里 page cache 的条目类型是 struct folio *,order-0 时 folio 与 page 是同一块内存;大 folio(多阶)在部分文件系统上启用,ext4 在 6.8 仍以 order-0 为主。

读路径:从缺页到 uptodate

read(fd, buf, 4096)
  → vfs_read() → file->f_op->read_iter()          ← ext4_file_read_iter
      → generic_file_read_iter()
          → filemap_read()
              → filemap_get_pages()               查 xarray,命中直接返回
              → page_cache_sync_readahead()       未命中:同步预读
              → filemap_read_folio()
                  → a_ops->read_folio()           ← ext4_read_folio → mpage_read_folio
                      → submit_bh()/bio           读磁盘
              → folio_mark_uptodate()             标记数据有效
              → folio_unlock()                    唤醒等待者
          → copy_folio_to_iter()                  拷到用户缓冲区

关键同步点:

  • PG_locked:一个 folio 正在读盘时置位,其他读者在 folio_wait_bit() 上睡眠;读完后 folio_unlock() 唤醒。
  • PG_uptodate:数据有效。没有它,页缓存里的内容不可信;read_folio 失败时 folio 保持 !uptodate,并 mapping_set_error()。
  • PG_error:该页出过错,后续访问直接返回 -EIO,直到重读成功。

filemap_read() 的骨架(省略了迭代器与信号处理):

/* mm/filemap.c: filemap_read() —— 6.8 节选,已大幅简化 */
ssize_t filemap_read(struct kiocb *iocb, struct iov_iter *iter,
		     ssize_t already_read)
{
	struct file *filp = iocb->ki_filp;
	struct address_space *mapping = filp->f_mapping;
	loff_t isize = i_size_read(mapping->host);
	...
	do {
		struct folio_batch fbatch;
		...
		error = filemap_get_pages(iocb, iter, &fbatch);
		if (error < 0)
			break;
		for (i = 0; i < folio_batch_count(&fbatch); i++) {
			struct folio *folio = fbatch.folios[i];
			size_t offset = iocb->ki_pos & (folio_size(folio) - 1);
			size_t bytes = min_t(loff_t, folio_size(folio) - offset,
					     iov_iter_count(iter));
			...
			copied = copy_folio_to_iter(folio, offset, bytes, iter);
			iocb->ki_pos += copied;
			...
		}
		folio_batch_release(&fbatch);
		cond_resched();
	} while (iov_iter_count(iter) && iocb->ki_pos < isize && !error);
	...
}

预读:file_ra_state 的状态机

预读不是「多读几页」这么简单,它要根据访问模式决定读多少、是否异步。状态在 struct file_ra_state 里:

字段含义
start本轮预读的起始页号
size本轮预读的页数(2 的幂增长)
async_size其中「异步预读」的页数;访问到这段就触发下一轮
ra_pages上限,来自 bdi->ra_pages(默认 128 KiB / 页大小)
mmap_missmmap 预读的「未命中」计数,避免随机 mmap 浪费
prev_pos上一次读到的位置,用来判断是否顺序

算法(ondemand_readahead())的核心:

  1. 首次读:预读 ra_pages 的一部分(通常 2 页);
  2. 顺序读命中预读窗口:size 翻倍,直到 ra_pages 上限;
  3. 随机读:清空窗口,只读当前页;
  4. 预读的页被访问到 async_size 边界:发起下一轮异步预读(不等结果)。

/sys/class/bdi/<major>:<minor>/read_ahead_kb 可以按设备调上限;posix_fadvise(POSIX_FADV_SEQUENTIAL) 会让内核把 ra_pages 翻倍,POSIX_FADV_RANDOM 则关闭预读。

脏页限流:为什么 write() 会突然变慢

脏页不是无限攒的。balance_dirty_pages_ratelimited() 在每个写循环里被调用,按「脏页比例」和「写回带宽」给写者限速:

参数默认作用
vm.dirty_background_ratio10%超过它,后台 kworker 开始写回
vm.dirty_ratio20%超过它,写者被强制阻塞(同步写回)
vm.dirty_background_bytes0以字节设置后台阈值,非 0 时覆盖 ratio
vm.dirty_bytes0以字节设置硬阈值
vm.dirty_expire_centisecs3000脏页超过 30 秒必须写回
vm.dirty_writeback_centisecs500回写线程每 5 秒醒一次
vm.dirtytime_expire_seconds43200lazytime 时间戳的过期时间(12 小时)

限流的「体感」是:dd 前几秒写入极快(进 page cache),随后速度掉到磁盘带宽,iostat 里 wkB/s 上升、%iowait 上升。这不是 bug,是内核在保护内存。

回写:从 i_io_list 到 writepages

wb_workfn()                         每个 bdi 一个 workqueue 工作项
  → wb_do_writeback()
      → wb_writeback(wb, work_index)
          → writeback_sb_inodes()     遍历 sb->s_inodes,挑脏 inode
              → __writeback_single_inode()
                  → do_writepages(mapping, wbc)
                      → a_ops->writepages()   ← ext4_writepages
                          → ext4_map_blocks() 分配/查 extent
                          → ext4_bio_write_folio() → submit_bio()
                  → wbc->nr_to_write 递减

struct writeback_control 里的 sync_mode 决定语义:

sync_mode含义触发者
WB_SYNC_NONE尽量写,不等待,写完就返回后台回写
WB_SYNC_ALL必须写完并等待 I/O 完成fsync、sync、umount

WB_SYNC_ALL 还会要求文件系统提交日志(->fsync 里的 jbd2_complete_transaction),所以 fsync 比后台回写贵得多。cgroup writeback(CONFIG_CGROUP_WRITEBACK)把每个 bdi_writeback 再按 memory cgroup 分裂,避免一个容器的脏页拖垮整机——/sys/kernel/debug/bdi/*/stats 能看到每个 wb 的 dirty/writeback 计数。

page cache 的六个边界
  1. 「i_size 之内的页一定在缓存里」:不一定;页可能被回收,读时再缺页。mincore() 只能告诉你「现在在不在」。
  2. 「预读总是划算」:随机读时预读会浪费带宽与缓存;数据库通常用 POSIX_FADV_RANDOM 或 O_DIRECT。
  3. 「mmap 写不经过 page cache」:正好相反,mmap 写就是改 page cache;msync() 才触发回写。
  4. 「O_DIRECT 与 buffered I/O 自动一致」:不。两者可能同时访问同一文件区间,内核不保证顺序;混用时必须自己 fsync 或用 invalidate_inode_pages2() 语义。
  5. 「write() 返回短写一定是错」:buffered 写一般写满;O_DIRECT、信号、RLIMIT_FSIZE 都可能短写。短写后必须从 buf + n 继续。
  6. 「页缓存会在 close() 时刷盘」:不会。close() 只 fput(),脏页等回写线程;要持久化必须 fsync()。

3.12.12 ext4 磁盘结构:超级块、extent 与 JBD2

这一节回答:dumpe2fs 打印的那些数字在磁盘的哪个偏移、extent 树的一个节点长什么样、日志里一个事务由哪些块组成。

3.12.5 给了 ext4 的布局图与日志流程。这一节把结构落到字节偏移,方便你用 dd/debugfs 直接验证。

超级块:1024 字节处的「文件系统身份证」

ext4 超级块固定从分区的 1024 字节处开始(块大小 4 KiB 时是块 0 的偏移 1024),大小 1024 字节。常用字段(偏移相对超级块起始):

偏移字段含义
0x00s_inodes_countinode 总数
0x04s_blocks_count_lo块总数低 32 位
0x0Cs_free_blocks_count_lo空闲块数低 32 位
0x10s_free_inodes_count空闲 inode 数
0x18s_log_block_size块大小 = 1024 << 该值
0x20s_blocks_per_group每组块数(通常 32768)
0x28s_inodes_per_group每组 inode 数
0x38s_magic0xEF53
0x3As_state1=clean,2=error,4=orphan recovery
0x54s_first_ino第一个非保留 inode(通常 11)
0x58s_inode_sizeinode 大小(默认 256)
0x5Cs_feature_compat兼容特性位
0x60s_feature_incompat不兼容特性位(不认识就不能挂)
0x64s_feature_ro_compat只读兼容特性位(不认识只能只读挂)
0x68s_uuid[16]文件系统 UUID
0xE0s_journal_inum日志 inode 号(内部日志)
0xE4s_journal_dev外部日志设备号(0 表示内部)
0xE8s_last_orphan孤儿 inode 链头
0xFEs_desc_size组描述符大小(32 或 64)
0x150s_blocks_count_hi块总数高 32 位(64bit 特性)
0x2FCs_checksum超级块 CRC32c

特性位决定「这个文件系统能不能挂」:

类别常用位含义
compatHAS_JOURNAL 0x4、EXT_ATTR 0x8、DIR_INDEX 0x20、ORPHAN_FILE 0x1000不认识也能安全读写
incompatFILETYPE 0x2、RECOVER 0x4、EXTENTS 0x40、64BIT 0x80、FLEX_BG 0x200、INLINE_DATA 0x8000、ENCRYPT 0x10000不认识就拒绝挂载
ro_compatSPARSE_SUPER 0x1、LARGE_FILE 0x2、HUGE_FILE 0x8、METADATA_CSUM 0x400、PROJECT 0x2000不认识只能只读挂

RECOVER 位是「日志需要重放」的标记:mount 时若置位,JBD2 先重放日志,再清位;e2fsck 看到它就知道上次没干净卸载。

组描述符与 inode 表

每个块组有一份 ext4_group_desc(32 字节;开 64bit 后 64 字节):

偏移字段含义
0x00bg_block_bitmap_lo块位图块号
0x04bg_inode_bitmap_loinode 位图块号
0x08bg_inode_table_loinode 表起始块号
0x0Cbg_free_blocks_count_lo组内空闲块数
0x0Ebg_free_inodes_count_lo组内空闲 inode 数
0x10bg_used_dirs_count_lo组内目录数
0x12bg_flagsINODE_UNINIT、BLOCK_UNINIT、INODE_ZEROED
0x1Ebg_checksum组描述符 CRC32c(metadata_csum)

bg_flags 的 UNINIT 位是懒初始化:新格式化的大文件系统不必把每个组的位图都写一遍,挂载后第一次用到才初始化。

inode 表是 s_inodes_per_group 个 inode 的数组,每个 s_inode_size 字节。ext4 inode 的前 160 字节布局稳定:

偏移字段含义
0x00i_mode类型 + 权限
0x02i_uid所有者低 16 位(高 16 位在 i_osd2)
0x04i_size_lo文件大小低 32 位
0x08/0x0C/0x10i_atime/i_ctime/i_mtime秒级时间戳
0x14i_dtime删除时间
0x18i_gid组低 16 位
0x1Ai_links_count硬链接数
0x1Ci_blocks_lo512 字节块计数
0x20i_flagsEXT4_EXTENTS_FL 0x80000、EXT4_INLINE_DATA_FL 0x10000000 等
0x28i_block[15]60 字节:extent 树根或 15 个块指针
0x6Ci_size_high文件大小高 32 位
0x80i_extra_isize额外 inode 字段大小
0x84/0x88/0x8Ci_ctime_extra 等纳秒 + epoch 扩展

i_block[15] 的 60 字节是 ext4 的关键设计:开 EXTENTS 特性时,它被解释为 extent 树根;否则退回 ext2 的「12 直接 + 1 一级 + 1 二级 + 1 三级间接」。一个 inode 只能二选一,EXT4_EXTENTS_FL 就是开关。

extent 树:三个 12 字节结构

ext4_extent_header(12 B)
  0x00 ee_magic      0xF30A
  0x02 ee_entries    当前有效条目数
  0x04 ee_max        容量
  0x06 ee_depth      0=叶子,>0=索引
  0x08 ee_generation
  (块尾 ext4_extent_tail 才是校验和,不在 extent header)

ext4_extent(12 B,叶子)
  0x00 ee_block      文件内起始逻辑块
  0x04 ee_len        长度(≤32768;>32768 表示未写 extent)
  0x06 ee_start_hi   物理块高 16 位
  0x08 ee_start_lo   物理块低 32 位

ext4_extent_idx(12 B,索引)
  0x00 ei_block      该子树覆盖的起始逻辑块
  0x04 ei_leaf_lo    子节点块号低 32 位
  0x08 ei_leaf_hi    子节点块号高 16 位
  0x0A ei_unused

ee_len 的最高位语义:值大于 32768 表示这是个未写 extent(fallocate 预留但没写数据),实际长度要减去 32768。读到未写 extent 的块,ext4 返回全零而不读磁盘;写它时才真正分配块。这就是 fallocate() 比「写零」快的原因。

extent 树最多 6 层(ee_depth 0–5),单文件最大 16 TiB(4 KiB 块、ee_len 上限 32768)。debugfs -R 'dump_extents /path' 会按层打印,filefrag -v 则把 extent 列表换算成「逻辑偏移—物理偏移—长度—标志」。

目录:ext4_dir_entry_2 与 htree

小目录是线性的「名字 → inode」数组:

/* fs/ext4/ext4.h —— 6.8 节选 */
struct ext4_dir_entry_2 {
	__le32	inode;			/* 0 表示空槽 */
	__le16	rec_len;		/* 本条目的总长度(到下一个条目) */
	__u8	name_len;
	__u8	file_type;		/* 1 普通、2 目录、7 符号链接 */
	char	name[];
};

rec_len 让删除不必搬移数据:把 inode=0、rec_len 合并给前一个条目即可。file_type 让 readdir 不必 stat 每个条目——ls 不带 -l 时快,就是因为它只读目录项。

大目录用 htree(DIR_INDEX 特性):根块的前 24 字节是 . 和 ..,随后是 dx_root_info(hash_version、info_length、indirect_levels),再往后是 dx_entry { hash, block } 数组,按名字哈希排序。查找时算哈希、二分、进子块;插入时若块满就分裂。htree 只加速「按名字查找」,不改变「目录项仍是线性块」的事实——readdir 仍要扫全部叶子块。

日志:JBD2 的五种块

ext4 的日志是一个单独的 inode(s_journal_inum,通常是 8)或外部设备,由 JBD2 管理。日志区按块解释,每块开头 12 字节是 journal_header_t:

/* include/linux/jbd2.h —— 6.8 节选 */
typedef struct journal_header_s {
	__be32	h_magic;		/* 0xC03B3998 */
	__be32	h_blocktype;		/* 1=descriptor 2=commit 3=sb_v1 4=sb_v2 5=revoke */
	__be32	h_sequence;		/* 事务序号 */
} journal_header_t;
块类型内容作用
超级块(v2)s_blocksize、s_maxlen、s_first、s_sequence、s_start、s_errno日志的元数据;s_start=0 表示无待重放事务
描述符块头部 + 若干 tag(t_blocknr、t_flags、t_checksum)声明「接下来的数据块属于哪个目标块」
数据块元数据块的副本真正写往最终位置的内容
提交块头部 + h_checksum事务完成的标志;有它才允许重放
撤销块若干 4 字节块号事务中又被释放的块,重放时跳过

tag 的 t_flags 有四位:ESCAPE(数据块头恰好等于 magic,需要转义)、SAME_UUID(沿用上一个 tag 的 UUID)、DELETED(该块被删除)、LAST_TAG(最后一个 tag)。SAME_UUID 是常见优化:同一事务的块通常属于同一文件系统。

一个事务的生命周期:

jbd2_journal_start()           拿 handle_t,预留 credits
  → jbd2_journal_get_write_access(bh)   把块加入事务的「写访问」列表
  → 修改元数据
  → jbd2_journal_dirty_metadata(bh)     标记为脏(JBD2 会写日志副本)
jbd2_journal_stop(handle)      提交或加入正在提交的事务
  → kjournald2 唤醒
      → jbd2_journal_commit_transaction()
          1. 锁定:停止新事务加入
          2. 刷数据:data=ordered 时先写数据块
          3. 写日志:描述符 + 数据 + 提交块
          4. 发 flush:确保日志落盘
          5. 检查点:把元数据写到最终位置,回收日志空间

失败路径:

错误触发点后果
-ENOSPC日志空间不足 / 块分配失败ext4_journal_start() 返回 ERR_PTR,调用者必须 IS_ERR() 检查
-EROFS文件系统已 remount-ro后续写全部失败;dmesg 有 EXT4-fs error
-EIO日志块读写失败JBD2 abort,文件系统进入只读;s_errno 记录
-EUCLEAN元数据校验和不匹配metadata_csum 报错,通常需要 e2fsck

jbd2_journal_start() 返回的是 handle_t *,错误用 ERR_PTR() 编码;忘记检查会把 -ENOSPC 当指针用,直接 oops。这是写 ext4 相关代码最常见的错误之一。

亲手验证

# 超级块原始字节:magic 应在偏移 0x38(相对 1024 字节处)
sudo dd if=/dev/vda1 bs=1 skip=$((1024+0x38)) count=2 2>/dev/null | xxd

# 结构化视图
sudo dumpe2fs -h /dev/vda1 | sed -n '1,40p'

# inode 与 extent
sudo debugfs -R 'stat /var/log/syslog' /dev/vda1
sudo debugfs -R 'dump_extents /var/log/syslog' /dev/vda1

# 目录的 htree 结构
sudo debugfs -R 'htree_dump /etc' /dev/vda1

# 日志超级块
sudo debugfs -R 'logdump -s' /dev/vda1 | head -30

# extent 的用户态视图
filefrag -v /var/log/syslog

dumpe2fs 的 Filesystem features 一行对应 3.12.12 的三类特性位;Inode size、Flex block group size、Journal size 都能和上面的字段对上。

OSTEP 的 VSFS 与 ext4 的对应

OSTEP 第 40 章用 VSFS 讲「数据区 + inode 表 + 位图 + 超级块」,ext4 只是把这套结构乘以块组、加上 extent 与日志:s_inodes_count/s_blocks_count 是 VSFS 超级块里的「有多少 inode/块」,bg_inode_bitmap/bg_block_bitmap 是 VSFS 的两张位图,i_block[] 是 VSFS 的指针数组(ext4 换成了 extent 树)。先把 VSFS 的五个部分记住,再看 ext4 的偏移表,就不会迷路。

块位图:找一个空闲块要几步

ext4 的块组里有 bg_block_bitmap/bg_inode_bitmap:每一位表示一个块/inode 是否占用。分配流程:

  1. 在位图里找第一个 0 位(ext4_find_next_zero_bit);
  2. 用 test_and_set_bit 原子占用;
  3. 更新组描述符的 bg_free_blocks_count;
  4. 若在事务中,位图块与组描述符都进日志。

崩溃一致性靠日志:位图与组描述符的修改在同一个事务里,要么都提交,要么都回滚。e2fsck 的「位图与 inode 表不一致」就是这类元数据在崩溃后出现矛盾时的修复。

3.12.13 持久化语义:fsync 家族、目录与错误报告

这一节回答:fsync 到底保证什么、为什么写完文件还要 fsync 目录、fsync 返回 EIO 时数据到底丢没丢。

七个接口的保证强度

接口保证不保证典型代价
write()数据进入 page cache掉电持久、元数据落盘内存拷贝
fdatasync(fd)数据 + 读取所需的元数据(大小、块映射)mtime/atime 等非必要元数据一次日志提交 + flush
fsync(fd)数据 + 全部元数据目录项(新建文件时)比 fdatasync 多刷 inode
syncfs(fd)该 fd 所在文件系统的所有脏数据其他文件系统全文件系统回写
sync()所有文件系统设备缓存之外的硬件谎报全局,可能几十秒
O_SYNC/O_DSYNC每次 write 等价一次同步仍不保证目录项每次写都等 I/O
sync_file_range()只发起回写,不等待、不报错持久性、错误几乎免费

O_SYNC 与 O_DSYNC 的区别在 inode 状态位:O_DSYNC 只要求 I_DIRTY_DATASYNC,O_SYNC 要求 I_DIRTY_SYNC | I_DIRTY_DATASYNC | I_DIRTY_PAGES。落到 ext4,前者可以跳过 mtime 的日志提交,后者不能。

fsync 的内核路径与三件事

fsync(fd)
  → vfs_fsync_range(file, 0, LLONG_MAX, datasync=0)
      → file->f_op->fsync()                 ← ext4_sync_file
          → file_write_and_wait_range()     1. 数据页写回并等待
          → jbd2_complete_transaction()     2. 等包含该 inode 的事务提交
          → blkdev_issue_flush()            3. 发 flush 给设备缓存
      → errseq_check_and_advance()          报告自上次 fsync 以来的回写错误

ext4_sync_file() 里还有一个容易忽略的分支:如果 inode 有未完成的 O_DIRECT I/O,先 inode_dio_wait();如果有未写 extent(fallocate 预留),先 ext4_flush_unwritten_io() 把它转成已写 extent,否则崩溃后文件里会出现「逻辑上有数据、物理上没分配」的洞。

目录也是文件

新建文件时,open(O_CREAT) 改了两处:新 inode 本身,以及父目录的数据块(多了个目录项)。fsync(file_fd) 只保证前者。要让「文件存在」这件事也持久,必须再 fsync(dir_fd):

/* durable_replace.c —— 原子替换一个文件,崩溃后要么旧要么新
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o durable_replace durable_replace.c
 * 运行: ./durable_replace /srv/data/state.txt
 */
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static int write_all(int fd, const char *s, size_t n)
{
	while (n > 0) {
		ssize_t w = write(fd, s, n);
		if (w < 0)
			return -1;
		s += w;
		n -= (size_t)w;
	}
	return 0;
}

int main(int argc, char **argv)
{
	const char *path = argc > 1 ? argv[1] : "state.txt";
	char tmp[4096];
	int fd, dirfd;

	if (snprintf(tmp, sizeof tmp, "%s.tmp", path) >= (int)sizeof tmp)
		return 1;
	fd = open(tmp, O_CREAT | O_TRUNC | O_WRONLY | O_CLOEXEC, 0644);
	if (fd < 0) { perror("open tmp"); return 1; }
	if (write_all(fd, "v=2\n", 4) < 0) { perror("write"); return 1; }
	if (fsync(fd) < 0) { perror("fsync tmp"); return 1; }   /* 1. 内容持久 */
	if (close(fd) < 0) { perror("close"); return 1; }
	if (rename(tmp, path) < 0) { perror("rename"); return 1; } /* 2. 原子替换 */

	dirfd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
	if (dirfd < 0) { perror("open dir"); return 1; }
	if (fsync(dirfd) < 0) { perror("fsync dir"); return 1; }   /* 3. 目录项持久 */
	close(dirfd);
	return 0;
}

顺序不能换:先 fsync(tmp) 再 rename,否则崩溃后可能看到「名字已换、内容还是空」;rename 之后 fsync(dir),否则崩溃后可能看到旧名字。OSTEP 第 39.7 节把这个模式叫「原子文件更新」,并明确指出「很多应用漏掉了目录的 fsync」。

错误报告:errseq_t 为什么要存在

回写是异步的:write() 早就返回了,EIO 可能几分钟后才出现。如果内核只在出错的那一刻置一个全局标志,应用无法知道「这个错误是不是我已经处理过的」。Linux 4.13 引入 errseq_t:一个 32 位序列号(低 12 位是错误码,高位是序号),每个 address_space 和 super_block 各有一个。

  • file->f_wb_err 保存「本 fd 上次看到的序列号」;
  • fsync() 调 errseq_check_and_advance(&mapping->wb_err, &file->f_wb_err):有新错误就返回它并推进序列号;
  • 同一个错误只会被同一个 fd 报告一次,不会无限重复。

这带来两个实践结论:

  1. fsync 返回 EIO 时,之前的 write 可能已经丢了;必须把这段数据当作不可信,回滚或重写。
  2. close() 不报告回写错误。只 write + close 的程序可能静默丢数据;要么 fsync,要么用 sync_file_range + 定期 fsync。
errno何时出现处理
EBADFfd 无效或不是打开的文件编程错误
EINVAL文件系统不支持 fsync(少数伪文件系统)忽略或改用 syncfs
EIO回写/设备错误数据不可信,重试或告警
ENOSPC延迟分配在回写时才发现没空间预留空间(fallocate)或减小写入
EROFS文件系统已只读(errors=remount-ro)停止写入
EINTR等待期间被信号打断(部分文件系统)重试
持久化的五个坑
  1. 「fsync 目录没必要」:新建/删除/rename 都改目录项;不 fsync 目录,掉电后文件可能「消失」或回到旧名字。
  2. 「fsync 返回 0 就绝对安全」:设备可能谎报 flush(廉价 U 盘、虚拟化 cache=unsafe);fsync 只保证内核把 flush 发出去了。
  3. 「close() 会刷盘」:不会,只释放 fd;错误也不会报告。
  4. 「sync_file_range 可以替代 fsync」:它只发起回写,不等待、不报告错误;数据库的 WAL 不能用它做持久化。
  5. 「tmpfs 上 fsync 没意义所以会失败」:tmpfs 的 fsync 返回 0,但数据只存在内存;容器里把数据放 /dev/shm 再 fsync,重启一定丢。

3.12.14 新挂载 API:fsopen、fsmount 与挂载传播

这一节回答:容器运行时怎么在不碰全局 /etc/mtab 的情况下拼出一个挂载树、mount --bind 与 open_tree 有什么区别、为什么 unshare -m 之后宿主的挂载会被污染。

从 mount(2) 到五段式 API

传统 mount(2) 把「解析参数、创建 super_block、挂到路径」塞进一次调用,既不好组合,也无法在挂载前检查参数。Linux 5.2 起引入新 API,把一次挂载拆成五步:

系统调用作用关键 flag
fsopen(fs_name, flags)打开一个「文件系统上下文」fdFSOPEN_CLOEXEC
fsconfig(fsfd, cmd, key, value, aux)设置参数/创建 super_blockFSCONFIG_SET_STRING、SET_FLAG、SET_FD、CMD_CREATE
fsmount(fsfd, flags, mount_attrs)把上下文变成一棵「detached 挂载树」,返回 mount fdFSMOUNT_CLOEXEC
move_mount(from, to, flags)把挂载树接到某个路径MOVE_MOUNT_F_EMPTY_PATH
open_tree(dfd, path, flags)以 fd 引用一棵挂载树(可克隆)OPEN_TREE_CLONE、OPEN_TREE_CLOEXEC

fsconfig() 的 cmd 取值:FSCONFIG_SET_FLAG=0、SET_STRING=1、SET_BINARY=2、SET_PATH=3、SET_FD=4、CMD_CREATE=5、CMD_RECONFIGURE=6。ext4 的 errors=remount-ro、data=ordered 都是 SET_STRING;loop 的 backing file 是 SET_FD。

一个最小的「无特权命名空间内挂 tmpfs」的流程(需要 CAP_SYS_ADMIN 在用户命名空间内):

/* 伪代码:错误检查省略,编译时需 _GNU_SOURCE 与 <linux/mount.h> */
int fsfd = fsopen("tmpfs", FSOPEN_CLOEXEC);
fsconfig(fsfd, FSCONFIG_SET_STRING, "size", "16m", 0);
fsconfig(fsfd, FSCONFIG_CMD_CREATE, NULL, NULL, 0);
int mfd = fsmount(fsfd, FSMOUNT_CLOEXEC, 0);
move_mount(mfd, "", AT_FDCWD, "/mnt/tmp", MOVE_MOUNT_F_EMPTY_PATH);
close(mfd);
close(fsfd);

挂载传播:MS_SHARED 为什么会把挂载泄漏给宿主

每个挂载点有一个传播类型:

类型行为
MS_PRIVATE只在本命名空间生效,不传播
MS_SHARED挂载/卸载事件传播到同 peer group 的其他挂载点
MS_SLAVE接收来自 master 的传播,但不回传
MS_UNBINDABLE既不可 bind,也不传播

systemd 启动时通常把 / 设为 MS_SHARED。容器运行时 unshare(CLONE_NEWNS) 之后,新命名空间继承这个共享关系;此时在容器里 mount 会通过 peer group 传播回宿主。正确做法是先 mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL),再挂载。

/proc/self/mountinfo 的第 7 个字段(可选字段)里有 shared:X、master:X、propagate_from:X、unbindable,这就是传播关系的证据。字段顺序:

字段含义
1mount ID
2parent ID
3major:minor
4挂载根(在文件系统内的路径)
5挂载点(在进程命名空间内的路径)
6挂载选项(rw/ro/relatime 等)
7可选字段(传播、idmapped 等)
8- 分隔符
9文件系统类型
10来源(设备名或 tmpfs)
11超级块选项

idmapped mount(5.12+)通过 mount_setattr() 的 MOUNT_ATTR_IDMAP 给一棵挂载树绑定一个用户命名空间,让同一份文件对不同容器呈现不同的 uid/gid。它比「chown 整个卷」安全得多,是 rootless 容器的关键机制。

chroot 不是安全边界

chroot() 只改 fs_struct->root,不阻止 .. 逃逸、不隔离挂载、不隔离 PID。容器早就不靠它:pivot_root() 换根、mount namespace 隔离挂载树、user namespace 限制权限。判断一个「容器」是否真隔离,看 /proc/self/status 的 CapEff、NSpid,而不是看它有没有 chroot。

3.12.15 观测:用 bpftrace 与 perf 看 VFS、ext4、JBD2

这一节回答:不写模块、不改内核,怎么量出「哪个进程在狂刷 fsync」「ext4 的日志提交等了多久」「dcache 命中率是多少」。

前提:内核开了 CONFIG_BPF、CONFIG_BPF_SYSCALL、CONFIG_KPROBE_EVENTS,装了 bpftrace;生产环境注意 probe 开销。

观测一:按进程统计 VFS 读写与 fsync 延迟

# 1) 每 5 秒打印一次:谁在读/写、各多少次
sudo bpftrace -e '
kprobe:vfs_read  { @read[comm]  = count(); }
kprobe:vfs_write { @write[comm] = count(); }
interval:s:5 { print(@read); print(@write); clear(@read); clear(@write); }'

预期输出(节选):

@read[systemd-journal]: 42
@read[sshd]: 118
@write[dd]: 90211
# 2) fsync 延迟直方图(微秒),按进程分桶
sudo bpftrace -e '
kprobe:vfs_fsync_range { @start[tid] = nsecs; }
kretprobe:vfs_fsync_range /@start[tid]/ {
	@us[comm] = hist((nsecs - @start[tid]) / 1000);
	delete(@start[tid]);
}'

典型双峰:@us[postgres] 在 100–500 µs 有一堆(日志已在页缓存),在 2–10 ms 有一堆(真的等设备 flush)。如果第二峰跑到 100 ms 以上,先查 iostat -x 的 f_await 和磁盘写缓存策略。

观测二:ext4 日志提交与回写

# JBD2 的 tracepoint:每个事务从 start 到 end 的耗时
sudo bpftrace -e '
tracepoint:jbd2:jbd2_start_commit { @t[tid] = nsecs; }
tracepoint:jbd2:jbd2_end_commit /@t[tid]/ {
	@commit_ms = hist((nsecs - @t[tid]) / 1000000);
	delete(@t[tid]);
}'
# 哪些文件在被回写(需要 6.x 的 writeback tracepoint)
sudo bpftrace -e 'tracepoint:writeback:writeback_dirty_inode { @[args->name] = count(); }'
# 核对:脏页与回写量
grep -E 'Dirty|Writeback|NFS_Unstable' /proc/meminfo
cat /sys/kernel/debug/bdi/*/stats 2>/dev/null | head -40

/proc/meminfo 的 Dirty 是全局脏页字节数;Writeback 是正在回写的字节数。Dirty 长期贴着 dirty_ratio 说明写者被限流;Writeback 高而 Dirty 低说明设备慢、回写队列堵住。

观测三:dcache 与路径解析

# 每秒 dcache 查找次数与未命中(lookup_slow 才真的读目录)
sudo bpftrace -e '
kprobe:d_lookup      { @fast = count(); }
kprobe:lookup_slow   { @slow = count(); }
interval:s:1 { printf("fast=%d slow=%d\n", @fast, @slow); clear(@fast); clear(@slow); }'

lookup_slow 的比例就是「dcache 未命中率」的粗略估计。它高不一定是问题:首次遍历大目录、容器频繁创建销毁都会让它高;持续高于 30% 且 dentry-state 的 nr_unused 很低,才值得查是不是 dcache 被压得太狠。

perf 侧:系统调用与块层

# 按进程统计 openat/fsync 的次数与耗时
sudo perf trace -e openat,openat2,fsync,fdatasync -p $(pgrep -n postgres) --duration 5

# ext4 的静态 tracepoint(内核编译时若启用)
sudo perf stat -e 'ext4:ext4_es_insert_extent' -a sleep 5

# 块层:谁在发 I/O、队列深度多少
sudo perf stat -e 'block:block_rq_issue,block:block_rq_complete' -a sleep 5

perf trace 的输出里,fsync 的耗时是「系统调用总时间」,包含等日志提交与 flush;要拆开就用 3.12.15 的 bpftrace 探针分别量 ext4_sync_file、jbd2_complete_transaction、blkdev_issue_flush。三个数加起来接近总时间,多出来的部分就是调度等待。

观测清单:先问哪一个
症状 先看 再看
写延迟尖刺 fsync 延迟直方图 JBD2 提交耗时、iostat f_await
读变慢 page cache 命中、read_ahead_kb filemap_fault 的 major fault
目录遍历慢 lookup_slow 比例 dentry-state、目录大小
磁盘写满但 df 不降 lsof +L1 debugfs stat 的 i_links_count
容器里挂载泄漏 mountinfo 的 shared: mount --make-rprivate /

3.12.16 陷阱清单:VFS 与文件系统篇

这一节把散在各处的坑集中成一张表,按「现象 → 原因 → 修复」排列。前 12 条在真实故障里出现频率最高。

#现象原因修复
1文件已 rm,df 不降还有进程持有 fd,i_nlink=0 但 i_count>0lsof +L1 找到进程;重启或让它 close
2进程卡在 D 状态,栈是 wait_on_bit新 inode 没有 unlock_new_inode()所有分支(含错误)都 unlock;错误用 iget_failed()
3BUG: scheduling while atomic在 RCU-walk/自旋锁区间里睡眠退回 ref-walk 再睡眠;might_sleep() 检查
4偶发 UAF,d_inode 指向已释放 inode跨睡眠点只存了裸 dentry 指针dget()/igrab() 持有引用,用完 dput()/iput()
5掉电后新文件「消失」或回到旧名字只 fsync 文件,没 fsync 目录先 fsync(file),rename 后再 fsync(dir)
6write+close 成功,重启后数据丢脏页未回写,close 不报错关键数据 fsync;批量写用「写 + 一次 fsync」
7O_DIRECT 读到的数据比 buffered 旧两套路径无一致性保证不混用;切换前 fsync 并丢弃缓存
8write 成功,fsync 返回 ENOSPC延迟分配在回写时才分配块预先 fallocate();处理 ENOSPC 并回滚
9unshare -m 后宿主机多出挂载点/ 是 MS_SHARED,挂载沿 peer group 传播先 mount --make-rprivate /
10容器「逃逸」到宿主目录把 chroot 当安全边界用 mount namespace + pivot_root + user namespace
11sync_file_range 后掉电仍丢数据它只发起回写,不等待、不报错持久化用 fsync/fdatasync
12NFS 上 rename 后文件「消失」客户端缓存与服务器回调有窗口fsync 目录、close-to-open 语义、必要时重开

再补三条版本相关的:

  • i_rwsem 的位置:6.5 前后 struct inode 的布局变过(内嵌信号量),照 4.x 的文章算偏移会错;写模块一律用字段名,不要硬编码偏移。
  • read_folio 取代 readpage:5.18 起 address_space_operations 的 readpage 改成 read_folio;6.8 写新文件系统要用新名字。
  • ->writepage 正在退场:6.8 的 buffered 写仍可用 writepage,但新代码应实现 writepages;iomap 与大 folio 都要求它。

3.12.17 本章出口检查

检查项达标线没达标去哪
画出四对象关系能说出谁指向谁、谁共享3.12.1
说清 dentry 与 inode 的分工硬链接=多 dentry 一 inode3.12.1
解释 RCU-walk 的退回条件-ECHILD 与 ref-walk3.12.2
说出 open 的五个阶段解析、查找、创建 file、->open、装 fd3.12.3
解释 write 与 fsync 的差别page cache vs 落盘三件事3.12.4
说清 ext4 的日志顺序数据先于元数据提交3.12.5
用 debugfs 看 extent能读出物理块号3.12.5
区分 i_count/i_nlink能说出何时 evict3.12.9
画出 page cache 读路径xarray → uptodate3.12.11
背出 ext4/JBD2 关键块magic 与偏移3.12.12
写出持久化三步写、rename、fsync 目录3.12.13
即时自测 · 3.12

Q1 /etc/hosts 被两个进程同时 open,它们共享哪些对象?各自的 f_pos 会互相影响吗?

Q2 一个文件有两个硬链接 /a 与 /b。dcache 里有几个 dentry?几个 inode?删除 /a 后 /b 还能打开吗?

Q3 write(fd, buf, 4096) 返回 4096 之后立刻掉电,数据会丢吗?如果紧接着 fsync(fd) 返回 0 再掉电呢?

Q4 ext4 的 data=ordered 与 data=writeback 在崩溃恢复后的差别是什么?

Q5 open(path, O_PATH) 得到的 fd 能 read 吗?它有什么用?

答案

A1 共享 super_block、inode、dentry(同一个文件);各自有独立的 struct file(不同的 f_pos、f_flags)与 fd。f_pos 不共享,所以两个进程各读各的;除非它们 dup/fork 共享同一个 struct file(那时 f_pos 才共享)。

A2 两个 dentry(/a、/b),一个 inode。删除 /a 只是去掉一个目录项、i_nlink 减一;/b 仍指向同一 inode,能正常打开。i_nlink 归零且无进程打开时才真正释放数据块。

A3 立刻掉电会丢——数据只在 page cache,没写盘。fsync 返回 0 后掉电不丢(假设设备遵守 flush 语义):数据页已写回、日志已提交、设备缓存已刷。若设备/虚拟化层不守规矩,仍可能丢,这是「fsync 之后丢数据」类 bug 的根源。

A4 data=ordered:数据块先写,元数据后提交;崩溃后文件要么是旧内容,要么是新内容,不会出现「大小是新的大小、数据是旧块」的混合。data=writeback:只保证元数据一致,数据块可能还是旧的,崩溃后可能读到旧数据(甚至垃圾),需要应用层校验。

A5 不能 read/write(返回 EBADF)。O_PATH 只用于「引用一个路径」:作为 openat/fstatat/fchmodat 的 dirfd、fstat、*at 系列,以及配合 openat2 做安全解析。它不创建真正的 file 操作表(empty_fops),开销极小。

母本对应

母本章节本书落点程度
OSTEP第 39 章 文件与目录3.12.1、3.12.3完整(inode/dentry/fd 的概念)
OSTEP第 40–42 章 文件系统实现、局部性、崩溃一致性3.12.4–3.12.6、3.12.5.1、3.12.12.1完整(布局、缓存、日志、写错/丢失写、位图)
OSTEP第 43 章 日志结构文件系统1.13.5、3.12.5合并(LFS 五机制在 1.13.5)
OSTEP第 44 章 数据完整性和保护3.12.5.1、3.13.5完整(写错/丢失写、校验和、flush/FUA)
奔跑吧 Linux 内核第 2 章 2.7 VMA 操作3.12.4背景(mmap 与 address_space)
Kerrisk第 14–18 章 文件系统与文件属性3.12.3、3.12.6完整(用户态接口与挂载)
李治军第 8 章 设备驱动3.12.1对照(0.11 的「文件视图」到设备)

下一站:3.13 块层、I/O 调度与持久化 继续往下:page cache 的脏页怎么变成 bio、blk-mq 怎么把它送到设备、fsync 的 flush 在块层怎么走。