同步 IO 与异步 IO ------ "谁在等谁"的内核真相
Key Words:同步 IO(Synchronous IO)、异步 IO(Asynchronous IO)、阻塞语义、系统调用、AIO(Asynchronous IO,异步输入输出)、io_uring、VFS(Virtual File System,虚拟文件系统)、task_struct、wait_queue、DMA(Direct Memory Access,直接内存访问)
很多人第一次接触 IO 模型时,脑海里都会形成一个极其顽固、却又高度危险的直觉:
同步 IO 就是阻塞,异步 IO 就是非阻塞。
这个直觉在初学阶段能勉强跑通 demo,但一旦开始读内核源码、分析高并发服务、或者在面试中被追问 "Linux 真正的异步 IO 到底异步在哪里",整个认知体系就会瞬间坍塌。
这一篇正是这个系列里的认知分水岭 。如果前两篇还停留在"模型表象"和"用户态感受",那这里要做的事情只有一件:
把"同步 / 异步"这个词,从 API 文档里拽出来,按在内核代码和调度器上反复对照。
贯穿全文的主线只有一条,而且必须死死咬住不放:
不是"调没调用回调函数",也不是"线程有没有被挂起",而是:
在 IO 生命周期中,谁负责等待 IO 的完成。
接下来所有讨论,都会沿着这条纵向穿梭线展开:
应用层看到的 read / aio_read / io_submit
→ VFS 层如何表达"我现在等还是你帮我等"
→ task_struct 与等待队列如何承接这种语义
→ 内核在什么时候把控制权交还给调度器
→ 最终由中断、DMA 或软中断完成 IO 收尾
这不是概念辨析文章,而是源码级语义纠偏。你会看到:
-
同步 IO 并不等于阻塞 IO,阻塞只是同步语义的一种调度实现方式
-
Linux 历史上的 AIO 为什么长期被骂"伪异步",以及这个骂名从何而来
-
为什么说 io_uring 才第一次把"异步 IO"这个词落在了内核实处
-
面试中高频出现的"epoll + 线程池算不算异步 IO"到底该怎么回答才不翻车
在结构安排上,后续章节会明确埋下几个技术锚点:
-
结构体拆解埋点 :
task_struct 中与 IO 等待强相关的状态位、等待队列(wait_queue_head_t)与完成通知路径,将被逐一解剖,而不是贴字段表。
-
调用流程图埋点 :
从 sys_read / sys_io_submit 进入内核后,IO 请求在同步路径与异步路径中究竟在哪一行代码开始"分叉"。
-
历史支线扩展埋点 :
从 2.6 时代的 POSIX AIO,到 libaio 的工程妥协,再到 io_uring 的语义重建,每一次设计选择背后都有明确的 trade-off。
!NOTE
这一篇的阅读目标并不是"记住定义",而是建立一个判断模型:
看到一个 IO 接口,就能下意识追问:等待行为发生在哪一层,由谁承担。
如果你曾经在这些问题上感到困惑,甚至在不同书籍、博客、面试官的说法之间来回打架,那说明你正好站在这篇文章的目标读者区间里。
文章目录
-
- [同步 IO 与异步 IO ------ "谁在等谁"的内核真相](#同步 IO 与异步 IO —— “谁在等谁”的内核真相)
- [1. 同步 IO 与异步 IO 的概念混乱](#1. 同步 IO 与异步 IO 的概念混乱)
-
- [1.1 概念点](#1.1 概念点)
- [1.2 POSIX 语义中 synchronous / asynchronous 的原始含义](#1.2 POSIX 语义中 synchronous / asynchronous 的原始含义)
- [1.3 Linux syscall 对同步语义的默认约束](#1.3 Linux syscall 对同步语义的默认约束)
- [1.4 同步 / 异步的真正分界线:完成责任属于谁](#1.4 同步 / 异步的真正分界线:完成责任属于谁)
- [1.5 "异步 IO 贫血"的根源:VFS 与 Page Cache 的工程选择](#1.5 “异步 IO 贫血”的根源:VFS 与 Page Cache 的工程选择)
- [1.6 同步 / 异步是语义问题,不是性能问题](#1.6 同步 / 异步是语义问题,不是性能问题)
- [1.7 补充支线:为什么大多数教程从这里开始埋雷](#1.7 补充支线:为什么大多数教程从这里开始埋雷)
- [1.8 抛给下一章的问题](#1.8 抛给下一章的问题)
- [2. 同步 IO 的内核现实](#2. 同步 IO 的内核现实)
-
- [2.1 Unix 文件模型的先天同步倾向](#2.1 Unix 文件模型的先天同步倾向)
- [2.2 read / write 是同步 IO 的真正原因](#2.2 read / write 是同步 IO 的真正原因)
- [2.3 syscall 到 VFS:同步语义是如何被"写死"的](#2.3 syscall 到 VFS:同步语义是如何被“写死”的)
- [2.4 Page Cache:同步模型最强的粘合剂](#2.4 Page Cache:同步模型最强的粘合剂)
- [2.5 同步 IO 的最大优势:语义稳定,而非易用](#2.5 同步 IO 的最大优势:语义稳定,而非易用)
- [2.6 为什么 O_NONBLOCK 改变不了同步本质](#2.6 为什么 O_NONBLOCK 改变不了同步本质)
- [2.7 实战范式:同步 IO 的正确打开方式](#2.7 实战范式:同步 IO 的正确打开方式)
- [2.8 小结与引向下一章的问题](#2.8 小结与引向下一章的问题)
- [3. Linux AIO 的真实面貌](#3. Linux AIO 的真实面貌)
-
- [3.1 POSIX AIO 在 Linux 上长期处于"半成品状态"](#3.1 POSIX AIO 在 Linux 上长期处于“半成品状态”)
-
- [VFS 核心假设的破坏](#VFS 核心假设的破坏)
- [glibc AIO 线程池模拟的本质](#glibc AIO 线程池模拟的本质)
- [3.2 Linux AIO 真正可用的领域:O_DIRECT + block IO](#3.2 Linux AIO 真正可用的领域:O_DIRECT + block IO)
-
- [为什么绕过 Page Cache 后 AIO 才"勉强成立"](#为什么绕过 Page Cache 后 AIO 才“勉强成立”)
- [O_DIRECT + block IO 的优势与局限](#O_DIRECT + block IO 的优势与局限)
- [3.3 POSIX AIO 的实现路径与内核源码分析](#3.3 POSIX AIO 的实现路径与内核源码分析)
-
- [关键结构体:`struct kiocb` 与 `struct aio_ring`](#关键结构体:
struct kiocb与struct aio_ring)
- [关键结构体:`struct kiocb` 与 `struct aio_ring`](#关键结构体:
- [3.4 glibc AIO vs kernel AIO:根本差异](#3.4 glibc AIO vs kernel AIO:根本差异)
- [3.5 数据库为何偏爱 Direct IO](#3.5 数据库为何偏爱 Direct IO)
- [3.6 实战范式:AIO 适用场景](#3.6 实战范式:AIO 适用场景)
- [3.7 小结与下章展望](#3.7 小结与下章展望)
- [4. "谁在等谁"的终极判断法](#4. “谁在等谁”的终极判断法)
-
- [4.1 判断 IO 是否异步的唯一问题:**调用线程是否在完成路径上承担责任**](#4.1 判断 IO 是否异步的唯一问题:调用线程是否在完成路径上承担责任)
- [4.2 逐一判定:read / write、epoll、AIO、io_uring](#4.2 逐一判定:read / write、epoll、AIO、io_uring)
-
- [1. **read / write**](#1. read / write)
- [2. **epoll**](#2. epoll)
- [3. **AIO(POSIX AIO)**](#3. AIO(POSIX AIO))
- [4. **io_uring**](#4. io_uring)
- [4.3 多数"异步 IO 框架"在 Linux 下实际上是 **同步 IO + 事件通知**](#4.3 多数“异步 IO 框架”在 Linux 下实际上是 同步 IO + 事件通知)
-
- [Reactor 模式 vs. Proactor 模式](#Reactor 模式 vs. Proactor 模式)
- [4.4 completion / eventfd 思想](#4.4 completion / eventfd 思想)
- [4.5 小结与收束:总结异步与同步的边界](#4.5 小结与收束:总结异步与同步的边界)
- [4.6 实用判断 checklist](#4.6 实用判断 checklist)
- [4.7 下一章展望:异步 IO 的实践与挑战](#4.7 下一章展望:异步 IO 的实践与挑战)
- [5. 同步 IO 与异步 IO 的阶段性总结](#5. 同步 IO 与异步 IO 的阶段性总结)
-
- [5.1 高并发时代之前,Linux 为什么坚定站在"同步"一侧](#5.1 高并发时代之前,Linux 为什么坚定站在“同步”一侧)
- [5.2 为什么"修补 read/write"永远得不到真正的异步 IO](#5.2 为什么“修补 read/write”永远得不到真正的异步 IO)
- [5.3 提交队列 / 完成队列:一次迟到二十年的抽象补课](#5.3 提交队列 / 完成队列:一次迟到二十年的抽象补课)
- [5.4 为什么 Linux 没有"真正的异步文件 IO"](#5.4 为什么 Linux 没有“真正的异步文件 IO”)
- [5.5 时代正在变化:IO 诉求发生了什么](#5.5 时代正在变化:IO 诉求发生了什么)
- [5.6 全文收束:从"谁负责完成",到"如何等多个 IO"](#5.6 全文收束:从“谁负责完成”,到“如何等多个 IO”)
1. 同步 IO 与异步 IO 的概念混乱
当前定位:IO 语义模型层(API 语义 × 内核责任边界)
1.1 概念点
"同步 IO 就是阻塞,异步 IO 就是非阻塞,对吧?"
这句话的问题不在于"说得不严谨",而在于它压根讨论的不是同一个维度 。
阻塞 / 非阻塞描述的是线程调度行为 ;
同步 / 异步讨论的则是IO 语义与责任归属。
一旦把这两个概念混为一谈,后续所有判断都会系统性失真,包括但不限于:
-
read 在 Page Cache 命中时算不算同步 IO
-
epoll + 线程池是不是异步 IO 架构
-
POSIX AIO 为什么"看起来很异步但跑得很慢"
这一章要做的事情,就是把这条认知歧路彻底切断。
1.2 POSIX 语义中 synchronous / asynchronous 的原始含义
在 POSIX 标准文本中,synchronous / asynchronous 从未被定义为"是否阻塞线程"。
它的核心表述只有一个焦点:
一次 IO 操作的完成(completion),是否必须在发起该操作的控制流中被确认。
换句话说,POSIX 关心的是:
调用者是否要对 IO 完成这件事"负责到底"。
在 synchronous IO 中,API 返回意味着:
-
IO 已经完成
-
完成结果(数据、错误码、偏移变化)已经在当前执行流中被确认
在 asynchronous IO 中,API 返回只意味着:
-
IO 已被"提交"
-
完成将在未来某个时间点,由其他机制告知调用者
这里的关键词不是"等不等",而是完成确认是否与调用路径绑定。
1.3 Linux syscall 对同步语义的默认约束
Linux 在系统调用层面,对同步 IO 有着极其强硬的默认立场。
以最常见的 read 路径为例:
c
// 用户态
read(fd, buf, count);
// 内核态调用链
sys_read
-> ksys_read
-> vfs_read
-> file->f_op->read_iter
这条路径定义在 fs/read_write.c 中,其核心约束只有一句话可以概括:
vfs_read 返回之前,这次 IO 的"完成语义"必须已经成立。
注意,这里并没有规定线程一定要睡眠。
如果数据已经在 Page Cache 中,read 完全可能是一次纯内存拷贝,但它依然是同步 IO。
原因很简单:
完成责任仍然在调用线程身上。
1.4 同步 / 异步的真正分界线:完成责任属于谁
同步与异步的真正分界线,并不在"是否阻塞线程",而在于一个更本质的问题:
IO 的推进与完成,是否必须由调用线程亲自承担。
这就是判断 IO 语义的唯一可靠判据,可以称之为:
完成责任(completion responsibility)归属原则。
在同步 IO 中:
-
IO 的生命周期被严格绑定在系统调用上下文中
-
不论中间经历多少次调度、睡眠或抢占
-
read/write 返回这一刻,调用线程必须"亲眼确认"完成结果
这也是为什么:
-
同步 IO 可以是非阻塞的(Page Cache 命中)
-
O_NONBLOCK 只能改变"愿不愿意等",却改变不了"谁负责完成"
而在真正的异步 IO 中:
-
调用线程在提交 IO 后,可以完全放弃对完成的直接控制
-
IO 的推进与完成,可以发生在完全不同的执行上下文
-
调用线程只在未来被"通知结果",而不是"亲自等到结果"
把这个判据套到几个常见机制上,混乱会立刻消失:
-
read / write
完成责任始终在调用线程 → 同步 IO
-
POSIX AIO(libaio)
API 语义是异步,但大量文件 IO 仍依赖同步推进或内核 worker → 语义异步、实现半同步
-
io_uring
提交与完成彻底解耦,完成由内核统一回收并投递到 CQ → 真正意义上的异步 IO
!IMPORTANT
判断同步 / 异步时,只问一个问题:
如果调用线程现在就"消失",这次 IO 还能不能自然完成?能 → 异步;不能 → 同步。
1.5 "异步 IO 贫血"的根源:VFS 与 Page Cache 的工程选择
Linux 长期被诟病"文件异步 IO 不成熟",但这个现象并不是能力不足,而是一个高度自觉的工程选择。
真正的约束来自两层:VFS 抽象 与 Page Cache 语义。
首先是 Page Cache。
Linux 的文件 IO 本质上不是"磁盘 IO",而是:
页缓存操作 + 延迟写回机制。
这意味着 read/write 操作的直接对象是内存中的 page,而不是块设备。
而页缓存必须满足一系列极强的同步语义约束:
-
文件偏移(
struct file::f_pos)必须线性推进 -
同一文件在不同进程间的数据可见性不能模糊
-
write 返回后,"我已经写过了"这件事必须立刻成立
这些语义天然要求:
IO 的完成点与系统调用返回点高度绑定。
再看 VFS。
VFS 的目标是统一,而不是极致并行。
它通过 struct file 与 struct file_operations,把 ext4、xfs、nfs、procfs 等完全不同的实体,强行纳入同一套 read/write 语义。
而这套语义的底线是:
API 返回即完成。
任何会破坏这一点的异步化尝试,都会在一致性、偏移语义或错误传播上引发灾难。
这也是为什么:
-
网络 IO 天然更容易异步
没有 Page Cache、没有文件偏移、数据边界天然离散
-
文件 IO 长期异步化困难
因为页缓存一致性与 VFS 统一抽象,主动压制了完成责任的转移
!NOTE
Linux 的文件 IO 同步倾向,不是历史包袱,而是为守住语义边界付出的代价。
1.6 同步 / 异步是语义问题,不是性能问题
这是最容易引发架构级灾难的误区。
很多系统在设计之初,就把"异步 IO"等价为"高性能 IO",于是出现大量反模式:
-
用 POSIX AIO 构建高并发文件服务
-
用 epoll + 线程池 + blocking read,宣称自己是"异步架构"
结果往往是:
-
线程数量失控
-
上下文切换成本吞噬吞吐
-
Page Cache 与 VFS 锁竞争被无限放大
问题的根源在于:
这些方案从未真正转移完成责任。
线程池的本质,是"用更多线程去同步等待"。
IO 依然是同步的,只是把等待成本摊给了更多执行流。
真正的异步设计,关注的从来不是"跑得快不快",而是:
-
是否减少了"为等待而存在的线程"
-
是否集中并复用完成路径
-
是否降低了完成确认的调度成本
这正是 io_uring 能在文件 IO 上第一次展现数量级优势的根本原因:
它改变的是 IO 语义责任边界,而不是简单堆资源。
!Question
epoll + 线程池算不算异步 IO?
------ 从内核语义角度看,它仍然是同步 IO 的调度放大版。
1.7 补充支线:为什么大多数教程从这里开始埋雷
!Supplement
在早期操作系统文献中,"synchronous"指的是控制流是否必须与事件完成保持一致步调,而不是"线程会不会睡眠"。
!Supplement
绝大多数教程在这一章就已经偏航,因为它们讨论的是"API 怎么用",而不是"内核在替谁承担完成责任"。
1.8 抛给下一章的问题
到这里,那个问题应该已经不再简单了:
Linux 真的支持异步 IO 吗?
如果你的第一反应变成了"要分情况看",那说明这层语义模型已经开始成型。
下一章,会直接沿着源码路径,把这个问题逼到内核面前。
2. 同步 IO 的内核现实
当前定位:syscall 路径 × VFS 同步模型
2.1 Unix 文件模型的先天同步倾向
Unix 文件模型从诞生那一刻起,就不是为"高度异步"而设计的。
它的核心抽象非常朴素:
文件是一个按字节线性展开的对象,read/write 是在这个线性空间上推进游标。
一旦接受了这个抽象,很多结论其实是被"预先决定"的:
-
文件偏移必须是确定的、可预测的
-
多次 read/write 的顺序语义不能模糊
-
返回值必须能立刻反映"这次操作对文件状态产生了什么影响"
这套模型天然要求:
系统调用返回时,IO 这件事已经在语义上完成。
同步 IO 并不是 Linux 后来"保守选择"的结果,而是 Unix 文件模型的直接投影。
2.2 read / write 是同步 IO 的真正原因
read/write 是同步 IO,不是因为它们"可能会阻塞",而是因为:
IO 的推进、等待与完成,必须由调用线程亲自参与。
这是一个责任问题,而不是一个调度问题。
当一个线程调用 read(fd, buf, size) 时,它承担了完整的一条 IO 生命周期职责:
第一,推进责任。
调用线程必须亲自把 IO 请求送入内核执行路径。
无论最终是命中 Page Cache,还是触发磁盘读,这条路径都从该线程开始。
第二,等待责任。
- 如果数据尚未准备好,调用线程是那个"被允许睡眠"的实体。
它不是被动阻塞,而是被指定为等待主体,挂在对应的等待队列上。 - 即使对 struct file 设置了 O_NONBLOCK read读取被出错也是会返回 EAGAIN。
内核不会给你吧数据准备好,也不会通知你已经准备好。需要重新调 read
第三,数据拷贝责任。
IO 完成后,数据从内核空间拷贝到用户缓冲区的动作,仍然发生在该线程的上下文中。
这一步无法被"异步化",因为它涉及用户地址空间、缺页异常和访问校验。
第四,错误与语义收尾责任。
短读、EOF、EINTR、EIO 等错误,必须在这次系统调用返回值中被明确表达。
返回值不仅是数据长度,更是语义状态的最终确认。
正是因为这四个责任被牢牢绑在调用线程上,read/write 才是同步 IO。
阻塞只是这些责任在资源不足时的一种调度表现形式。
2.3 syscall 到 VFS:同步语义是如何被"写死"的
从源码路径看,这种同步语义并不是"约定俗成",而是被一层一层固化下来的。
典型调用链如下:
c
sys_read
-> ksys_read
-> vfs_read
-> file->f_op->read
在 fs/read_write.c 中,vfs_read 的函数签名已经透露了一切:
c
ssize_t vfs_read(struct file *file,
char __user *buf,
size_t count,
loff_t *pos);
这里有两个关键点:
-
loff_t *pos文件偏移是通过指针传入的,意味着 read 必须在返回前更新它。
-
返回值是
ssize_t返回值本身承担了"完成确认"的语义角色。
在 VFS 视角里,read 的完成点就是函数返回点。
任何试图把"完成"延后到调用路径之外的设计,都会直接破坏这个接口契约,所以说是同步。
2.4 Page Cache:同步模型最强的粘合剂
如果说 VFS 是同步语义的"接口约束",那 Page Cache 就是同步模型的"工程粘合剂"。
文件 IO 在 Linux 中的真实路径,并不是:
read → 磁盘 → 拷贝 → 返回
而是:
read → Page Cache → (必要时)触发磁盘 IO → 返回
mm/filemap.c 中的逻辑表明: (mm文件夹是 memory management 的意思)
read/write 的核心操作对象是 address_space(地址空间),而不是块设备。
address_space 地址空间代表了一个文件在页缓存中的映射关系,它承担着:
-
页面缓存一致性
-
并发读写协调
-
写回(writeback)调度
一旦 IO 操作涉及 page cache,就很难允许"完成语义"的漂移:
-
read 返回时,数据必须已经稳定存在于用户缓冲区
-
write 返回时,数据必须已经进入页缓存,并对其他读者可见
-
文件偏移的变化,必须与数据可见性同步发生
这些约束使得同步模型具有极强的"粘性"。
它不是因为"好用",而是因为一旦拆开,语义会立刻失控。
!NOTE
Page Cache 的存在,本身就在反对"轻易异步化文件 IO"。
2.5 同步 IO 的最大优势:语义稳定,而非易用
很多人误以为同步 IO 在内核中"占主导",是因为它写起来简单。
这恰恰是倒因果。
同步 IO 真正不可替代的优势是:
语义稳定性与一致性保证。
在同步模型下:
-
文件偏移是线性且可推导的
-
错误处理路径是封闭的
-
并发读写的结果是可解释的
这对于内核来说,价值极高。
一旦引入广义异步模型:
-
文件偏移的归属会变得模糊
-
完成顺序需要额外排序或版本控制
-
错误传播必须跨上下文传递
这些复杂性并不是"性能优化"的自然代价,而是语义层面的结构性风险。
这也是为什么 Linux 在几十年里,对文件 IO 的态度始终是:
能同步,就同步;异步必须非常克制。
2.6 为什么 O_NONBLOCK 改变不了同步本质
!TIP
为什么 O_NONBLOCK 无法让 read 变成异步?
O_NONBLOCK 只是在告诉内核:
"如果现在没法立刻完成,那就别让我等。"
它改变的是"等待策略",而不是"完成责任"。
-
IO 仍然在调用线程中推进
-
数据拷贝仍然由调用线程完成
-
返回值仍然承担完成语义
所以 O_NONBLOCK 能让 read 变成"同步但不等待",
却永远无法让它变成"异步 IO"。
2.7 实战范式:同步 IO 的正确打开方式
!TIP
同步 IO 的标准使用模板,必须显式处理以下问题:
短读、EINTR、并发语义
c
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) < 0) {
if (errno == EINTR)
continue;
perror("read");
return -1;
}
在成熟项目中,同步 read 往往被严格限制在"尝试读取"这一职责上。
例如 muduo 的做法是:
同步 read 只负责拷数据,是否等待完全交给 epoll。
这并不是把同步 IO 变成异步,而是:
在不破坏同步语义的前提下,把等待控制权上移到事件层。
2.8 小结与引向下一章的问题
这一章需要反复记住的一句话是:
同步 IO ≠ 阻塞 IO。
read/write 的同步性,来自完成责任的绑定,而不是线程是否睡眠。
到这里,一个更尖锐的问题已经呼之欲出:
如果同步 IO 的语义如此稳固,那 Linux 究竟是如何"硬生生"引入异步 IO 的?
下一章,会从 POSIX AIO 开始,沿着失败与妥协的历史,把这个问题彻底展开。
3. Linux AIO 的真实面貌
当前层次定位:异步接口层 × 内核历史包袱
3.1 POSIX AIO 在 Linux 上长期处于"半成品状态"
在 Linux 上,POSIX AIO(异步输入输出)早已被广泛讨论和实现,但它的实现一直被开发者诟病,甚至被 Linus Torvalds 吐槽过。直到今天,它依然处于"半成品"状态。问题的根本原因,并不是 AIO 的设计本身有问题,而是它破坏了 VFS 的核心假设。
VFS 核心假设的破坏
VFS 层(虚拟文件系统层)从设计上就假设文件操作是同步进行的。这个设计假设的核心在于:
文件的偏移、可见性和一致性必须紧密绑定到调用线程的上下文中。
这保证了:
-
文件数据的偏移(
f_pos)是稳定且可预测的 -
同一进程和不同进程看到的数据是一致的
而 POSIX AIO 试图将文件 IO 完成责任从调用线程中剥离开来,允许调用线程不直接等待完成信号。这在理论上是异步的,但与 VFS 的设计哲学发生了严重冲突。
Linux AIO 最初的实现依赖于用户空间的模拟,这就是为什么 glibc AIO 使用了线程池来模拟异步行为的原因。
glibc AIO 线程池模拟的本质
glibc AIO 的实质,是通过一个线程池来模拟异步行为。这些线程实际上并没有真正"异步"处理 IO 操作,它们仍然是在内核的同步语义框架下推进,只不过由多个线程来"假装"做 IO:
-
用户线程发起异步请求, 线程池中的工作线程接管实际 IO 任务
-
工作线程执行同步 IO, 完成后通过信号或回调通知用户线程
这种设计虽然绕过了主线程,但本质上依然在使用同步 IO。换句话说,线程池的引入,并没有改变内核中 IO 的同步模型 ,它仅仅是通过资源分摊来提高并发度,本质上并未突破 VFS 同步假设的束缚。
3.2 Linux AIO 真正可用的领域:O_DIRECT + block IO
要理解 Linux AIO 何时能够"勉强成立",我们必须关注O_DIRECT 和block IO 路径。只有绕过了 Page Cache,异步 IO 才能在 Linux 内核中取得一定的效果。
为什么绕过 Page Cache 后 AIO 才"勉强成立"
在普通的同步 IO 模式下,Page Cache 是文件操作的核心部分,它承担了文件的读写操作,并且提供了数据一致性和缓存管理。当一个线程调用 read() 或 write() 时,数据并不直接从磁盘或网络设备中读取或写入,而是首先经过内存中的页缓存,这样做的目的是为了提高性能,减少磁盘 I/O。
然而,Page Cache 的存在本质上对 AIO 模型构成了巨大挑战。它要求IO 完成时,数据必须与文件系统中的偏移一致。在异步 IO 中,**如果 IO 操作的推进和完成由不同的上下文控制,**这种偏移的一致性就变得非常难以保证。
因此,Linux 中的异步 IO 只有在绕过了 Page Cache 的情况下,才能勉强成立 。这通常是通过 O_DIRECT(直接 I/O)模式实现的。在这种模式下,数据直接从用户空间和块设备之间传输,不经过 Page Cache,这样就避免了因为一致性保证而必须在调用线程中等待的问题。
O_DIRECT + block IO 的优势与局限
O_DIRECT 模式的异步 IO 可以绕过内核缓存机制,直接访问磁盘。这种方式的优势是:
-
能够减少缓存带来的延迟,特别适用于大文件的顺序读取
-
不需要通过页缓存的方式对文件偏移和数据一致性进行繁琐的管理
但这也意味着,O_DIRECT 只能在不依赖于文件系统缓存的情况下工作,它并不适用于所有应用场景,特别是那些需要高频次随机读写的操作。
3.3 POSIX AIO 的实现路径与内核源码分析
Linux AIO 的核心实现路径可以追溯到 fs/aio.c 和 include/linux/aio.h,这两个文件包含了 POSIX AIO 的大部分内部逻辑。
关键结构体:struct kiocb 与 struct aio_ring
-
struct kiocb:是一个内核控制块,表示一个异步 IO 请求。它持有异步操作的状态信息,指示操作是否已经完成,是否需要回调等信息。 -
struct aio_ring:是io_uring的一部分,它用于组织和管理异步 IO 的提交和完成队列。尽管在 POSIX AIO 中并未完全实现io_uring的机制,但它提供了基础的异步处理能力。
这两个结构体帮助内核管理异步 IO 请求,但它们依然无法完全摆脱文件系统同步模型的限制,尤其是在涉及 Page Cache 的场景下。
3.4 glibc AIO vs kernel AIO:根本差异
!Supplement
glibc AIO vs kernel AIO 的根本差异
glibc AIO 是用户空间的模拟,而 Linux 内核 AIO 则直接参与了内核级的 IO 操作。两者的差异在于,glibc AIO 更倾向于通过线程池模拟异步行为,而 Linux 内核 AIO 是通过 kiocb 和 aio_ring 来提供异步 I/O 的基础设施。
但无论如何,Linux 的 POSIX AIO 实现仍然面临着 Page Cache 同步模型的根本性限制,即使它通过使用内核线程来避免用户线程等待,最终 IO 的完成仍需要依赖内核的同步机制。
3.5 数据库为何偏爱 Direct IO
!TIP
数据库为何偏爱 direct IO?
数据库应用通常要求高效、可预测的磁盘访问性能,因此它们常常偏爱使用 O_DIRECT 来绕过页缓存,直接进行磁盘读写操作。这样可以避免数据被重复缓存到内存中,从而避免了缓存一致性和同步问题,特别是在高并发场景下,Direct IO 可以显著提高数据吞吐量。
对于大文件顺序读写,Direct IO 显然能提供更大的性能优势。然而,这种方式并不适用于所有的应用场景,尤其是需要频繁随机访问小块数据的情况。
3.6 实战范式:AIO 适用场景
!TIP
AIO 仅建议用于:
数据库
大文件顺序 IO
在高性能系统设计中,AIO 仅适用于 大文件顺序 IO 和 数据库场景 。对于文件系统而言,即便是通过 O_DIRECT 来绕过 Page Cache,异步 IO 的效率也相对较低。只有在特定情况下,异步 IO 才能够提供比同步 IO 更好的性能,尤其是在高并发写入大文件或数据库操作时。
3.7 小结与下章展望
Linux AIO 是一个经过妥协的异步模型,它并没有真正突破 VFS 的同步假设,而是通过绕过 Page Cache 和使用 O_DIRECT 模式来实现有限的异步操作。尽管这种设计在特定场景下可以提供性能提升,但它也存在诸多局限,特别是在频繁随机读写的文件操作中。
随着 io_uring 的引入,Linux 异步 IO 进入了一个新的阶段,未来的章节将深入探讨 io_uring 如何改变异步 IO 的面貌,以及它如何解决 POSIX AIO 所遗留的问题。
4. "谁在等谁"的终极判断法
当前层次定位:IO 语义抽象层
4.1 判断 IO 是否异步的唯一问题:调用线程是否在完成路径上承担责任
在 Linux 内核中,判断 IO 是否异步的唯一问题是:调用线程是否在完成路径上承担责任 。
这个问题看似简单,却能够把从应用层到内核的所有 IO 模型串联成一个清晰、可验证的思维框架。
4.2 逐一判定:read / write、epoll、AIO、io_uring
1. read / write
read 和 write 是同步 IO 的典型代表,调用线程必须亲自承担"完成责任"。
-
推进:调用线程启动 IO 请求,决定了数据从内核到用户空间的迁移。
-
等待:如果数据不在 Page Cache 中,调用线程将被挂起,等待 IO 完成。
-
完成:调用线程在 IO 完成后,通过系统调用返回数据并处理错误或偏移更新。
即使你使用了 O_NONBLOCK,这仅仅是不愿等待 而已,它并没有改变 IO 语义的本质------调用线程仍然是完成路径的责任承担者 。
因此,read / write 是同步 IO,即便它有非阻塞的特性。
2. epoll
epoll 是一种用于监听文件描述符的事件通知机制,表面上看它具备异步 IO 的特性。
然而,epoll 本质上并不提供异步 IO。
-
推进:epoll 是一个事件通知机制,用户通过 epoll_wait 等待事件的发生。
-
等待:用户线程进入等待,直到事件(如文件可读或可写)发生。
-
完成:完成的责任并没有真正"转移"------一旦事件发生,调用线程仍然需要显式地调用 read/write 来完成实际的 IO 操作。
-
结论 :epoll 实际上是 同步 IO + 事件通知 ,它通过通知用户线程"有事件发生"来避免不断轮询,但IO 完成的责任依然在调用线程身上。
3. AIO(POSIX AIO)
POSIX AIO 引入了一种"异步"的接口,试图将完成通知从调用线程中剥离。
但 Linux 上的 AIO 实际上仍存在根本性问题正如上一章讲的那样:
-
推进:用户线程提交 AIO 请求,内核启动 IO 操作。
-
等待:内核通过信号或回调告知用户线程完成状态,调用线程并不直接参与等待。
-
完成 :尽管 AIO 并不需要用户线程阻塞,但它仍然依赖内核线程或工作队列来完成 IO 操作。完成路径的责任没有完全剥离,仍由内核的某个上下文承担。
-
结论 :POSIX AIO 的确能够实现一定程度的异步,但它并没有从根本上改变同步 IO 的模型,调用线程依然需要等待通知或检查完成状态。
4. io_uring
io_uring 是目前最接近"真正异步 IO"的实现,它引入了一个全新的异步提交和完成模型。
-
推进 :用户线程通过
io_uring_submit提交 IO 请求,内核通过环形缓冲区接收请求。 -
等待:用户线程提交请求后,并不会主动阻塞等待,而是可以继续执行其他任务。
-
完成 :完成通知通过 completion queue (CQ) 回传给用户线程,用户线程无需参与 IO 操作的完成。
-
结论 :io_uring 是异步 IO 的典型代表,它通过独立的队列将完成责任完全从调用线程中剥离,并能在内核和用户空间之间实现高度解耦。
!IMPORTANT
判断 IO 是否异步的核心依据:如果调用线程"必须"参与完成路径,那么它就是同步 IO。
如果完成可以完全在内核或其他上下文中完成,那么它就是异步 IO。
4.3 多数"异步 IO 框架"在 Linux 下实际上是 同步 IO + 事件通知
多数所谓的"异步 IO 框架"在 Linux 下,实际上依然是同步 IO + 事件通知 。这一点从传统的 epoll 到 glibc AIO、甚至现代的线程池,都能看得清清楚楚。
Reactor 模式 vs. Proactor 模式
-
Reactor 模式 :
事件循环由用户线程控制,它等待事件发生(如 epoll_wait),一旦有可读写事件,用户线程就会执行 IO 操作。
同步 IO + 事件通知,调用线程"仍然在 IO 操作的完成路径上"。 -
Proactor 模式 :
完全的异步模型,事件发生后内核或底层服务(如
io_uring)自动完成 IO 操作,用户线程仅在 IO 完成后得到通知。
异步 IO,完成的责任由内核或工作线程承担,用户线程不直接参与 IO 完成。
!TIP
面试中如果被问到"epoll 是同步还是异步",最准确的回答是:epoll 是同步 IO,通过事件通知机制避免了轮询,但调用线程仍需参与实际的 IO 操作。
4.4 completion / eventfd 思想
completion 是 Linux 内核中常用的一种异步事件通知机制。它通过 struct completion 实现任务完成后的通知,主要用于内核内部的异步执行。
同样,eventfd 是一种基于文件描述符的事件通知机制,允许线程或进程之间通过文件描述符传递事件信号。它为用户提供了类似的异步通知功能,广泛用于现代的异步框架中。
include/linux/completion.h
在内核中,completion 主要通过 等待(wait)和唤醒(wake)机制实现:
-
线程在等待某个事件完成时,可以调用
wait_for_completion()来挂起自己,直到该事件完成。 -
内核的其他部分可以通过
complete()来唤醒等待的线程。
这种机制使得内核能够在某些任务完成时,通知并唤醒其他线程,从而避免了传统的阻塞等待。
4.5 小结与收束:总结异步与同步的边界
至此,我们已经清楚地定义了如何判断一个 IO 操作是同步还是异步:
调用线程是否参与完成路径 。
这一原则贯穿整个 Linux 内核的 IO 模型,无论是 read/write、epoll 还是 io_uring,都可以通过这一原则来明确它们的"异步性"或"同步性"。
随着 io_uring 的出现,Linux 的异步 IO 真正得以解耦,它将成为未来 IO 设计的主流方向。
4.6 实用判断 checklist
-
调用线程是否必须亲自处理完成状态?
-
是:同步 IO
-
否:异步 IO
-
-
是否有事件通知机制存在?
-
是:检查事件的"责任归属"
-
否:同步 IO
-
-
是否有独立的完成队列(CQ)?
-
是:异步 IO(如 io_uring)
-
否:同步 IO
-
-
用户线程是否能够在 IO 完成前自由运行?
-
是:异步 IO
-
否:同步 IO
-
4.7 下一章展望:异步 IO 的实践与挑战
到这里,我们已经理清了 IO 模型的基本框架,下一章将深入探讨 io_uring 的应用场景与挑战,揭示其在高并发、低延迟环境下的表现和局限,并为实践中的设计提供更具操作性的指导。
5. 同步 IO 与异步 IO 的阶段性总结
当前定位:IO 模型认知分水岭
5.1 高并发时代之前,Linux 为什么坚定站在"同步"一侧
如果把时间拨回到 Unix 诞生与 Linux 成长的早期阶段,会发现一个被今天严重低估的事实:
同步 IO 在当时不是"保守方案",而是唯一能成立的工程解法。
Linux 长期以同步语义为核心,并不是因为设计者"不懂异步",而是因为整个操作系统栈------从文件模型、内存模型到硬件交互方式------都天然围绕"同步完成"来构建。
首先是文件模型的历史惯性 。
Unix 抽象中的文件,是一个线性字节流,read/write 同时承担了三种职责:
推进偏移、拷贝数据、确认完成。
这些职责在设计之初就被强行绑定在一次系统调用中,而不是拆分成"提交"和"完成"两个阶段。
这种绑定带来的好处是显而易见的:
语义简单、错误路径封闭、并发行为可解释。
一个系统调用返回,就意味着一次完整、可验证的状态跃迁已经发生。
其次是Page Cache 的出现进一步加固了同步语义 。
Linux 并不是一个"磁盘操作系统",而是一个"以内存为中心的 IO 系统"。
read/write 的主要对象不是块设备,而是页缓存中的 page。
这要求:
-
数据在返回时已经稳定存在
-
文件偏移已经推进
-
并发读写能看到一致结果
这些要求几乎是天然反异步的。
一旦允许 IO 在系统调用返回后继续推进,Page Cache 的一致性、可见性和回写路径都会立刻变成灾难现场。
再往下,是硬件与中断模型的限制 。
早期磁盘 IO 粒度粗、延迟高,中断频繁而昂贵。
让内核替用户线程"偷偷完成 IO",并不能带来真正的吞吐提升,反而会让调度与同步成本失控。
站在那个时代的工程视角看,Linux 的选择并不保守,反而非常理性:
与其构建一个语义脆弱、实现复杂、收益不明的异步文件 IO,不如把同步语义做到极致稳定。
!NOTE
Linux 以同步 IO 为核心,不是能力不足,而是对"语义稳定性"压倒性优先的历史选择。
5.2 为什么"修补 read/write"永远得不到真正的异步 IO
直到今天,仍然有人试图在 read/write 这套接口之上"缝补"异步能力:
O_NONBLOCK、AIO、线程池、事件回调......
这些方案有的能缓解等待,有的能提高并发,但它们都绕不开一个根本问题:
read/write 从一开始就不是为"提交 / 完成分离"而设计的。
read/write 的 API 语义决定了三件事必须在一次调用中完成:
-
IO 是否成功
-
数据是否可用
-
文件状态如何变化
这意味着:
完成点 = 返回点。
一旦你试图把"完成"推迟到调用路径之外,就必然破坏其中至少一个维度:
-
文件偏移变得不可预测
-
错误传播变得跨上下文
-
并发语义需要额外协调
这正是 POSIX AIO 在 Linux 上长期处于尴尬状态的根本原因。
它不是"不够异步",而是被强行塞进了一个不允许异步的语义容器。
真正的异步 IO,必须做一件 read/write 永远做不到的事:
重塑 IO 的生命周期模型。
也就是把一次 IO 明确拆成两个阶段:
-
提交(submission):我想做一件 IO
-
完成(completion):这件 IO 已经结束
只有当这两个阶段在语义上、结构上彻底分离,完成责任才能真正从调用线程身上移走。
这正是 io_uring 的革命性之处。
io_uring 没有试图"优化 read/write",而是直接绕开了它。
它告诉内核的不是"帮我读点数据",而是:
我提交了一批 IO 请求,你在合适的时候完成它们,然后把结果放到完成队列里。
从这一刻开始:
-
系统调用不再等同于完成点
-
IO 生命周期不再绑定线程
-
完成可以集中、合并、批量回收
这不是"性能优化",而是语义重构。
!IMPORTANT
真正的异步 IO,不是让 read/write 更快,而是让"完成"不再属于调用线程。
5.3 提交队列 / 完成队列:一次迟到二十年的抽象补课
在今天回头看,会发现 io_uring 所采用的 提交队列(Submission Queue) / 完成队列(Completion Queue) 思想,其实并不新。
它早已存在于:
-
硬件层(DMA 描述符队列)
-
网络协议栈
-
GPU 命令提交模型
只是文件 IO 长期被 Page Cache 和 VFS 语义包裹,迟迟没有引入这一抽象。
提交队列的意义在于:
把"我想做什么"从"什么时候完成"中解耦。
完成队列的意义在于:
把"完成确认"从具体线程中抽离,变成一种可消费的事件流。
一旦这两个队列成立,真正的异步 IO 才有了存在的语义空间。
5.4 为什么 Linux 没有"真正的异步文件 IO"
!Supplement
面试题:为什么 Linux 没有真正的异步文件 IO?
因为在很长一段时间里:
-
文件模型不允许
-
Page Cache 不允许
-
工程收益不足以覆盖语义风险
Linux 不是"不支持",而是不愿意为一个尚未成熟的需求,牺牲整个文件系统的稳定性。
io_uring 的出现,恰恰说明了一点:
当需求真的成熟到必须改变语义模型时,Linux 选择的是推倒重来,而不是打补丁。
5.5 时代正在变化:IO 诉求发生了什么
高并发时代带来的变化非常明确:
-
IO 数量远大于 CPU 核数
-
线程不再是廉价资源
-
延迟比吞吐更敏感
这使得"一个 IO 占用一个线程"等待完成的模型,开始显露出系统性瓶颈。
但请注意:
这并不意味着同步 IO 是错误的。
它只是完成了自己的历史使命。
5.6 全文收束:从"谁负责完成",到"如何等多个 IO"
到这里,可以把整个系列的核心认知压缩成三句话:
-
同步 / 异步解决的是:谁负责 IO 的完成
-
异步并不自动解决并发等待问题
-
如何高效地等待"多个 IO",是另一条完全不同的主线
这条主线,正是 IO 多路复用。
下一篇文章,将从最原始、最朴素的地方开始:
# select / poll ------ 线性扫描时代的 IO 多路复用原型
在那里,我们会看到:
在"完成责任"问题尚未解决的年代,人们是如何试图至少把"等"的成本降到最低的。