Filesystem and I/O
文件系统基础概念
文件的抽象:一切皆文件(regular file / directory / device / pipe / socket)
路径解析:绝对/相对路径;/ 根;..、.;符号链接解析(软链接)
Inode
存在位置: 磁盘上(在文件系统的特定区域,如 Inode 表)。当需要时,内核会将其读取到内存中。
inode(index node):表示“一个文件对象”的内核结构/文件系统对象。
它存 文件元数据,典型包括:
- 文件类型与权限(mode)、UID/GID、link count
- 大小、时间戳(atime/mtime/ctime)
- 指向数据块的索引信息(extent/块指针等,具体取决于文件系统)
- 可能还有 ACL、xattr 等
不存文件名(这是面试最常问的一句)
dentry
存在位置: 内存中(通常作为目录项缓存 (Dentry Cache) 存在),不持久化存储在磁盘上。
dentry(directory entry):表示“路径名中的一个组件”的内核对象。
- 它解决的问题是:文件名如何映射到 inode。
- 典型包含:
- 该组件的名字(name)
- 指向 inode 的指针(d_inode)
- 父目录 dentry 指针(d_parent)
- 以及大量用于缓存/哈希/引用计数的字段
- dentry 常常来自目录文件内容里的“目录项”,但在 VFS 中还会形成缓存对象(dcache)。
一句话:dentry 是 “名字(name)→ inode 的映射 + 路径组件关系”。
目录本身也是一个文件(有 inode)。
目录的数据区存的是一组“目录项”(文件名 + 指向 inode 的标识/号),不同文件系统组织方式不同。
路径解析
以打开 /home/alice/a.txt 为例(简化):
- 从根 dentry 开始,查找
"home" - 先查 dcache:有没有
"home"这个子 dentry - 有:直接得到对应 inode(目录 inode)
- 没有:读目录文件的数据区(查目录项),创建并缓存该 dentry
- 进入
home目录,重复查"alice"、再查"a.txt" - 最终得到
a.txt的 inode,再创建/返回 file object
缓存:icache 与 dcache
- icache:inode 缓存(同一个 inode 可能被多处引用)
- dcache:dentry 缓存(路径组件缓存,命中就不用读目录块)
- Linux 甚至支持 negative dentry(“这个名字不存在”的缓存),避免反复查不存在的文件。
硬链接(hard link)
本质:新增一个目录项指向同一个 inode(同一份文件内容/元数据)。
特点:
- 不能跨文件系统(不同分区/挂载点 inode 不通用)
- 通常不能给目录建硬链接(防止目录环导致遍历/回收复杂;只有
./..是特例) - 删除任意一个名字不影响数据:只有当 link count 变 0 且没有进程打开,数据才真正释放
stat看起来像“同一个文件”(inode 号相同)
适合:你想要多个名字共享同一份真实内容,并且希望任何一个名字删除后内容仍保留(直到都删完)。
软链接(symbolic link / symlink)
- 本质:一个独立文件(有自己的 inode),内容是目标路径字符串。
- 特点:
- 可以跨文件系统
- 可以指向目录、也可以指向不存在的目标(会形成“断链”)
- 目标若被移动/删除,软链接可能失效(除非路径仍可解析)
lstat看的是链接本身,stat看的会跟随链接
适合:你想要指向某个路径,像“快捷方式/别名”,并且希望跨文件系统或指向目录。
硬链接 vs 软链接
- 硬链接:多个名字指向同一个 inode;删除一个名字不等于删除数据(link count 归零才释放)
- 软链接:独立 inode,内容是目标路径;目标删了会“断链”
权限与所有权:rwx、ugo、umask、setuid/setgid/sticky(目录 sticky 常问:/tmp)
文件类型与 stat:stat/lstat/fstat;S_ISREG/S_ISDIR/...
时间戳:atime/mtime/ctime(Linux ctime 是“状态改变时间”,不是 creation time)
文件描述符 FD
就是 Linux/Unix 里进程用来访问“打开的对象”的一个小整数句柄。
每个进程都有一张 fd 表(数组/表结构)表项指向内核里的一个对象:struct file(打开文件对象)
open file description 里有:文件偏移 offset、状态 flags(O_APPEND/O_NONBLOCK…)
fork 后 FD 继承:父子共享同一个 open file description(offset 会相互影响)
dup 后两个 FD 共享 offset/flags(同一个 open file description)
文件 I/O API(POSIX)与 C++ I/O 的区别
POSIX 低层 I/O(系统调用)
open/close/read/write/lseekpread/pwrite(不改变文件 offset,线程更友好)fsync/fdatasync(落盘语义与性能)fcntl(FD flags、锁、非阻塞、复制/设置等)dup/dup2/dup3(文件描述符复制、重定向)- 重点能讲:系统调用会进入内核;有缓冲/页缓存;返回值与 errno;短读短写处理。
open
open("/a/b.txt", O_RDONLY) 大致做这些事:
- 陷入内核(syscall),检查参数与权限。
- 路径解析:逐级查 dentry 缓存(dcache),必要时读目录块;最终找到目标 inode。
- 建立 file object(“一次打开”的实例):里面有
- 当前文件偏移
f_pos - 打开标志(O_APPEND/O_NONBLOCK…)
- 指向 inode / 文件操作表等
- 在进程的 fd table 分配一个整数
fd,返回给用户态。
面试常追问:
fork后父子共享同一个 open file description(所以 offset 会互相影响),dup也类似。
O_DIRECT
open(..., O_DIRECT) 会让内核尽量不经过 page cache,而是把数据更“直接”地在 用户缓冲区 ↔ 块设备 之间搬运(仍然会经过块层/驱动,不是“完全不经内核”)。
避免 page cache 占用内存、避免“双缓存”
read
假设你调用 read(fd, buf, n):
- 用户态 → 内核态:syscall 进入内核;检查 fd 合法、是否可读等。
- 从
fd找到对应的 file object,确定读的位置(f_pos或pread指定的 offset)。 - 查页缓存(page cache):文件的这一段数据是否已经在内存页里?
- 命中:直接把页缓存里的数据 copy_to_user 到用户缓冲区
buf,更新f_pos,返回读到的字节数。 - 未命中:触发缺页式的“读入”流程:
- 文件系统把“文件 offset → 磁盘块/extent”映射出来
- 通过块层提交 I/O 请求(生成 bio/request)
- 设备驱动发给磁盘/SSD(可能用 DMA)
- I/O 完成中断/回调,数据进入 页缓存
- 再 copy_to_user 给用户态
- 预读(readahead):顺序读时,内核常会顺便提前读后续页,提高吞吐。
- 返回用户态(可能出现短读:比如到 EOF、信号打断、非阻塞等)。
关键点:常见的
read()并不是“直接从磁盘读到用户 buf”,而是 磁盘 → 页缓存 → 拷贝到用户态。
write
- 用户态 → 内核态:检查 fd 是否可写、是否需要追加(O_APPEND)、是否有文件锁/权限等。
- 确定写入位置(
f_pos,或 O_APPEND 下原子定位到 EOF)。 - 写入页缓存:
- 把用户数据 copy_from_user 到页缓存对应的 page 中
- 标记这些页为 dirty(脏页)
- 更新 inode 大小、时间戳,更新
f_pos write()通常此时就返回成功(只保证数据进了内核缓存,不保证落盘)。- 异步回写到磁盘(后台 writeback):
- 回写线程按策略把脏页刷到磁盘
- 若文件系统有日志(如 ext4、xfs),还涉及 journal/元数据一致性 的提交顺序
- 如果你显式要求更强持久化:
fsync(fd):等待该文件的数据/必要元数据尽量落盘- 更严谨的“写临时文件 + rename + fsync 目录”用于崩溃一致性
关键点:
write()的“成功”通常意味着写进页缓存,不是“写进硬盘”。
mmap
它做什么
mmap把文件的一段映射到进程虚拟地址空间,之后你对这段内存的读写像访问数组一样。- 在进程的 VMA(virtual memory area) 结构里记录一段区间
读:缺页(page fault)驱动
- 第一次访问某个映射页时,如果该页不在内存,会触发 page fault:
- 内核把“文件 offset → 磁盘块”映射出来,发起磁盘读
- 数据进入 page cache,并建立页表映射
- 程序继续执行(你感觉不到 read 调用)
写:变脏(dirty)+ 延迟回写
- 对
MAP_SHARED的写: - 写的是对应的 page cache 页
- 页被标记为 dirty
- 之后由内核回写线程异步刷盘(或你手动触发)
持久化/可见性怎么保证
msync(addr, len, MS_SYNC):要求把这段映射的脏页刷到磁盘后再返回(更强语义)。fsync(fd):对文件 fd 做同步,通常也会把相关脏页刷下去(实现细节依赖内核/FS,但面试里可以说“用于强制落盘”)。MAP_PRIVATE:写时复制(COW),改动不回写到文件。
mmap 的常见坑
- 文件被截断/底层 I/O 错误,访问映射区可能触发 SIGBUS。
- 并发与一致性:多个进程映射同一文件,
MAP_SHARED下会共享看到修改,但仍需自己做同步(锁/原子/协议)。
Page fault
系统调用 → 查页缓存 → 未命中则块层/驱动发 I/O → DMA 入内存 → 中断完成 → 唤醒进程 → 拷贝/映射给用户。
-
应用发起读请求
read(fd, buf, n),mmap后访问内存触发缺页 -
进入内核:VFS + 文件系统定位数据块
- 通过 VFS(虚拟文件系统层)找到这个
fd对应的文件对象、权限等 - 文件系统(ext4/xfs 等)根据“文件偏移量”定位到对应的 逻辑块/物理块
- 准备把这些块读进 页缓存 page cache
- 先查:数据是否已经在内存缓存里?
- 命中 page cache:数据已经在内存页里
read:内核把 page cache 里的数据 拷贝到用户缓冲区 buf(copy_to_user)然后返回
- 未命中:发起真正的磁盘 I/O
- 内核创建/合并一个 块 I/O 请求(bio/request),交给块层(block layer)
- 经过 I/O 调度(合并相邻请求、排序等,现代 NVMe 场景可能更简化)
- 进入 设备驱动(SATA/SAS/NVMe 驱动),由驱动去和 磁盘控制器打交道
- 控制器执行读操作,把数据从磁盘介质读出,通常通过 DMA 直接写入主存里内核准备好的缓冲区/页(减少 CPU 搬运)
- I/O 完成:中断/回调 + 唤醒进程
- 磁盘控制器读完后触发 硬件中断(或轮询完成)
- 驱动的中断处理/完成回调运行:标记请求完成、更新状态
- 数据页进入/更新到 page cache,并做必要的校验、解锁页等
- 唤醒等待该 I/O 的进程,把它从 阻塞态 → 就绪态,等待调度
进程被调度到运行后:
read:把 page cache 的数据 拷贝到用户 buf,系统调用返回,用户态拿到数据mmap:缺页处理完成后,指令重试,进程直接访问那页内存
VFS
VFS 是 Virtual File System(虚拟文件系统)。它是操作系统内核里的一层“统一接口/抽象层”,把各种不同类型的文件系统(ext4、XFS、FAT、NTFS、NFS、procfs、tmpfs……)都包装成同一种访问方式.
VFS 里常见的核心对象
inode:描述一个文件的元数据(权限、时间戳、大小、数据块位置等)。
dentry(目录项):名字到 inode 的映射缓存,用于加速路径查找(比如 /home/a.txt)。
superblock:描述一个已挂载文件系统的整体信息(类型、块大小、操作函数表等)。
file object:一次 open() 打开后的“打开文件实例”,对应进程里的文件描述符 fd。
RAID
RAID:把多块磁盘组成一个逻辑磁盘,用 条带化 / 镜像 / 校验 来在 性能、容量、可靠性之间做权衡。
-
RAID 0(Striping)
-
目的:性能
-
容错:无(坏 1 块全挂)
-
容量:N × 单盘
-
RAID 1(Mirroring)
目的:可靠性
容错:每个镜像组可坏 1 块(两盘镜像就是坏 1 块)
容量:50%
特点:读可并行、写要写两份
- RAID 5(Striping + 分布式单校验)
最少:3 块
容错:可坏 1 块
容量:(N-1) × 单盘
- RAID 6(双校验)
最少:4 块
容错:可坏 2 块
容量:(N-2) × 单盘
特点:更安全;写更慢、重建更重
-
RAID 10(1+0:镜像后条带)
-
最少:4 块
-
容错:每个镜像组可坏 1 块(但“坏法”有讲究)
-
容量:50%
-
特点:性能+可靠性都强,数据库/高 IO 常用
C/C++ 高层 I/O(库)
fopen/fread/fwrite(用户态缓冲)iostream / fstream(类型安全、格式化;同样有缓冲与同步问题)- 常问对比:
- POSIX:更贴近 OS,可控性强(非阻塞、epoll、fd 复用)
- stdio/iostream:更易用,但缓冲/同步/性能不可控、与 fd 混用要小心(
std::ios::sync_with_stdio、fflush、fsync的区别)
同步/异步 I/O
同步 I/O(Synchronous I/O)
特点:调用发出去后,当前线程一直等到 I/O 完成(或至少等到“可用结果”)才返回。
- 典型:
read()读磁盘文件、write()(在需要刷盘/阻塞的情况下)、阻塞 socket 的recv()/send()
异步 I/O(Asynchronous I/O, AIO)
特点:调用发出去后立即返回;I/O 在后台进行;完成后用“事件/回调/信号/完成队列”等方式通知你。
- 你提交:
submit_io(...)→ 立刻返回 - 你获取完成:
get_events(...)/ 回调 / 完成队列
缓冲、页缓存、落盘语义
页缓存(page cache)
- 缓存对象:以 内存页(page,常见 4KB) 为单位缓存文件内容。
- 服务对象:主要服务于文件系统的文件 I/O(
read/write/mmap)。 - 典型工作方式
read():先查 page cache,命中直接拷贝到用户缓冲区;未命中再从磁盘读入 page cache。write():先写入 page cache,把页标记为 dirty,之后异步回写磁盘(或fsync强制刷盘)。mmap:访问映射地址触发缺页,从 page cache 提供数据;写共享映射会把页弄脏并回写。
一句话:page cache = “按页缓存文件数据”的缓存。
用户态缓冲:stdio/iostream 的 buffer(fflush 只到内核)
内核页缓存(page cache):write() 通常只是写到页缓存,未必落盘
落盘相关:
fsync(fd):文件数据 + 元数据尽量落盘fdatasync(fd):主要数据(少量必要元数据)- 目录项/rename 的落盘:有时需要对目录 fd
fsync(追求崩溃一致性时)
Direct I/O:O_DIRECT 绕过页缓存(对齐要求,适用场景有限)
mmap vs read/write:页缓存仍可能参与;mmap 需要 msync 才更接近“落盘”
Buffer Cache
buffer cache传统上缓存的是:块设备 I/O 的“块”(block)
超级块、inode 表、位图(block bitmap)、目录块等(这些通常以块为单位管理)
块设备(block device)是一类 I/O 设备抽象:它把数据看成一块一块的固定大小单元(块,block)来存取,并且通常支持按块随机访问
原子性与一致性:哪些操作是原子的?
原子性的常见结论(POSIX/Linux 语义)
rename():目录项替换通常是原子(常用来做“写临时文件→rename”)write():对 pipe/FIFO 在PIPE_BUF以内通常原子;对普通文件不保证多线程/多进程写不交错O_APPEND:定位到 EOF 是原子动作,但写入内容仍可能交错(尤其是多次 write)
一致性套路(崩溃安全写文件)
- 写到临时文件
fsync(temp)rename(temp, target)fsync(dir)(更严谨)
常见文件系统操作与语义
mkdir/rmdir/unlink/remove
truncate/ftruncate(把文件变短/变长的语义)
chmod/chown/umask
statfs(文件系统信息:块大小、剩余空间等)
空间分配与“洞文件”:sparse file;lseek 跳过创建空洞
文件锁
flock(整文件锁,语义简单)fcntl记录锁(可锁区间;注意是“建议锁”advisory,需配合)
目录遍历:opendir/readdir;或 C++17 std::filesystem::directory_iterator
I/O 多路复用与非阻塞
核心: 让一个线程同时“等很多 fd 的就绪事件”,哪个可读/可写就告诉你,然后你再去 read/write。
阻塞/非阻塞:O_NONBLOCK
I/O 复用模型:select/poll/epoll(Linux)
- select:fd 数量受限、拷贝开销大
- poll:无固定上限但仍线性扫描
- epoll:事件驱动,适合大量连接(水平/边缘触发 LT/ET)
配合 read/write 的正确姿势
- 处理
EAGAIN/EWOULDBLOCK - 循环读写直到耗尽
- 注意半包/粘包属于协议层问题(socket 常问)
epoll
把关注的 fd 先 epoll_ctl 注册到内核里(一次性)
epoll_wait 直接拿“就绪列表”,避免每次全量拷贝/扫描(实现上更高效)
支持两种触发方式:
- LT(水平触发):只要还有数据没读完,会一直通知(更好写)
- ET(边沿触发):状态从“无→有”才通知一次(更高效但更难写,必须读到
EAGAIN)
mmap(映射文件)专题
mmap/munmap/msync/mprotect
private vs shared:MAP_PRIVATE(写时复制)/MAP_SHARED(写回文件)
页缺失、按需调页(page fault)
适用场景:大文件随机读、零拷贝思路、共享内存
风险点:SIGBUS(底层文件截断/IO 错误)、一致性与落盘时机
零拷贝与高性能 I/O
sendfile:文件 → socket 直接走内核路径
splice/tee:管道与 fd 间移动数据
io_uring(Linux 新一代异步 I/O)
- 思路:共享 ring buffer,减少 syscalls/上下文切换
- 面试常问:和 epoll、传统 AIO 的区别、优势劣势
常见错误码与健壮性
EINTR:系统调用被信号打断(要重试,或用 SA_RESTART)
EAGAIN/EWOULDBLOCK:非阻塞没数据
EBADF:fd 无效
ENOSPC:磁盘满
EXDEV:跨文件系统 rename 失败(需要 copy + unlink)
EMFILE/ENFILE:进程/系统 fd 用尽
短读短写:网络/管道/非阻塞/信号等都可能导致;写必须循环直到全部写完
权限与安全
Unix 权限:rwx + ugo,chmod/chown
特殊位:setuid/setgid/sticky
ACL(扩展权限)了解:getfacl/setfacl
umask:默认权限掩码
C++17/20 std::filesystem
path:拼接、规范化、扩展名、文件名等
exists/is_regular_file/is_directory/file_size/last_write_time
create_directories/remove/rename/copy
directory_iterator/recursive_directory_iterator
注意异常 vs std::error_code 版本 API(面试会问你更倾向哪种)
文件系统中 inode 是什么?都包含哪些信息?
软链接和硬链接的区别?在什么场景下使用?
说一下操作系统中一次文件读写的大致流程。
缓冲区缓存(buffer cache)、页缓存(page cache)分别是什么?
同步 I/O 和异步 I/O 的区别?
阻塞 I/O、非阻塞 I/O、I/O 多路复用(select/poll/epoll) 区别和适用场景?
epoll 与 select/poll 的区别?为什么 epoll 更高效?
零拷贝(zero-copy) 是什么?举几个常见的系统调用或机制。
写时缓存(write-back)和写透(write-through)策略是什么?
什么是日志文件系统(Journaling FS)?为什么更安全?
RAID 的基本思想是什么?不同级别(RAID 0/1/5/10)的大致特点?
read/write 为什么会“短读短写”?怎么写一个 write_all?
fflush 和 fsync 区别?什么时候数据才算真正落盘?
fork 后父子进程对同一 fd 写文件会发生什么?offset 是否共享?
mmap 与 read/write 对比,优缺点?
rename 为什么常用于原子更新配置文件?
O_APPEND 是否能保证多进程写日志不乱?怎么做更可靠?
select/poll/epoll 对比,ET 模式注意什么?
硬链接/软链接区别?inode 里有什么?
stat 与 lstat 区别?
文件锁 flock vs fcntl 的差异与适用场景?