文件 I/O、阻塞 / 非阻塞、同步 / 异步、缓冲 I/O、直接 I/O
前面把目录项、 inode、dentry、fd、硬链接这些概念理顺之后,再往下看文件系统时,很快就会碰到另一组特别容易混在一起的词:
- 文件 I/O
- 阻塞 / 非阻塞
- 同步 / 异步
- 缓冲 I/O
- 直接 I/O
- page cache
fsync
这些概念之所以容易乱,不是因为它们都难,而是因为它们其实不在同一个层面上。
有的在描述:
- 怎么打开和读写文件
有的在描述:
- 调用发出去之后,线程是等着,还是先返回
还有的在描述:
- 数据是否经过 page cache
write()返回之后数据是否真的落盘
如果不先把层次拆开,就很容易出现一种情况:每个词都认识,但一串起来就开始混。
所以这篇我想把这条线完整理一遍,重点回答下面几个问题:
- 文件 I/O 到底在做什么
- 一次
open / read / write / close大致会经过哪些对象 - 阻塞 / 非阻塞 和 同步 / 异步 分别在说什么
- 缓冲 I/O 和直接 I/O 的差别到底在哪里
- 为什么
write()成功不等于数据已经落盘
一、先把“文件 I/O”放到整体图里
先抓住一句最核心的话:
文件 I/O 本质上就是进程通过系统调用,请求内核帮自己完成“内存和文件之间的数据交换”。
也就是说,进程自己不会直接操作磁盘,也不会自己拿着 inode 去找 block。真正做这些事的是内核。
应用程序在用户态里看到的通常只是这样几类接口:
open()
read()
write()
lseek()
close()也就是说,站在应用视角,你像是在操作“文件”;但站在内核视角,真正参与这件事的通常是:
- 路径
- dentry
- inode
- file 对象
- fd
- page cache
- 文件系统实现
- 块设备层
- 磁盘设备
所以更准确地说:
文件 I/O 不是“进程直接读写磁盘”,而是“进程通过 fd 操作打开文件,内核再沿着文件系统和缓存链路去完成实际数据访问”。
二、文件 I/O 到底在操作哪些对象
如果只从用户代码看,最直观的是:
- 路径字符串
- 文件描述符 fd
- 用户缓冲区
buf
但如果把内核里的对象也串起来,这条链更完整一些:
路径 -> dentry -> inode -> file -> fd然后真正读写数据时,还会继续经过:
inode -> page cache / 磁盘块1. 路径
例如:
/tmp/a.txt这是“找到文件”这一步所依赖的名字。
2. dentry
dentry 是内存里的目录项缓存对象,更关注的是:
某个父目录下的某个名字,到底对应哪个对象。
也就是说,它主要参与路径查找。
3. inode
inode 描述的是文件对象本身,例如:
- 文件类型
- 权限
- 大小
- 时间戳
- 数据所在位置
它不主要负责保存文件名,更多是在说:
这个对象是什么,它的数据在哪里。
4. file 对象
open() 之后,内核会创建一个“打开文件”的运行时对象,也就是 file。它通常会记录:
- 当前打开方式
- 当前文件偏移量 offset
- 状态标志
- 指向 inode / dentry 的引用
它表示的是:
这一次打开操作所对应的访问上下文。
5. fd
fd 是进程 fd 表里的一个整数句柄,例如 3、4、5。
它不是文件本身,而是:
当前进程访问某个已打开文件对象的入口编号。
所以我现在更愿意这样记:
- 路径:用来“找到文件”
- fd:用来“操作已经打开的文件”
三、最常见的文件 I/O 系统调用
1. open()
int fd = open("a.txt", O_RDONLY);open() 的重点不是把整个文件内容读进来,而是:
- 解析路径
- 做权限检查
- 找到目标 dentry 和 inode
- 创建
file对象 - 在进程 fd 表里分配一个 fd
所以 open() 更像是在做:
把路径解析成一个后续可操作的打开状态。
2. read()
ssize_t n = read(fd, buf, 4096);read() 的作用是:
- 从 fd 对应文件的当前偏移开始
- 读取一段数据
- 把数据放到用户缓冲区
buf - 成功后推进文件偏移量
这里有个很重要的点:
read()返回时,读到的数据通常已经拷到用户缓冲区里了。
3. write()
ssize_t n = write(fd, buf, len);write() 的作用是:
- 从用户缓冲区取数据
- 按当前偏移写入文件
- 成功后推进文件偏移量
但它最容易被误解的一点是:
write()成功,通常只代表内核已经接收了这些数据,并不一定代表磁盘已经完成落盘。
4. lseek()
lseek(fd, 0, SEEK_SET);lseek() 负责修改当前 file 对象里的文件偏移量 offset。
这也是为什么普通文件支持随机读写。
5. close()
close(fd);close() 负责关闭 fd,释放对应引用;必要时,相关的 file 对象和其它资源会在引用归零后被回收。
四、一次 open() 到底在做什么
假设执行:
int fd = open("/tmp/a.txt", O_RDONLY);大致可以理解成下面这条链。
1. 先做路径解析
内核会从起始目录开始,逐层解析路径分量,例如:
/tmpa.txt
这一步主要依赖:
- dentry cache
- inode cache
- 必要时读取目录页
2. 找到最终对象
路径查找成功后,内核会拿到:
- 最终 dentry
- 最终 inode
3. 做权限和打开方式检查
例如:
- 文件是否存在
- 是否有读权限 / 写权限
- 是否带了
O_CREAT - 是否带了
O_TRUNC - 是否带了
O_APPEND
4. 创建 file 对象并返回 fd
之后内核会创建这次打开的运行时状态,并在当前进程 fd 表中登记一个整数句柄。
所以从整体上说:
路径字符串
↓
路径解析
↓
dentry + inode
↓
创建 file
↓
分配 fd五、一次 read() 大致经历什么
假设有下面这句:
read(fd, buf, 4096);这件事粗略可以分成几步看。
1. 根据 fd 找到 file
进程先通过 fd 表,找到当前 fd 对应的 file 对象。
2. 通过 file 找到 inode 和当前 offset
file 里会记录:
- 当前偏移量
- 打开方式
- 对 inode 的引用
3. 先看 page cache 里有没有对应文件页
这一步特别重要。
普通文件读取时,内核通常不会一上来就直接碰磁盘,而是先看:
目标文件对应偏移位置的页,是否已经在 page cache 中。
如果已经在,那么这次读取通常就不需要真实磁盘 I/O。
4. 如果 page cache 没命中,再发起磁盘 I/O
如果目标页不在缓存中,内核才会:
- 根据 inode 定位到对应文件数据所在的磁盘位置
- 通过文件系统和块设备层发起读请求
- 把数据读到内核中的 page cache
5. 再从 page cache 拷到用户缓冲区
当数据进入 page cache 后,内核再把这部分数据复制到用户态 buf 中。
所以普通文件读取很常见的一条路径是:
磁盘 -> page cache -> 用户缓冲区如果 page cache 命中,则更像是:
page cache -> 用户缓冲区6. 更新偏移量
读取成功后,file 对象里的 offset 会向后推进。
六、一次 write() 大致经历什么
再看写文件。
write(fd, buf, len);大致可以理解成下面这条路径。
1. 根据 fd 找到 file
和 read() 一样,先从 fd 表找到 file 对象。
2. 拿到 inode 和当前 offset
随后确定:
- 要写哪个文件
- 从哪个偏移开始写
- 打开标志是什么
3. 把用户缓冲区数据写入 page cache
在普通缓冲 I/O 下,常见做法不是立刻把数据直接打到磁盘,而是先进入 page cache。
这一步可以理解成:
用户缓冲区 -> page cache如果相关页已经在 page cache 中,就直接改这些缓存页;如果没有,就需要先为对应文件偏移建立缓存页。
这里要注意一件事:
相关页不在 page cache 中时,不代表一定先把旧页从磁盘完整读回来。
是否需要先读旧数据,要看写入方式,例如:
- 是不是整页覆盖
- 是不是部分覆盖
- 是不是追加写
- 文件系统和内核的具体处理方式
所以不能简单记成“page cache miss 就一定先读磁盘旧页”。
4. 把这些页标记为脏页
一旦 page cache 中的内容被修改,这些页就会变成 dirty page,也就是:
内存中的新版数据已经有了,但磁盘上的旧版本还没同步。
5. write() 提前返回
普通缓冲写下,write() 很可能在“数据已经安全进入内核缓存层”之后就返回。
这意味着:
- 对用户进程来说,这次调用已经成功
- 但对磁盘来说,数据可能还没真正写下去
6. 后续再由内核安排回写
后续的落盘可能由这些时机触发:
- 后台回写线程定期刷脏页
- 脏页比例过高
- 内存压力变大
- 调用
fsync()/fdatasync() - 使用某些同步写语义,如
O_SYNC
所以普通写文件的常见路径是:
用户缓冲区 -> page cache -> 磁盘七、为什么 write() 成功不等于已经落盘
这是文件 I/O 里最关键的一个易混点。
1. write() 成功,通常只说明内核已经收到了数据
更准确一点说,常见情况下它说明:
- 内核已经把数据从用户缓冲区接过来了
- 数据通常已经进入 page cache
- 相关页已被标记为 dirty page
2. 这不等于磁盘已经完成持久化
磁盘上的数据更新往往是后续异步发生的。
也就是说:
- 进程看到
write()返回成功 - 但系统如果此时掉电
- 刚写的数据未必已经真正写进磁盘稳定介质
3. 如果真的关心持久化,就要显式同步
常见做法是:
fsync(fd);或:
fdatasync(fd);你可以先粗略记成:
write():把数据交给内核缓存路径fsync():要求内核尽量把相关修改同步到磁盘
八、page cache 到底是什么,为什么它这么重要
page cache 是内核维护的文件页缓存。它的存在,本质上是在做一件事:
把最近访问过或者即将访问的文件内容先放在内存里,减少真实磁盘 I/O。
1. 为什么需要它
因为磁盘和内存的速度差距太大了。
如果每次 read() 都必须去磁盘,文件访问性能会非常差。
2. 它缓存的是什么
page cache 缓存的是“文件页”,既可以缓存:
- 普通文件内容页
- 目录文件内容页
所以它不仅服务于 read() / write(),路径查找需要扫描目录内容时也可能用到它。
3. 它带来了什么效果
最直观的就是:
- 第一次读文件可能慢
- 第二次读同样数据可能快很多
因为第二次很可能命中了 page cache。
4. 它也是普通缓冲 I/O 的关键中间层
很多人说“写到了内核缓冲区”,在普通文件场景里,大多数时候说的就是这层 page cache。
所以更准确地说:
普通缓冲 I/O 默认就是围绕 page cache 展开的。
九、缓冲 I/O 是什么
缓冲 I/O 也可以理解成“普通文件 I/O 的默认路径”。
它的核心特征是:
读写会经过 page cache。
1. 读的时候
常见流程是:
- 先查 page cache
- 没命中再读磁盘
- 再拷贝到用户缓冲区
2. 写的时候
常见流程是:
- 先把用户数据写进 page cache
- 标脏
- 之后再由内核安排回写
3. 它的优点
- 更容易利用缓存局部性
- 减少磁盘访问次数
- 合并小 I/O,整体吞吐通常更友好
- 是通用场景下最自然、最稳妥的默认选择
4. 它的代价
- 会占用内存作为 page cache
- 可能带来额外拷贝
- 有时候会让应用和内核都各自维护一份缓存
- 对某些大规模顺序扫描场景,可能带来缓存污染
十、直接 I/O 是什么
直接 I/O 的核心思想通常可以粗略理解成:
尽量绕过 page cache,让数据直接在用户缓冲区和块设备 I/O 路径之间流动。
它常见于:
- 数据库
- 对缓存策略要自己掌控的系统
- 不希望污染页缓存的大文件顺序处理场景
1. 它不等于“绕过内核”
这是一个特别容易误解的点。
直接 I/O 绕过的是 page cache,不是绕过内核。
真正的链路仍然要经过:
- 系统调用
- 内核
- 文件系统
- 块设备层
- 磁盘设备
所以更准确地说:
直接 I/O 是绕过 page cache,不是用户态直接操作磁盘。
2. 它不等于“天然更快”
直接 I/O 也不是绝对比缓冲 I/O 快。
要看场景:
- 大块、顺序、应用自带缓存管理时,直接 I/O 很可能更合适
- 小块、局部性强、读写重复度高时,缓冲 I/O 往往更占优
所以更准确的说法是:
直接 I/O 不是为了“绝对更快”,而是为了让缓存路径和 I/O 行为更可控。
3. 它通常更麻烦
使用直接 I/O 时,应用往往需要更关心这些问题:
- 缓冲区对齐
- I/O 大小对齐
- 自己的缓存策略
- 什么时候真正需要同步持久化
也就是说:
你绕开了 page cache 的便利,也意味着很多事情要自己更清楚地掌握。
十一、O_DIRECT 不等于“立刻落盘”
这也是很容易混的点。
很多人一看到“直接 I/O”,就会自然联想到:
- 不经过 page cache
- 那是不是就等于直接写磁盘
- 那是不是
write()返回就说明已经稳稳落盘了
这一步跳得太快了。
更准确的理解是:
O_DIRECT主要强调的是尽量绕开 page cache,而不是自动提供强持久化保证。
也就是说:
- 它和“是否经过 page cache”有关
- 但和“是否已经持久化到稳定存储”不是同一个问题
如果业务真的关心落盘语义,仍然要看:
- 是否使用了同步写标志
- 是否调用了
fsync()/fdatasync() - 文件系统和设备的具体语义
所以:
直接 I/O 不等于自动持久化,绕过缓存和真正落盘是两层不同问题。
十二、阻塞 / 非阻塞 在说什么
阻塞 / 非阻塞 关注的不是 page cache,也不是是否落盘,而是:
当调用发出去后,如果结果还没准备好,当前线程是等在那里,还是立刻返回。
1. 阻塞 I/O
阻塞 I/O 的特点是:
- 调用已经发出
- 但条件还不满足
- 当前线程就睡眠等待
- 直到结果准备好后再继续
例如:
- 数据还没到
- 缓冲区没准备好
- 设备还没完成
这时线程就可能挂起,等内核唤醒。
2. 非阻塞 I/O
非阻塞 I/O 的特点是:
- 如果当前条件不满足
- 调用不会把线程挂在那里
- 而是直接返回一个“现在还不行”的结果,例如
EAGAIN
也就是说:
非阻塞强调的是“先返回”,不是“自动完成”。
3. 为什么非阻塞不一定更省事
因为你虽然没有睡着等,但你要自己处理:
- 什么时候再试
- 怎么避免空转
- 怎么同时管理多个 fd
如果只是自己写一个死循环不断 read(),确实会浪费 CPU。
所以实际工程里,更常见的是:
非阻塞 I/O 往往和
select / poll / epoll这类 I/O 复用机制配合使用。
十三、同步 / 异步 在说什么
同步 / 异步 这组概念比阻塞 / 非阻塞 更容易说混。
我现在更愿意把它们理解成:
一次 I/O 请求提交之后,完成这件事到底是不是要调用方自己等、自己收尾。
1. 同步 I/O
同步 I/O 更接近这样一种模式:
- 调用方发起 I/O
- 最终还得自己等这次 I/O 完成
- 等完成后才能拿到结果
例如普通 read(),通常可以粗略理解成:
- 数据准备好
- 数据拷到用户缓冲区
read()才返回
也就是说:
read()返回时,调用方已经拿到了数据。
2. 异步 I/O
异步 I/O 更接近这样一种模式:
- 调用方先提交请求
- 然后不用一直卡在那里等
- 内核在后台把事情做完
- 完成后再通知调用方
所以异步 I/O 的关键不在于“有没有把数据带回来”,而在于:
调用方提交后能不能先脱身,以及完成是不是由内核在后台推进。
3. 一个容易误解的点
有时会把它说成:
- 同步:拿到数据才返回
- 异步:还没拿到数据也先返回
这种说法只能算非常粗略的记忆法,真正更准确的理解还是:
- 同步:调用方自己等完成
- 异步:完成后由内核通知
十四、为什么“非阻塞”不等于“异步”
这是文件 I/O 和网络 I/O 里都特别容易混的一点。
它们看起来都像“不会傻等”,但其实问的问题不一样。
1. 非阻塞问的是:现在没准备好时,你是等还是先回来
也就是说,它关心的是:
当前这次调用会不会把线程卡住。
2. 异步问的是:这次 I/O 完成这件事,是谁负责在后面推进
也就是说,它关心的是:
请求提交后,是调用方自己之后再跟进,还是内核后台做完后通知你。
3. 所以两者完全可以交叉组合去理解
例如:
- 非阻塞但同步:现在没数据就先返回,但后续还是要你自己再来取
- 阻塞但同步:最传统的
read()风格 - 真正异步:你先提交,完成后再通知你
所以最该记住的一句话是:
非阻塞不等于异步,它们一个在描述“会不会卡住当前调用”,一个在描述“完成机制由谁推进”。
十五、文件 I/O 和广义 I/O 模型的边界
学到这里时,很容易出现另一个问题:
- 阻塞 / 非阻塞 / 同步 / 异步
- 这些到底算不算文件 I/O 内容
更准确地说,它们属于:
更广义的 I/O 模型概念。
因为它们不只会出现在文件里,也会出现在:
- socket
- 管道
- 设备 I/O
所以我现在更愿意这样分:
1. 文件 I/O 主线
重点是:
open / read / write / lseek / close- 路径解析
- dentry / inode / file / fd
- page cache
- dirty page
fsync- 缓冲 I/O / 直接 I/O
2. 更广义 I/O 模型
重点是:
- 阻塞 / 非阻塞
- 同步 / 异步
- I/O 复用
- 零拷贝
之所以经常一起讲,是因为文件 I/O 会碰到它们,但它们本身并不只属于文件。
十六、mmap() 和普通文件 I/O 的关系
文件 I/O 再往后走一步,通常还会碰到 mmap()。
1. 普通文件 I/O
你是显式调用:
read()write()
然后由内核在用户缓冲区和文件之间搬运数据。
2. mmap()
mmap() 的思路是:
把文件的一段内容映射到进程虚拟地址空间里,让进程像访问内存一样访问文件。
于是你操作的看起来不再是:
read(fd, buf, ...)而更像是:
p[0] = 'A';但底层它仍然离不开:
- 页表
- 缺页中断
- page cache
- 文件系统
所以你可以把它理解成:
read / write:显式文件 I/Ommap():以内存映射方式访问文件
十七、几个最容易混的点,顺手集中收一下
1. fd 不是文件本身
fd 只是当前进程访问已打开文件的整数句柄。
2. open() 不是把整个文件读进来
open() 主要是在做路径解析和建立打开状态,真正的文件内容访问通常发生在 read() 之后。
3. page cache 不是磁盘块
page cache 是内存里的缓存页,磁盘 block 是磁盘上的存储单位,它们相关但不是同一个东西。
4. write() 成功不等于落盘
很多时候它只代表数据已经进了 page cache,并被标成脏页。
5. 直接 I/O 不等于自动持久化
它主要解决的是“是否经过 page cache”,不是自动解决“是否立刻刷盘”。
6. 非阻塞不等于异步
非阻塞关注调用会不会立刻返回,异步关注完成机制由谁推进。
十八、小结
把这一整块重新收一下,我现在更愿意这样理解:
文件 I/O 的主线,是进程通过
open / read / write / close这类系统调用,借助 fd 去操作已经打开的文件;内核再通过 dentry、inode、file、page cache、文件系统实现和块设备层,完成真正的数据读写。普通缓冲 I/O 默认经过 page cache,所以read()不一定每次都读磁盘,write()成功也通常只代表数据已经进入内核缓存并被标成脏页,不代表已经真正落盘;如果需要持久化语义,还要进一步依赖fsync()等机制。直接 I/O 则是尽量绕过 page cache,让应用更主动地控制缓存路径,但它不等于绕过内核,也不等于天然更快,更不等于自动落盘。至于阻塞 / 非阻塞、同步 / 异步,它们描述的是更广义的 I/O 行为模型:前者关注调用会不会卡住当前线程,后者关注 I/O 完成到底由谁来推进和通知。把这几层分开之后,文件 I/O 这一块就会清楚很多。
