Q先生的世界

面朝大海,春暖花开

经典系统基础|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 到底分别在解决什么问题,它们之间又是什么关系?

很多工程师对这些接口并不陌生。

比如:

  1. 知道 open/read/write 是最基础的文件 I/O
  2. 知道 fsync 和落盘有关
  3. 知道 mmap 可以把文件映射到内存
  4. 知道 epoll 是高并发网络编程常用接口
  5. 知道 io_uring 是 Linux 近年的新 I/O 机制

但这些认知经常是分散的。

于是现实里就很容易出现几种典型混乱:

  1. epoll 当成“更快的文件 I/O”
  2. mmap 当成“自动持久化”
  3. fsync 当成“写入完成”的唯一同义词
  4. io_uring 理解成“Linux 版异步 API,所以可以替代所有 I/O 接口”
  5. 把 POSIX 和 Linux 扩展混成一层,没有区分共同基线和平台专有能力

所以这篇文章想做的事情很明确:

把 POSIX 文件 I/O 和 Linux 扩展放在同一张语义地图里,系统地讲一遍。

这里的重点不是把 API 手册再抄一遍,而是回答这些更关键的问题:

  1. POSIX 文件 I/O 的基线到底是什么
  2. open/read/write/fsync 分别定义了哪一层语义
  3. mmap 和 read/write 是替代关系,还是不同访问模型
  4. epoll 为什么更适合 socket/事件就绪,而不是普通磁盘文件
  5. io_uring 想解决的问题到底和 epoll、传统同步 I/O 分别是什么关系
  6. 在存储系统和高性能服务里,这些接口应该如何放在同一张图里理解

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

这张图里最关键的不是层数,而是两条主线:

  1. POSIX 基线主要在定义“程序如何做 I/O”
  2. Linux 扩展主要在优化“程序如何等待 I/O、提交 I/O 和组织高并发 I/O”

也就是说:

  1. open/read/write/fsync/mmap 主要在回答文件访问与持久化语义
  2. epoll/io_uring 更多在回答高并发等待、事件驱动和提交完成模型

这两条线交叉很多,但不要混为一谈。


2. POSIX 文件 I/O 的基线:先从 open/read/write/close 讲起

如果只保留最核心的 POSIX 文件 I/O 语言,那就是这一组:

  1. open()
  2. read()
  3. write()
  4. close()

这是 Unix 世界里最朴素、也最重要的 I/O 心智模型:

  1. 先打开一个对象,拿到文件描述符
  2. 再通过这个文件描述符读写数据
  3. 最后关闭它

最简单的例子:

int fd = open("data.txt", O_RDONLY);
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));
close(fd);

这套模型的意义不只是“简单”,而是它定义了 Unix I/O 世界最基础的共同语言:

  1. 文件是 fd
  2. socket 很多时候也是 fd
  3. 管道也是 fd
  4. 终端也是 fd

所以大量上层抽象都能建立在同一个底座上。


3. open() 在语义上做了什么

很多人习惯把 open() 理解成“打开文件”,但从系统角度,它至少做了三件事:

  1. 路径解析
  2. 权限检查
  3. 在当前进程里建立一个文件描述符到内核对象的关联

open() 的 flags 又在告诉内核:

  1. 这个对象以只读、只写还是读写方式访问
  2. 如果文件不存在,是否创建
  3. 是否追加写
  4. 是否截断旧内容
  5. 是否使用同步语义,如 O_SYNC
  6. 是否尝试绕过 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);

它通常意味着:

  1. 这次调用可能立即返回
  2. 也可能因为数据尚未可得而阻塞
  3. 返回时要么拿到数据,要么拿到错误或 EOF 语义

同理:

ssize_t n = write(fd, buf, len);

通常意味着:

  1. 数据至少被接收到内核某个层次
  2. 不代表一定已经稳定持久化到介质

这最后一点非常重要。

很多工程问题就是因为把:

  1. write() 返回成功

误解成:

  1. 数据已经安全落盘

而实际上,两者之间可能隔着 page cache、回写线程、文件系统事务和设备缓存。


5. fsync() / fdatasync():它们解决的是“可见写入”和“稳定持久化”之间的缝

如果 write() 不等于稳定落盘,那么 POSIX 世界里谁负责把这条缝补上?

最核心的接口就是:

  1. fsync()
  2. fdatasync()

它们的核心目标是:

要求内核把相关脏数据和必要元数据推到更稳定的持久化边界。

简单理解:

  1. write() 更多在表达“我提交了修改”
  2. fsync() 更多在表达“我需要这次修改被尽可能稳定地落到持久层”

这两个接口之所以重要,是因为它们把“功能正确”和“崩溃后仍然算数”区分开了。

对于数据库、WAL、对象存储、文件系统引擎,这条边界尤其关键。

fsync 不负责什么

这里也要明确一件事。

fsync() 很重要,但它不应该被神化成“事务完成按钮”。

它并不自动替你解决:

  1. 多个文件之间的原子更新
  2. 目录项持久化顺序
  3. 用户态协议层的一致性设计

这也是为什么前面那个系列里,我们讨论 FUSE、本地文件系统和 WAL 时,会反复提到 rename + fsync(parent_dir) 这类模式。


6. mmap():它不是“更高级的 read/write”,而是另一种访问模型

到了这里,通常会遇到第一个经常被误解的接口:mmap()

很多人第一次接触 mmap 时,会把它理解成:

  1. 文件读写的优化版
  2. 或者“零拷贝文件 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 特别适合这类场景:

  1. 随机读很多
  2. 希望用指针和内存结构直接看文件内容
  3. 文件数据结构天然适合映射,比如页式数据库、索引文件、只读字典
  4. 想减少显式 read()/write() 搬运代码复杂度

前面这个博客里写 BoltDB 源码分析时,其实就已经碰到这个话题了。

BoltDB 之所以能把单文件数据库组织得很直接,一个关键点就是:

它把整个文件映射进内存视图,然后在页级别组织事务和 B+Tree。

mmap 也不是什么银弹。

它不擅长或者容易踩坑的地方包括:

  1. 写回持久化语义容易被误解
  2. 缺页和页失效时延迟可能不可预测
  3. 文件增长、remap、并发可见性管理更复杂
  4. 出错模式更靠近内存访问异常,而不是显式 I/O 返回值

所以 mmap 本质上是:

用更像内存的方式访问文件,而不是绕开文件系统语义。


8. msync()fsync() 不要混着理解

只要说到 mmap,就一定得顺手把 msync() 放进图里。

因为 mmap 把“文件修改”变成了“内存页修改”,于是你自然会问:

那这些脏页什么时候真正写回去?

这里相关接口是:

  1. msync()
  2. munmap()
  3. 文件描述符上的 fsync()

工程上最重要的一点不是背细节,而是要记住:

mmap 路径下的数据修改与 read/write 路径下的数据修改,虽然都通向同一个文件系统和 page cache 世界,但程序可见的操作方式不同,持久化控制也不能想当然地混成一件事。

也就是说,不要简单把:

  1. mmap + store

当成:

  1. write() 的自动替身

它们共享底层很多机制,但上层编程模型和故障模型并不一样。


9. 到这里为止,都是“如何访问文件”;epoll 开始转向“如何等待事件”

现在我们切到 Linux 扩展里最经典的一个:epoll

先说一句最容易混淆但最重要的话:

epoll 不是文件读写接口,它主要是 I/O 就绪事件通知机制。

也就是说,epoll 解决的问题不是:

  1. 如何把字节写到文件里

而是:

  1. 当我同时管理大量 fd 时,哪些现在可读、可写、出错、挂断

这在高并发网络服务里特别关键。

因为如果你有上万连接,不可能对每个 socket 都傻傻阻塞 read()

于是 Linux 提供了:

  1. select
  2. poll
  3. epoll

epoll 是其中面向大量 fd 场景更高效的一种。


10. 为什么 epoll 更像“socket 世界的调度器”,而不是“磁盘文件加速器”

这是另一个特别常见的误区。

很多人第一次学高并发 I/O,会把 epoll 和所有 I/O 都混在一起,觉得:

  1. 有了 epoll,所有 I/O 都能变高性能

其实不对。

对普通磁盘文件来说,内核通常认为它们“总是可读/可写”或者并不适合按网络连接那种 readiness 模型使用。

所以 epoll 最擅长的场景通常是:

  1. socket
  2. pipe
  3. eventfd
  4. timerfd
  5. signalfd
  6. 其他适合 readiness 语义的 fd

也就是说:

epoll 的主战场是高并发事件驱动,不是常规磁盘文件读写本身。

这也是为什么你不能拿 epoll 去替代 read/write/fsync/mmap 那条文件 I/O 主线。

它们不是同类工具。


11. epoll 的核心价值:不是让 I/O 更快,而是让等待方式更可扩展

这一点非常关键。

epoll 真正改善的,不是单次 read() 的速度,而是:

在大量 fd 并存时,程序如何高效地知道“下一步该处理谁”。

所以它本质上是在优化:

  1. 等待模型
  2. 事件分发模型
  3. 主循环结构

这也是为什么你在 Nginx、Redis、很多网络框架里,经常会看到:

  1. epoll_wait() 拿到一批就绪事件
  2. 然后再对对应 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。

也就是说:

  1. epoll 更关心“哪些 fd 现在值得你去操作”
  2. io_uring 更关心“你把请求提交给内核,内核完成后再把结果还给你”

这两个模型在思维方式上有明显区别。

前者更像:

  1. 我来盯事件
  2. 一旦就绪,我再主动做系统调用

后者更像:

  1. 我先描述要做的 I/O 操作
  2. 交给内核执行
  3. 之后异步收割完成结果

这就是为什么 io_uring 一出来,很多人会把它看成 Linux 在 I/O 模型上的一次明显推进。


13. 为什么 io_uring 会被认为比传统同步 I/O 和 epoll 都更进一步

因为它试图同时碰几个长期存在的问题:

  1. 系统调用开销
  2. 提交与完成路径割裂
  3. readiness 模型下应用层状态机复杂
  4. 某些场景里传统 Linux AIO 能力有限且接口不统一

io_uring 的核心机制可以非常粗略地理解成两条 ring:

  1. Submission Queue, SQ
  2. Completion Queue, CQ

应用把请求放进 SQ,内核处理后把完成结果放进 CQ。

所以它最吸引人的地方在于:

  1. 批量提交更自然
  2. 批量收割更自然
  3. 可以覆盖更广的操作类型,不只是 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?

你看,一旦这么放,关系就清楚很多了。

它们不是“新接口替代旧接口”的线性演化,而更像:

  1. 基础访问语义
  2. 持久化控制语义
  3. 内存映射访问语义
  4. 高并发事件等待语义
  5. 更现代的异步提交完成语义

这五类问题本来就不是一件事。


15. 一个常见误区:把 mmapio_uring 理解成同一层竞争关系

很多人喜欢问:

  1. mmapio_uring 谁更先进
  2. mmap 能不能被 io_uring 替代

这种问法本身就有点歪。

因为它们很多时候不在同一层竞争。

mmap 更像是在说:

  1. 文件访问模型是不是以内存映射为中心

io_uring 更像是在说:

  1. I/O 请求的提交与完成方式是不是更现代、更批量化、更异步化

所以它们解决的问题并不相同。

你可以有:

  1. 基于 read/write + epoll 的系统
  2. 基于 mmap 的数据库
  3. 基于 io_uring 的高性能文件/网络服务
  4. 某些场景里甚至混合使用不同模型

关键不在于“谁更新”,而在于:

你的 workload 更像哪种访问与等待模式。


16. 存储系统应该怎样看这张图

如果你做的是存储系统,这张图特别有价值。

因为不同系统会天然偏向不同组合。

对象存储 / 日志系统

更常见关注点:

  1. open/write/fsync/rename
  2. O_DIRECT
  3. 顺序写和持久化边界

嵌入式数据库 / 映射型索引

更常见关注点:

  1. mmap
  2. 页式访问
  3. msync / fsync
  4. 崩溃恢复协议

高并发网络服务

更常见关注点:

  1. socket fd
  2. epoll
  3. 非阻塞 read/write
  4. accept/send/recv

新一代高性能 I/O 框架

更常见关注点:

  1. io_uring
  2. 批量提交与完成
  3. 减少系统调用与状态机复杂度

你会发现,不同系统并不是“从旧 API 一路升级到新 API”,而是在按自己的瓶颈选工具。


17. 为什么 POSIX 基线今天依然重要

讲到 io_uring 这种新接口时,很容易让人产生一种错觉:

  1. 传统 POSIX I/O 快过时了

这其实不对。

POSIX 基线今天仍然极其重要,原因至少有三个:

  1. 它仍然是 Unix-like 世界里最稳定的共同语言
  2. 大量语言运行时和库的默认实现仍然建立在它之上
  3. 即使你最终使用 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. 如果你只想记住一条选择思路,应该记什么

如果把工程选择压缩成一条最实用的判断线,我会建议这样记:

当你主要关心的是文件访问基础语义

先想:

  1. open/read/write/close
  2. 是否需要 lseek
  3. 是否需要 fsync/fdatasync

当你主要关心的是文件像内存一样访问

先想:

  1. mmap
  2. 页粒度行为
  3. msync/fsync 与恢复语义

当你主要关心的是大量连接上的事件等待

先想:

  1. 非阻塞 fd
  2. epoll
  3. readiness 驱动状态机

当你主要关心的是高并发提交/完成模型

先想:

  1. io_uring
  2. 批量提交/收割
  3. 统一处理不同 I/O 操作的可能性

也就是说,不要先从“哪个 API 更新”出发,而要先从:

你到底在解决访问、持久化、映射、事件等待还是异步提交问题。


20. 总结:把 I/O 放回语义地图,很多概念就不再混乱了

如果把这篇文章压缩成一句话,那就是:

open/read/write/fsync/mmap/epoll/io_uring 之所以容易让人混乱,不是因为它们本身太难,而是因为很多人把不同层次、不同目标的 I/O 机制混成了一类工具;一旦把它们放回同一张语义地图里,关系就会清楚很多。

你真正该抓住的是这几条主线:

  1. open/read/write/close 是 POSIX 文件 I/O 的基础语言
  2. fsync/fdatasync 负责把“写入可见”推进到“尽量稳定持久”
  3. mmap 提供的是文件到内存视图的访问模型
  4. epoll 解决的是大量 fd 的 readiness 等待问题
  5. io_uring 解决的是更统一、更低开销的提交/完成模型问题

它们不是一条简单替代链,而是一套逐层叠加、各自针对不同瓶颈演化出来的工具箱。

而 POSIX 与 Linux 扩展之间的关系,也应该这样理解:

  1. POSIX 提供共同基线
  2. Linux 扩展在基线之上针对性能和扩展性继续推进

你把这张图真正看清,再回头看数据库、对象存储、FUSE、网络框架、日志系统里的 I/O 设计,就会顺很多。

因为那些系统本质上都在回答同一个问题:

在什么语义约束下,用什么 I/O 模型,去换取你最在意的吞吐、延迟、持久化和复杂度平衡。