Skip to content

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 为例(简化):

  1. 从根 dentry 开始,查找 "home"
  2. 先查 dcache:有没有 "home" 这个子 dentry
  3. 有:直接得到对应 inode(目录 inode)
  4. 没有:读目录文件的数据区(查目录项),创建并缓存该 dentry
  5. 进入 home 目录,重复查 "alice"、再查 "a.txt"
  6. 最终得到 a.txt 的 inode,再创建/返回 file object

缓存:icache 与 dcache

  • icache:inode 缓存(同一个 inode 可能被多处引用)
  • dcache:dentry 缓存(路径组件缓存,命中就不用读目录块)
  • Linux 甚至支持 negative dentry(“这个名字不存在”的缓存),避免反复查不存在的文件。

本质:新增一个目录项指向同一个 inode(同一份文件内容/元数据)。

特点:

  • 不能跨文件系统(不同分区/挂载点 inode 不通用)
  • 通常不能给目录建硬链接(防止目录环导致遍历/回收复杂;只有 ./.. 是特例)
  • 删除任意一个名字不影响数据:只有当 link count 变 0 且没有进程打开,数据才真正释放
  • stat 看起来像“同一个文件”(inode 号相同)

适合:你想要多个名字共享同一份真实内容,并且希望任何一个名字删除后内容仍保留(直到都删完)。

  • 本质:一个独立文件(有自己的 inode),内容是目标路径字符串
  • 特点:
  • 可以跨文件系统
  • 可以指向目录、也可以指向不存在的目标(会形成“断链”)
  • 目标若被移动/删除,软链接可能失效(除非路径仍可解析)
  • lstat 看的是链接本身,stat 看的会跟随链接

适合:你想要指向某个路径,像“快捷方式/别名”,并且希望跨文件系统指向目录

硬链接 vs 软链接

  • 硬链接:多个名字指向同一个 inode;删除一个名字不等于删除数据(link count 归零才释放)
  • 软链接:独立 inode,内容是目标路径;目标删了会“断链”

权限与所有权rwxugo、umask、setuid/setgid/sticky(目录 sticky 常问:/tmp

文件类型与 statstat/lstat/fstatS_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/lseek
  • pread/pwrite(不改变文件 offset,线程更友好)
  • fsync/fdatasync(落盘语义与性能)
  • fcntl(FD flags、锁、非阻塞、复制/设置等)
  • dup/dup2/dup3(文件描述符复制、重定向)
  • 重点能讲:系统调用会进入内核;有缓冲/页缓存;返回值与 errno;短读短写处理。

open

open("/a/b.txt", O_RDONLY) 大致做这些事:

  1. 陷入内核(syscall),检查参数与权限。
  2. 路径解析:逐级查 dentry 缓存(dcache),必要时读目录块;最终找到目标 inode
  3. 建立 file object(“一次打开”的实例):里面有
  4. 当前文件偏移 f_pos
  5. 打开标志(O_APPEND/O_NONBLOCK…)
  6. 指向 inode / 文件操作表等
  7. 在进程的 fd table 分配一个整数 fd,返回给用户态。

面试常追问:fork 后父子共享同一个 open file description(所以 offset 会互相影响),dup 也类似。

O_DIRECT

open(..., O_DIRECT) 会让内核尽量不经过 page cache,而是把数据更“直接”地在 用户缓冲区 ↔ 块设备 之间搬运(仍然会经过块层/驱动,不是“完全不经内核”)。

避免 page cache 占用内存、避免“双缓存”

read

假设你调用 read(fd, buf, n)

  1. 用户态 → 内核态:syscall 进入内核;检查 fd 合法、是否可读等。
  2. fd 找到对应的 file object,确定读的位置(f_pospread 指定的 offset)。
  3. 查页缓存(page cache):文件的这一段数据是否已经在内存页里?
  4. 命中:直接把页缓存里的数据 copy_to_user 到用户缓冲区 buf,更新 f_pos,返回读到的字节数。
  5. 未命中:触发缺页式的“读入”流程:
    1. 文件系统把“文件 offset → 磁盘块/extent”映射出来
    2. 通过块层提交 I/O 请求(生成 bio/request)
    3. 设备驱动发给磁盘/SSD(可能用 DMA)
    4. I/O 完成中断/回调,数据进入 页缓存
    5. copy_to_user 给用户态
  6. 预读(readahead):顺序读时,内核常会顺便提前读后续页,提高吞吐。
  7. 返回用户态(可能出现短读:比如到 EOF、信号打断、非阻塞等)。

关键点:常见的 read() 并不是“直接从磁盘读到用户 buf”,而是 磁盘 → 页缓存 → 拷贝到用户态

write

  1. 用户态 → 内核态:检查 fd 是否可写、是否需要追加(O_APPEND)、是否有文件锁/权限等。
  2. 确定写入位置(f_pos,或 O_APPEND 下原子定位到 EOF)。
  3. 写入页缓存
  4. 把用户数据 copy_from_user 到页缓存对应的 page 中
  5. 标记这些页为 dirty(脏页)
  6. 更新 inode 大小、时间戳,更新 f_pos
  7. write() 通常此时就返回成功(只保证数据进了内核缓存,不保证落盘)。
  8. 异步回写到磁盘(后台 writeback):
  9. 回写线程按策略把脏页刷到磁盘
  10. 若文件系统有日志(如 ext4、xfs),还涉及 journal/元数据一致性 的提交顺序
  11. 如果你显式要求更强持久化:
  12. fsync(fd):等待该文件的数据/必要元数据尽量落盘
  13. 更严谨的“写临时文件 + 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 入内存 → 中断完成 → 唤醒进程 → 拷贝/映射给用户。

  1. 应用发起读请求read(fd, buf, n), mmap 后访问内存触发缺页

  2. 进入内核:VFS + 文件系统定位数据块

  3. 通过 VFS(虚拟文件系统层)找到这个 fd 对应的文件对象、权限等
  4. 文件系统(ext4/xfs 等)根据“文件偏移量”定位到对应的 逻辑块/物理块
  5. 准备把这些块读进 页缓存 page cache
  6. 先查:数据是否已经在内存缓存里?
  7. 命中 page cache:数据已经在内存页里
    • read:内核把 page cache 里的数据 拷贝到用户缓冲区 buf(copy_to_user)然后返回
  8. 未命中:发起真正的磁盘 I/O
  9. 内核创建/合并一个 块 I/O 请求(bio/request),交给块层(block layer)
  10. 经过 I/O 调度(合并相邻请求、排序等,现代 NVMe 场景可能更简化)
  11. 进入 设备驱动(SATA/SAS/NVMe 驱动),由驱动去和 磁盘控制器打交道
  12. 控制器执行读操作,把数据从磁盘介质读出,通常通过 DMA 直接写入主存里内核准备好的缓冲区/页(减少 CPU 搬运)
  13. I/O 完成:中断/回调 + 唤醒进程
  14. 磁盘控制器读完后触发 硬件中断(或轮询完成)
  15. 驱动的中断处理/完成回调运行:标记请求完成、更新状态
  16. 数据页进入/更新到 page cache,并做必要的校验、解锁页等
  17. 唤醒等待该 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_stdiofflushfsync 的区别)

同步/异步 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/Oread/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/OO_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)

一致性套路(崩溃安全写文件)

  1. 写到临时文件
  2. fsync(temp)
  3. rename(temp, target)
  4. 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 + ugochmod/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

fflushfsync 区别?什么时候数据才算真正落盘?

fork 后父子进程对同一 fd 写文件会发生什么?offset 是否共享?

mmapread/write 对比,优缺点?

rename 为什么常用于原子更新配置文件?

O_APPEND 是否能保证多进程写日志不乱?怎么做更可靠?

select/poll/epoll 对比,ET 模式注意什么?

硬链接/软链接区别?inode 里有什么?

statlstat 区别?

文件锁 flock vs fcntl 的差异与适用场景?