经典系统基础|POSIX 文件 I/O 与 Linux 扩展对比:把 open/read/write/fsync/mmap/epoll/io_uring 放在同一张图里讲
上一篇文章,我们把 POSIX 作为一套系统接口标准,完整梳理了一遍。
但只要你真的开始写网络服务、数据库、KV、对象存储、日志系统,或者分析 Linux 下的性能问题,很快就会遇到另一个更具体的问题:
同样都叫 I/O,open/read/write/fsync/mmap/epoll/io_uring 到底分别在解决什么问题,它们之间又是什么关系?
很多工程师对这些接口并不陌生。
比如:
- 知道
open/read/write是最基础的文件 I/O - 知道
fsync和落盘有关 - 知道
mmap可以把文件映射到内存 - 知道
epoll是高并发网络编程常用接口 - 知道
io_uring是 Linux 近年的新 I/O 机制
但这些认知经常是分散的。
于是现实里就很容易出现几种典型混乱:
- 把
epoll当成“更快的文件 I/O” - 把
mmap当成“自动持久化” - 把
fsync当成“写入完成”的唯一同义词 - 把
io_uring理解成“Linux 版异步 API,所以可以替代所有 I/O 接口” - 把 POSIX 和 Linux 扩展混成一层,没有区分共同基线和平台专有能力
所以这篇文章想做的事情很明确:
把 POSIX 文件 I/O 和 Linux 扩展放在同一张语义地图里,系统地讲一遍。
这里的重点不是把 API 手册再抄一遍,而是回答这些更关键的问题:
- POSIX 文件 I/O 的基线到底是什么
open/read/write/fsync分别定义了哪一层语义mmap和 read/write 是替代关系,还是不同访问模型epoll为什么更适合 socket/事件就绪,而不是普通磁盘文件io_uring想解决的问题到底和epoll、传统同步 I/O 分别是什么关系- 在存储系统和高性能服务里,这些接口应该如何放在同一张图里理解
1. 先给一张总图:这几类接口分别处在哪一层
先别急着记 API,先把地图搭出来。
如果把 Linux/Unix 世界里的常见 I/O 路径抽象一下,大致可以画成这样:
Application
|
| POSIX baseline
| - open/close
| - read/write
| - lseek
| - fsync/fdatasync
| - mmap/munmap/msync
v
Kernel VFS / page cache / fd table
|
| Linux event/completion extensions
| - epoll (readiness notification)
| - io_uring (submission/completion model)
v
File systems / sockets / block layer / device drivers
这张图里最关键的不是层数,而是两条主线:
- POSIX 基线主要在定义“程序如何做 I/O”
- Linux 扩展主要在优化“程序如何等待 I/O、提交 I/O 和组织高并发 I/O”
也就是说:
open/read/write/fsync/mmap主要在回答文件访问与持久化语义epoll/io_uring更多在回答高并发等待、事件驱动和提交完成模型
这两条线交叉很多,但不要混为一谈。
2. POSIX 文件 I/O 的基线:先从 open/read/write/close 讲起
如果只保留最核心的 POSIX 文件 I/O 语言,那就是这一组:
open()read()write()close()
这是 Unix 世界里最朴素、也最重要的 I/O 心智模型:
- 先打开一个对象,拿到文件描述符
- 再通过这个文件描述符读写数据
- 最后关闭它
最简单的例子:
int fd = open("data.txt", O_RDONLY);
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));
close(fd);
这套模型的意义不只是“简单”,而是它定义了 Unix I/O 世界最基础的共同语言:
- 文件是 fd
- socket 很多时候也是 fd
- 管道也是 fd
- 终端也是 fd
所以大量上层抽象都能建立在同一个底座上。
3. open() 在语义上做了什么
很多人习惯把 open() 理解成“打开文件”,但从系统角度,它至少做了三件事:
- 路径解析
- 权限检查
- 在当前进程里建立一个文件描述符到内核对象的关联
而 open() 的 flags 又在告诉内核:
- 这个对象以只读、只写还是读写方式访问
- 如果文件不存在,是否创建
- 是否追加写
- 是否截断旧内容
- 是否使用同步语义,如
O_SYNC - 是否尝试绕过 page cache,如
O_DIRECT
这说明 open() 并不是一个无聊的起始动作,它其实在定义:
你后续希望以什么语义访问这个对象。
这也是为什么很多 I/O 问题,根因其实在 open flags,而不在后面的 write()。
4. read() / write():最经典的同步阻塞 I/O 模型
POSIX 最基础的 read() / write(),默认提供的是一种非常朴素的模型:
当前线程发起调用,内核在完成这次操作或至少推进到某个返回条件前,不会把控制权还给你。
这就是大家常说的同步阻塞 I/O。
比如:
ssize_t n = read(fd, buf, 4096);
它通常意味着:
- 这次调用可能立即返回
- 也可能因为数据尚未可得而阻塞
- 返回时要么拿到数据,要么拿到错误或 EOF 语义
同理:
ssize_t n = write(fd, buf, len);
通常意味着:
- 数据至少被接收到内核某个层次
- 不代表一定已经稳定持久化到介质
这最后一点非常重要。
很多工程问题就是因为把:
write()返回成功
误解成:
- 数据已经安全落盘
而实际上,两者之间可能隔着 page cache、回写线程、文件系统事务和设备缓存。
5. fsync() / fdatasync():它们解决的是“可见写入”和“稳定持久化”之间的缝
如果 write() 不等于稳定落盘,那么 POSIX 世界里谁负责把这条缝补上?
最核心的接口就是:
fsync()fdatasync()
它们的核心目标是:
要求内核把相关脏数据和必要元数据推到更稳定的持久化边界。
简单理解:
write()更多在表达“我提交了修改”fsync()更多在表达“我需要这次修改被尽可能稳定地落到持久层”
这两个接口之所以重要,是因为它们把“功能正确”和“崩溃后仍然算数”区分开了。
对于数据库、WAL、对象存储、文件系统引擎,这条边界尤其关键。
fsync 不负责什么
这里也要明确一件事。
fsync() 很重要,但它不应该被神化成“事务完成按钮”。
它并不自动替你解决:
- 多个文件之间的原子更新
- 目录项持久化顺序
- 用户态协议层的一致性设计
这也是为什么前面那个系列里,我们讨论 FUSE、本地文件系统和 WAL 时,会反复提到 rename + fsync(parent_dir) 这类模式。
6. mmap():它不是“更高级的 read/write”,而是另一种访问模型
到了这里,通常会遇到第一个经常被误解的接口:mmap()。
很多人第一次接触 mmap 时,会把它理解成:
- 文件读写的优化版
- 或者“零拷贝文件 I/O”
这不算完全错,但非常不完整。
更准确地说,mmap() 提供的是:
把文件内容映射进进程虚拟地址空间,让你用内存访问的方式去访问文件内容。
于是程序视角会从:
read(fd, buf, n)
write(fd, buf, n)
变成:
ptr = mmap(...)
load/store memory through ptr
这意味着什么?
意味着 mmap 不是把 I/O 消灭了,而是把 I/O 的形式从显式系统调用,变成了页错误、页缓存和内存映射访问路径。
7. mmap 适合解决什么问题,不适合解决什么问题
mmap 特别适合这类场景:
- 随机读很多
- 希望用指针和内存结构直接看文件内容
- 文件数据结构天然适合映射,比如页式数据库、索引文件、只读字典
- 想减少显式
read()/write()搬运代码复杂度
前面这个博客里写 BoltDB 源码分析时,其实就已经碰到这个话题了。
BoltDB 之所以能把单文件数据库组织得很直接,一个关键点就是:
它把整个文件映射进内存视图,然后在页级别组织事务和 B+Tree。
但 mmap 也不是什么银弹。
它不擅长或者容易踩坑的地方包括:
- 写回持久化语义容易被误解
- 缺页和页失效时延迟可能不可预测
- 文件增长、remap、并发可见性管理更复杂
- 出错模式更靠近内存访问异常,而不是显式 I/O 返回值
所以 mmap 本质上是:
用更像内存的方式访问文件,而不是绕开文件系统语义。
8. msync() 和 fsync() 不要混着理解
只要说到 mmap,就一定得顺手把 msync() 放进图里。
因为 mmap 把“文件修改”变成了“内存页修改”,于是你自然会问:
那这些脏页什么时候真正写回去?
这里相关接口是:
msync()munmap()- 文件描述符上的
fsync()
工程上最重要的一点不是背细节,而是要记住:
mmap 路径下的数据修改与 read/write 路径下的数据修改,虽然都通向同一个文件系统和 page cache 世界,但程序可见的操作方式不同,持久化控制也不能想当然地混成一件事。
也就是说,不要简单把:
mmap + store
当成:
write()的自动替身
它们共享底层很多机制,但上层编程模型和故障模型并不一样。
9. 到这里为止,都是“如何访问文件”;epoll 开始转向“如何等待事件”
现在我们切到 Linux 扩展里最经典的一个:epoll。
先说一句最容易混淆但最重要的话:
epoll 不是文件读写接口,它主要是 I/O 就绪事件通知机制。
也就是说,epoll 解决的问题不是:
- 如何把字节写到文件里
而是:
- 当我同时管理大量 fd 时,哪些现在可读、可写、出错、挂断
这在高并发网络服务里特别关键。
因为如果你有上万连接,不可能对每个 socket 都傻傻阻塞 read()。
于是 Linux 提供了:
selectpollepoll
而 epoll 是其中面向大量 fd 场景更高效的一种。
10. 为什么 epoll 更像“socket 世界的调度器”,而不是“磁盘文件加速器”
这是另一个特别常见的误区。
很多人第一次学高并发 I/O,会把 epoll 和所有 I/O 都混在一起,觉得:
- 有了
epoll,所有 I/O 都能变高性能
其实不对。
对普通磁盘文件来说,内核通常认为它们“总是可读/可写”或者并不适合按网络连接那种 readiness 模型使用。
所以 epoll 最擅长的场景通常是:
- socket
- pipe
- eventfd
- timerfd
- signalfd
- 其他适合 readiness 语义的 fd
也就是说:
epoll 的主战场是高并发事件驱动,不是常规磁盘文件读写本身。
这也是为什么你不能拿 epoll 去替代 read/write/fsync/mmap 那条文件 I/O 主线。
它们不是同类工具。
11. epoll 的核心价值:不是让 I/O 更快,而是让等待方式更可扩展
这一点非常关键。
epoll 真正改善的,不是单次 read() 的速度,而是:
在大量 fd 并存时,程序如何高效地知道“下一步该处理谁”。
所以它本质上是在优化:
- 等待模型
- 事件分发模型
- 主循环结构
这也是为什么你在 Nginx、Redis、很多网络框架里,经常会看到:
epoll_wait()拿到一批就绪事件- 然后再对对应 socket 调
read/write
也就是说,epoll 并没有取代 read/write,它只是决定了何时对谁调用它们。
12. io_uring 想解决的,已经不只是“等谁就绪”,而是“怎么提交和回收 I/O”
现在来到 Linux 近些年最受关注的 I/O 扩展:io_uring。
如果要一句话概括它和 epoll 的差别,可以这么说:
epoll 主要是 readiness model,io_uring 更接近 submission/completion model。
也就是说:
epoll更关心“哪些 fd 现在值得你去操作”io_uring更关心“你把请求提交给内核,内核完成后再把结果还给你”
这两个模型在思维方式上有明显区别。
前者更像:
- 我来盯事件
- 一旦就绪,我再主动做系统调用
后者更像:
- 我先描述要做的 I/O 操作
- 交给内核执行
- 之后异步收割完成结果
这就是为什么 io_uring 一出来,很多人会把它看成 Linux 在 I/O 模型上的一次明显推进。
13. 为什么 io_uring 会被认为比传统同步 I/O 和 epoll 都更进一步
因为它试图同时碰几个长期存在的问题:
- 系统调用开销
- 提交与完成路径割裂
- readiness 模型下应用层状态机复杂
- 某些场景里传统 Linux AIO 能力有限且接口不统一
io_uring 的核心机制可以非常粗略地理解成两条 ring:
- Submission Queue, SQ
- Completion Queue, CQ
应用把请求放进 SQ,内核处理后把完成结果放进 CQ。
所以它最吸引人的地方在于:
- 批量提交更自然
- 批量收割更自然
- 可以覆盖更广的操作类型,不只是 socket,也包括文件 I/O、超时、accept、send/recv 等很多对象
也就是说,io_uring 试图给 Linux 提供一套更统一的高性能异步操作框架。
14. 现在把它们放回一张图:它们到底各自回答什么问题
到这里,可以把这几个接口重新压缩到一张更清楚的对照图里:
1. open/close
回答:我怎样拿到或释放一个 I/O 对象的访问入口?
2. read/write
回答:我怎样同步地收发字节?
3. fsync/fdatasync
回答:我怎样要求修改尽可能稳定地持久化?
4. mmap/msync
回答:我能不能把文件映射成内存视图来访问?
5. epoll
回答:当我同时管理很多 fd 时,谁现在值得处理?
6. io_uring
回答:我能不能用更统一、更低开销的提交/完成模型组织大量 I/O?
你看,一旦这么放,关系就清楚很多了。
它们不是“新接口替代旧接口”的线性演化,而更像:
- 基础访问语义
- 持久化控制语义
- 内存映射访问语义
- 高并发事件等待语义
- 更现代的异步提交完成语义
这五类问题本来就不是一件事。
15. 一个常见误区:把 mmap 和 io_uring 理解成同一层竞争关系
很多人喜欢问:
mmap和io_uring谁更先进mmap能不能被io_uring替代
这种问法本身就有点歪。
因为它们很多时候不在同一层竞争。
mmap 更像是在说:
- 文件访问模型是不是以内存映射为中心
而 io_uring 更像是在说:
- I/O 请求的提交与完成方式是不是更现代、更批量化、更异步化
所以它们解决的问题并不相同。
你可以有:
- 基于
read/write + epoll的系统 - 基于
mmap的数据库 - 基于
io_uring的高性能文件/网络服务 - 某些场景里甚至混合使用不同模型
关键不在于“谁更新”,而在于:
你的 workload 更像哪种访问与等待模式。
16. 存储系统应该怎样看这张图
如果你做的是存储系统,这张图特别有价值。
因为不同系统会天然偏向不同组合。
对象存储 / 日志系统
更常见关注点:
open/write/fsync/renameO_DIRECT- 顺序写和持久化边界
嵌入式数据库 / 映射型索引
更常见关注点:
mmap- 页式访问
msync/fsync- 崩溃恢复协议
高并发网络服务
更常见关注点:
- socket fd
epoll- 非阻塞
read/write accept/send/recv
新一代高性能 I/O 框架
更常见关注点:
io_uring- 批量提交与完成
- 减少系统调用与状态机复杂度
你会发现,不同系统并不是“从旧 API 一路升级到新 API”,而是在按自己的瓶颈选工具。
17. 为什么 POSIX 基线今天依然重要
讲到 io_uring 这种新接口时,很容易让人产生一种错觉:
- 传统 POSIX I/O 快过时了
这其实不对。
POSIX 基线今天仍然极其重要,原因至少有三个:
- 它仍然是 Unix-like 世界里最稳定的共同语言
- 大量语言运行时和库的默认实现仍然建立在它之上
- 即使你最终使用 Linux 扩展,也仍然需要先理解基础 fd、阻塞语义、持久化边界和错误模型
换句话说:
Linux 扩展不是在否定 POSIX,而是在 POSIX 基线之上,针对特定瓶颈继续演化。
所以工程上最危险的状态不是“你不会 io_uring”,而是:
你连 write() 成功和数据 durable 之间的差别都没想清楚,就急着追逐更新的接口。
18. 常见误区集中拆一下
到这里,可以把几个最常见的误区集中说清楚。
误区一:write() 返回成功就等于落盘
不是。它通常只说明数据进入了内核某层可见路径。
误区二:fsync() 等于事务提交
不是。它是持久化边界的重要工具,但不是自动事务系统。
误区三:mmap 就是更快的 read/write
不准确。它是另一种访问模型,故障模式和持久化控制都不同。
误区四:epoll 是高性能磁盘文件 I/O API
不是。它的主战场是 readiness notification,尤其是 socket/事件驱动场景。
误区五:io_uring 会自动取代所有 I/O 模型
不是。它很强,但适用性、复杂度、内核版本要求、生态成熟度和工作负载模型都要一起考虑。
19. 如果你只想记住一条选择思路,应该记什么
如果把工程选择压缩成一条最实用的判断线,我会建议这样记:
当你主要关心的是文件访问基础语义
先想:
open/read/write/close- 是否需要
lseek - 是否需要
fsync/fdatasync
当你主要关心的是文件像内存一样访问
先想:
mmap- 页粒度行为
msync/fsync与恢复语义
当你主要关心的是大量连接上的事件等待
先想:
- 非阻塞 fd
epoll- readiness 驱动状态机
当你主要关心的是高并发提交/完成模型
先想:
io_uring- 批量提交/收割
- 统一处理不同 I/O 操作的可能性
也就是说,不要先从“哪个 API 更新”出发,而要先从:
你到底在解决访问、持久化、映射、事件等待还是异步提交问题。
20. 总结:把 I/O 放回语义地图,很多概念就不再混乱了
如果把这篇文章压缩成一句话,那就是:
open/read/write/fsync/mmap/epoll/io_uring 之所以容易让人混乱,不是因为它们本身太难,而是因为很多人把不同层次、不同目标的 I/O 机制混成了一类工具;一旦把它们放回同一张语义地图里,关系就会清楚很多。
你真正该抓住的是这几条主线:
open/read/write/close是 POSIX 文件 I/O 的基础语言fsync/fdatasync负责把“写入可见”推进到“尽量稳定持久”mmap提供的是文件到内存视图的访问模型epoll解决的是大量 fd 的 readiness 等待问题io_uring解决的是更统一、更低开销的提交/完成模型问题
它们不是一条简单替代链,而是一套逐层叠加、各自针对不同瓶颈演化出来的工具箱。
而 POSIX 与 Linux 扩展之间的关系,也应该这样理解:
- POSIX 提供共同基线
- Linux 扩展在基线之上针对性能和扩展性继续推进
你把这张图真正看清,再回头看数据库、对象存储、FUSE、网络框架、日志系统里的 I/O 设计,就会顺很多。
因为那些系统本质上都在回答同一个问题:
在什么语义约束下,用什么 I/O 模型,去换取你最在意的吞吐、延迟、持久化和复杂度平衡。