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。
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)。
- 「
open会立刻读文件内容」:open只解析路径、分配struct file、调->open(),不读数据。数据在第一次read时才进 page cache。 - 「dcache 命中就不用碰磁盘」:命中只保证「名字到 inode 的映射」在内存里,inode 的元数据可能还要读(
iget未命中时)。 - 「路径越深越慢」:每层一次 dcache 查找,命中是 O(1) 哈希;慢在未命中的目录读取与符号链接展开。
- 「
..就是父目录」:..经过挂载点时会跨过挂载边界(follow_dotdot),在 chroot/namespace 里还受nd->root限制。 - 「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;
}
关键点:
write()只把数据拷进 page cache 并标脏,不碰磁盘。返回成功只代表「内核接下了」。balance_dirty_pages_ratelimited()在脏页超过阈值时主动限流(让写者睡眠),防止脏页把内存吃光。阈值由vm.dirty_background_ratio(默认 10%)与vm.dirty_ratio(默认 20%)控制。- 写回由内核线程
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() | 只提示回写某段,不保证落盘 |
- 「
write返回就安全了」:只保证进了 page cache。进程崩溃不丢(内核还活着),机器掉电会丢。 - 「
fsync一定比fdatasync慢」:通常是的(多刷元数据),但某些文件系统上差别很小;关键看是否需要 mtime/size 的持久化。 - 「
O_DIRECT绕过 page cache 就一定快」:它绕过缓存,但要求对齐(地址、偏移、长度按块大小),且失去预读与合并,随机小 I/O 反而更慢。 - 「读文件一定经过 page cache」:
O_DIRECT读绕过;mmap读也走 page cache(通过缺页),但MAP_POPULATE会预读。 - 「删除文件就释放空间」:还有进程持有 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 模式:
- 事务开始:
jbd2_journal_start()拿一个handle_t; - 修改元数据前:
jbd2_journal_get_write_access()告诉 JBD2「这个块要改」; - 改完元数据:
jbd2_journal_dirty_metadata()把块标记为脏(JBD2 会把它写进日志); - 事务提交:
jbd2_journal_stop(),JBD2 把日志块写盘、发 flush、写 commit record; - 检查点:日志空间回收时,元数据块被写到最终位置。
data=ordered 的「ordered」指:数据块先写盘,元数据才提交。这样崩溃后即使元数据指向新块,块里也已经是完整数据(不会出现「文件大小变了但内容是旧的/垃圾」)。另外两种模式:data=journal(数据也进日志,最安全最慢)、data=writeback(只保证元数据一致,数据可能旧)。
# 看当前挂载的日志模式
findmnt -no OPTIONS / | tr ',' '\n' | grep data=
# 看日志大小与事务
dumpe2fs -h /dev/vda1 | grep -i journal
因为日志里记录了「未完成的事务」。挂载时 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
| 项 | NFSv3 | NFSv4 |
|---|---|---|
| 状态 | 无状态(服务器不记客户端打开的文件) | 有状态(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 带来的三个边界
- 错误语义:本地盘的
EIO很少见,NFS 的ETIMEDOUT/ESTALE/EJUKEBOX是常态;应用必须能重试/重开,不能假设read一次成功。 - 持久化语义:
fsync()到 NFS 只保证「服务器已接收并落盘」(取决于导出选项sync/async);O_DIRECT、mmap的语义更弱。 - 锁与 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_mode | umode_t | 文件类型 + 权限位(S_IFREG、0777) | 磁盘 inode;chmod/chown 时改 |
i_uid / i_gid | kuid_t / kgid_t | 所有者(内核内部 uid,不是用户态 uid) | chown;用户命名空间映射后可能「看起来」不同 |
i_flags | unsigned int | 用户可见标志:FS_IMMUTABLE_FL、FS_APPEND_FL、FS_NODUMP_FL | ioctl(FS_IOC_SETFLAGS)/chattr |
i_ino | unsigned long | inode 号(文件系统内唯一) | 创建时定,不变 |
i_size | loff_t | 文件字节数 | write/truncate;读用 i_size_read() |
i_blocks | blkcnt_t | 已分配块数(512 字节为单位) | 分配/释放块时变;stat.st_blocks |
i_atime/i_mtime/i_ctime | struct timespec64 | 访问/修改/元数据变更时间 | 读、写、chmod;relatime/lazytime 影响落盘时机 |
i_nlink | unsigned int | 硬链接数(磁盘目录项计数) | link/unlink |
i_op | const struct inode_operations * | 名字与元数据操作(lookup/create/unlink/rename) | 文件系统初始化时定 |
i_fop | const struct file_operations * | 默认的打开后操作表 | open 时复制到 file->f_op |
i_sb | struct super_block * | 所属文件系统实例 | 创建时定 |
i_mapping | struct address_space * | 页缓存宿主,通常指向 &i_data | 创建时定;块设备可能指向 bdev->bd_mapping |
i_data | struct address_space | 内嵌的 address_space | 同上 |
i_count | atomic_t | 内存引用计数(不是链接数) | igrab/ihold/iput |
i_state | unsigned long | VFS 内部状态位(见下表) | VFS 在 iget/回写/evict 时改 |
i_rwsem | struct rw_semaphore | 串行化 ->lookup、->create、truncate 等 | VFS 拿,文件系统不必自己再加锁 |
i_hash | struct hlist_node | inode 哈希链节点 | insert_inode_hash/remove_inode_hash |
i_sb_list | struct list_head | 挂进 sb->s_inodes | 分配/销毁时 |
i_lru | struct list_head | inode LRU(可回收 inode) | inode_add_lru/inode_lru_isolate |
i_io_list | struct list_head | 回写链表(b_dirty/b_io/b_more_io) | 脏 inode 排队时 |
i_writecount | atomic_t | 写者计数;deny_write_access() 用它阻止「正在执行的文件被写」 | open(O_WRONLY)/mmap(PROT_WRITE) |
i_count、i_nlink、i_writecount 三个计数经常被混为一谈,实际含义完全不同:
| 计数 | 增加 | 减少 | 归零意味着 |
|---|---|---|---|
i_count | igrab()、ihold()、dcache 引用 | iput() | 没有任何内核对象引用它,可以进 LRU 或 evict |
i_nlink | link() 创建目录项 | unlink() 删除目录项 | 没有名字指向它;若还有 fd,文件仍可读写((deleted)) |
i_writecount | 以写方式 open、写映射 | close、解除映射 | 可以重新以写方式打开,或允许 execve |
i_state 是 VFS 的内部状态机,和用户可见的 i_flags 不是一回事:
| 状态位 | 值(6.8) | 含义 |
|---|---|---|
I_DIRTY_SYNC | 1<<0 | 元数据脏,fsync 要同步 |
I_DIRTY_DATASYNC | 1<<1 | 数据 + 必要元数据脏(fdatasync 关心) |
I_DIRTY_PAGES | 1<<2 | 有脏页 |
I_NEW | 1<<3 | 正在构造,尚未就绪;其他访问者必须等待 |
I_WILL_FREE | 1<<4 | 已决定释放,拒绝新引用 |
I_FREEING | 1<<5 | 正在 evict(),不能再 igrab() |
I_SYNC | 1<<7 | 正在回写;回写代码用它串行化同一个 inode |
I_REFERENCED | 1<<8 | LRU 二次机会标记 |
I_DIRTY_TIME | 1<<11 | lazytime 模式下「只脏了时间戳」 |
I_WB_SWITCH | 1<<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 长期驻留的是:
- dcache 中的正 dentry:每个正 dentry 都让 inode 的
i_count至少为 1; - 打开的文件:
file->f_path持有 dentry,dentry 又持有 inode; - 内核子系统的显式引用:
igrab()(如exportfs、fsnotify、fanotify); - 正在回写:
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() 返回 NULL | alloc_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 的负 dentry | VFS 用 DCACHE_PAR_LOOKUP 挡住,见 3.12.10 |
| inode 释放后仍被访问 | 只持有裸指针,没有 igrab() | 任何跨越睡眠点的保存都必须 igrab(),用完 iput() |
i_nlink 为 0 但 df 不降 | 还有 fd 打开 | lsof +L1;等最后一个 close() |
- 「
i_count就是硬链接数」:i_count是内存引用,i_nlink是磁盘链接数。ls -l的链接数看i_nlink,/proc/slabinfo的ext4_inode_cache活跃对象更接近i_count的总体规模。 - 「
iput()一定释放 inode」:只有i_count归零、且drop_inode()判定可丢弃时才evict();否则进 LRU,等prune_icache()。 - 「inode 号在文件系统内永远唯一,所以可以当全局 ID」:只在一个
super_block内唯一;不同文件系统、不同挂载命名空间里的同号 inode 毫无关系。跨文件系统识别要用statx()的stx_mnt_id+stx_ino(或name_to_handle_at()的 handle)。 - 「inode 缓存越大越好」:inode 缓存持有页缓存与 dentry,内存紧张时先回收它们;
echo 2 > /proc/sys/vm/drop_caches会清掉可回收的 dentry/inode,但不会碰脏页。 - 「
iget_locked()内部会读磁盘」:它只查哈希、分配空壳;读磁盘是调用者(ext4_iget()等)的事。I_NEW就是「空壳已建,内容未就绪」的标志。
3.12.10 dentry 缓存:哈希、并发 lookup 与回收
这一节回答:dcache 命中为什么是 O(1)、两个进程同时 open 同一个不存在的路径时谁去读磁盘、负 dentry 有什么用、dentry 什么时候被回收。
字段:一个名字的全部状态
| 字段 | 类型(6.8) | 含义 | 谁维护 / 何时变 |
|---|---|---|---|
d_flags | unsigned int | 缓存行为标志(见下表) | 文件系统/VFS 按 ops 设置 |
d_seq | seqcount_spinlock_t | RCU 查找的序列号;rename 时递增 | d_rehash/__d_move/d_instantiate |
d_hash | struct hlist_bl_node | 父目录哈希桶中的节点 | __d_add/__d_drop |
d_parent | struct dentry * | 父 dentry(RCU 下可能瞬间变化) | rename 时改 |
d_name | struct qstr | 名字:name 指针 + len + hash | 创建时定;rename 换名字 |
d_inode | struct inode * | 指向 inode;负 dentry 为 NULL | d_instantiate/d_delete |
d_lockref | struct lockref | 自旋锁 + 引用计数合体 | dget/dput/d_lock |
d_op | const struct dentry_operations * | 文件系统自定义:hash/compare/revalidate/delete/prune | 文件系统初始化 |
d_sb | struct super_block * | 所属文件系统 | 创建时定 |
d_lru | struct list_head | dentry LRU 链 | d_lru_add/d_lru_del |
d_child | struct list_head | 挂在父的 d_subdirs 上 | 分配/销毁 |
d_subdirs | struct list_head | 所有子 dentry 链 | 同上 |
d_u | union | d_alias(inode 别名链)/d_in_lookup_hash/d_rcu | 按状态复用 |
d_flags 只列常用的几位:
| 标志 | 值 | 含义 |
|---|---|---|
DCACHE_OP_HASH | 0x1 | 使用 d_op->d_hash(大小写不敏感等) |
DCACHE_OP_COMPARE | 0x2 | 使用 d_op->d_compare(大小写不敏感、Unicode 归一化) |
DCACHE_OP_REVALIDATE | 0x4 | 使用 d_op->d_revalidate(NFS 等网络文件系统) |
DCACHE_OP_DELETE | 0x8 | 使用 d_op->d_delete |
DCACHE_OP_PRUNE | 0x10 | 使用 d_op->d_prune |
DCACHE_DISCONNECTED | 0x20 | 暂无父目录(NFS 文件句柄打开、fsck 恢复) |
DCACHE_WHITEOUT | 0x80 | overlayfs 的白out 项 |
DCACHE_MOUNTED | 0x10000 | 该 dentry 是某个挂载点的根 |
DCACHE_NEED_AUTOMOUNT | 0x20000 | 需要触发自动挂载(autofs) |
DCACHE_MANAGE_TRANSIT | 0x40000 | 文件系统要接管跨挂载点解析 |
DCACHE_PAR_LOOKUP | BIT(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_WHITEOUT | overlayfs 用来表示「上层删除了下层文件」 | overlayfs |
负 dentry 是 dcache 最容易被低估的部分:它让「反复 stat 一个不存在的路径」不必每次都读目录。但负 dentry 也有代价——创建文件时必须先把它失效,否则 open(O_CREAT) 会看到「已缓存的不存在」。lookup_open() 里的 d_lookup + d_invalidate 就是处理这件事。
回收:LRU、shrinker 与 dput
dentry 的引用计数在 d_lockref.count。dput() 减一,归零后:
- 若
DCACHE_OP_DELETE且d_op->d_delete()返回 1,直接dentry_free(); - 否则挂到
sb->s_dentry_lru(d_lru_add()),等待内存压力; dentry_shrinker注册在sb->s_shrink上,shrink_dentry_list()从 LRU 尾部摘取,检查引用计数仍为 0、没有子 dentry、不是挂载点,才dentry_kill();dentry_kill()做__d_drop()(从哈希摘除)、dentry_unlink_inode()(iputinode)、dentry_free()(RCU 延迟释放,因为 RCU-walk 可能还在读)。
retain_dentry() 是「二次机会」:DCACHE_REFERENCED 或仍有子 dentry 时把它放回 LRU 头部。/proc/sys/fs/dentry-state 的 nr_unused 就是 LRU 上可回收的数量。
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) | 含义 |
|---|---|---|
host | struct inode * | 宿主 inode(块设备的 mapping 指向 bdev) |
i_pages | struct xarray | 页号 → folio 的映射;带 DIRTY/WRITEBACK/TOWRITE 三个标记 |
i_mmap | struct rb_root_cached | 所有映射该文件的 VMA,按虚拟地址排序(RMAP 用) |
i_mmap_writable | atomic_t | 可写映射计数(ETXTBSY 判断的一部分) |
nrpages | unsigned long | 缓存页数(不含被换出的) |
writeback_index | pgoff_t | 回写扫描的起点,避免每次都从 0 开始 |
a_ops | const struct address_space_operations * | read_folio/writepages/dirty_folio/write_begin/write_end |
flags | unsigned long | AS_UNEVICTABLE、AS_EXITING、AS_NO_WRITEBACK_TAGS 等 |
i_mmap_rwsem | struct rw_semaphore | 保护 i_mmap 与缺页路径 |
invalidate_lock | struct rw_semaphore | 保护 truncate 与 page fault 的竞争(5.17+) |
private_lock/private_list | spinlock_t/list_head | 文件系统私有(如 ext4 的 i_es_tree 不在这里) |
wb_err | errseq_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_miss | mmap 预读的「未命中」计数,避免随机 mmap 浪费 |
prev_pos | 上一次读到的位置,用来判断是否顺序 |
算法(ondemand_readahead())的核心:
- 首次读:预读
ra_pages的一部分(通常 2 页); - 顺序读命中预读窗口:
size翻倍,直到ra_pages上限; - 随机读:清空窗口,只读当前页;
- 预读的页被访问到
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_ratio | 10% | 超过它,后台 kworker 开始写回 |
vm.dirty_ratio | 20% | 超过它,写者被强制阻塞(同步写回) |
vm.dirty_background_bytes | 0 | 以字节设置后台阈值,非 0 时覆盖 ratio |
vm.dirty_bytes | 0 | 以字节设置硬阈值 |
vm.dirty_expire_centisecs | 3000 | 脏页超过 30 秒必须写回 |
vm.dirty_writeback_centisecs | 500 | 回写线程每 5 秒醒一次 |
vm.dirtytime_expire_seconds | 43200 | lazytime 时间戳的过期时间(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 计数。
- 「
i_size之内的页一定在缓存里」:不一定;页可能被回收,读时再缺页。mincore()只能告诉你「现在在不在」。 - 「预读总是划算」:随机读时预读会浪费带宽与缓存;数据库通常用
POSIX_FADV_RANDOM或O_DIRECT。 - 「
mmap写不经过 page cache」:正好相反,mmap写就是改 page cache;msync()才触发回写。 - 「
O_DIRECT与 buffered I/O 自动一致」:不。两者可能同时访问同一文件区间,内核不保证顺序;混用时必须自己fsync或用invalidate_inode_pages2()语义。 - 「
write()返回短写一定是错」:buffered 写一般写满;O_DIRECT、信号、RLIMIT_FSIZE都可能短写。短写后必须从buf + n继续。 - 「页缓存会在
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 字节。常用字段(偏移相对超级块起始):
| 偏移 | 字段 | 含义 |
|---|---|---|
0x00 | s_inodes_count | inode 总数 |
0x04 | s_blocks_count_lo | 块总数低 32 位 |
0x0C | s_free_blocks_count_lo | 空闲块数低 32 位 |
0x10 | s_free_inodes_count | 空闲 inode 数 |
0x18 | s_log_block_size | 块大小 = 1024 << 该值 |
0x20 | s_blocks_per_group | 每组块数(通常 32768) |
0x28 | s_inodes_per_group | 每组 inode 数 |
0x38 | s_magic | 0xEF53 |
0x3A | s_state | 1=clean,2=error,4=orphan recovery |
0x54 | s_first_ino | 第一个非保留 inode(通常 11) |
0x58 | s_inode_size | inode 大小(默认 256) |
0x5C | s_feature_compat | 兼容特性位 |
0x60 | s_feature_incompat | 不兼容特性位(不认识就不能挂) |
0x64 | s_feature_ro_compat | 只读兼容特性位(不认识只能只读挂) |
0x68 | s_uuid[16] | 文件系统 UUID |
0xE0 | s_journal_inum | 日志 inode 号(内部日志) |
0xE4 | s_journal_dev | 外部日志设备号(0 表示内部) |
0xE8 | s_last_orphan | 孤儿 inode 链头 |
0xFE | s_desc_size | 组描述符大小(32 或 64) |
0x150 | s_blocks_count_hi | 块总数高 32 位(64bit 特性) |
0x2FC | s_checksum | 超级块 CRC32c |
特性位决定「这个文件系统能不能挂」:
| 类别 | 常用位 | 含义 |
|---|---|---|
| compat | HAS_JOURNAL 0x4、EXT_ATTR 0x8、DIR_INDEX 0x20、ORPHAN_FILE 0x1000 | 不认识也能安全读写 |
| incompat | FILETYPE 0x2、RECOVER 0x4、EXTENTS 0x40、64BIT 0x80、FLEX_BG 0x200、INLINE_DATA 0x8000、ENCRYPT 0x10000 | 不认识就拒绝挂载 |
| ro_compat | SPARSE_SUPER 0x1、LARGE_FILE 0x2、HUGE_FILE 0x8、METADATA_CSUM 0x400、PROJECT 0x2000 | 不认识只能只读挂 |
RECOVER 位是「日志需要重放」的标记:mount 时若置位,JBD2 先重放日志,再清位;e2fsck 看到它就知道上次没干净卸载。
组描述符与 inode 表
每个块组有一份 ext4_group_desc(32 字节;开 64bit 后 64 字节):
| 偏移 | 字段 | 含义 |
|---|---|---|
0x00 | bg_block_bitmap_lo | 块位图块号 |
0x04 | bg_inode_bitmap_lo | inode 位图块号 |
0x08 | bg_inode_table_lo | inode 表起始块号 |
0x0C | bg_free_blocks_count_lo | 组内空闲块数 |
0x0E | bg_free_inodes_count_lo | 组内空闲 inode 数 |
0x10 | bg_used_dirs_count_lo | 组内目录数 |
0x12 | bg_flags | INODE_UNINIT、BLOCK_UNINIT、INODE_ZEROED |
0x1E | bg_checksum | 组描述符 CRC32c(metadata_csum) |
bg_flags 的 UNINIT 位是懒初始化:新格式化的大文件系统不必把每个组的位图都写一遍,挂载后第一次用到才初始化。
inode 表是 s_inodes_per_group 个 inode 的数组,每个 s_inode_size 字节。ext4 inode 的前 160 字节布局稳定:
| 偏移 | 字段 | 含义 |
|---|---|---|
0x00 | i_mode | 类型 + 权限 |
0x02 | i_uid | 所有者低 16 位(高 16 位在 i_osd2) |
0x04 | i_size_lo | 文件大小低 32 位 |
0x08/0x0C/0x10 | i_atime/i_ctime/i_mtime | 秒级时间戳 |
0x14 | i_dtime | 删除时间 |
0x18 | i_gid | 组低 16 位 |
0x1A | i_links_count | 硬链接数 |
0x1C | i_blocks_lo | 512 字节块计数 |
0x20 | i_flags | EXT4_EXTENTS_FL 0x80000、EXT4_INLINE_DATA_FL 0x10000000 等 |
0x28 | i_block[15] | 60 字节:extent 树根或 15 个块指针 |
0x6C | i_size_high | 文件大小高 32 位 |
0x80 | i_extra_isize | 额外 inode 字段大小 |
0x84/0x88/0x8C | i_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 第 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 是否占用。分配流程:
- 在位图里找第一个 0 位(
ext4_find_next_zero_bit); - 用
test_and_set_bit原子占用; - 更新组描述符的
bg_free_blocks_count; - 若在事务中,位图块与组描述符都进日志。
崩溃一致性靠日志:位图与组描述符的修改在同一个事务里,要么都提交,要么都回滚。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 报告一次,不会无限重复。
这带来两个实践结论:
fsync返回EIO时,之前的write可能已经丢了;必须把这段数据当作不可信,回滚或重写。close()不报告回写错误。只write+close的程序可能静默丢数据;要么fsync,要么用sync_file_range+ 定期fsync。
| errno | 何时出现 | 处理 |
|---|---|---|
EBADF | fd 无效或不是打开的文件 | 编程错误 |
EINVAL | 文件系统不支持 fsync(少数伪文件系统) | 忽略或改用 syncfs |
EIO | 回写/设备错误 | 数据不可信,重试或告警 |
ENOSPC | 延迟分配在回写时才发现没空间 | 预留空间(fallocate)或减小写入 |
EROFS | 文件系统已只读(errors=remount-ro) | 停止写入 |
EINTR | 等待期间被信号打断(部分文件系统) | 重试 |
- 「
fsync目录没必要」:新建/删除/rename 都改目录项;不fsync目录,掉电后文件可能「消失」或回到旧名字。 - 「
fsync返回 0 就绝对安全」:设备可能谎报 flush(廉价 U 盘、虚拟化cache=unsafe);fsync只保证内核把 flush 发出去了。 - 「
close()会刷盘」:不会,只释放 fd;错误也不会报告。 - 「
sync_file_range可以替代fsync」:它只发起回写,不等待、不报告错误;数据库的 WAL 不能用它做持久化。 - 「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) | 打开一个「文件系统上下文」fd | FSOPEN_CLOEXEC |
fsconfig(fsfd, cmd, key, value, aux) | 设置参数/创建 super_block | FSCONFIG_SET_STRING、SET_FLAG、SET_FD、CMD_CREATE |
fsmount(fsfd, flags, mount_attrs) | 把上下文变成一棵「detached 挂载树」,返回 mount fd | FSMOUNT_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,这就是传播关系的证据。字段顺序:
| 字段 | 含义 |
|---|---|
| 1 | mount ID |
| 2 | parent ID |
| 3 | major: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() 只改 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>0 | lsof +L1 找到进程;重启或让它 close |
| 2 | 进程卡在 D 状态,栈是 wait_on_bit | 新 inode 没有 unlock_new_inode() | 所有分支(含错误)都 unlock;错误用 iget_failed() |
| 3 | BUG: 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) |
| 6 | write+close 成功,重启后数据丢 | 脏页未回写,close 不报错 | 关键数据 fsync;批量写用「写 + 一次 fsync」 |
| 7 | O_DIRECT 读到的数据比 buffered 旧 | 两套路径无一致性保证 | 不混用;切换前 fsync 并丢弃缓存 |
| 8 | write 成功,fsync 返回 ENOSPC | 延迟分配在回写时才分配块 | 预先 fallocate();处理 ENOSPC 并回滚 |
| 9 | unshare -m 后宿主机多出挂载点 | / 是 MS_SHARED,挂载沿 peer group 传播 | 先 mount --make-rprivate / |
| 10 | 容器「逃逸」到宿主目录 | 把 chroot 当安全边界 | 用 mount namespace + pivot_root + user namespace |
| 11 | sync_file_range 后掉电仍丢数据 | 它只发起回写,不等待、不报错 | 持久化用 fsync/fdatasync |
| 12 | NFS 上 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 一 inode | 3.12.1 |
| 解释 RCU-walk 的退回条件 | -ECHILD 与 ref-walk | 3.12.2 |
| 说出 open 的五个阶段 | 解析、查找、创建 file、->open、装 fd | 3.12.3 |
| 解释 write 与 fsync 的差别 | page cache vs 落盘三件事 | 3.12.4 |
| 说清 ext4 的日志顺序 | 数据先于元数据提交 | 3.12.5 |
用 debugfs 看 extent | 能读出物理块号 | 3.12.5 |
区分 i_count/i_nlink | 能说出何时 evict | 3.12.9 |
| 画出 page cache 读路径 | xarray → uptodate | 3.12.11 |
| 背出 ext4/JBD2 关键块 | magic 与偏移 | 3.12.12 |
| 写出持久化三步 | 写、rename、fsync 目录 | 3.12.13 |
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 在块层怎么走。