Linux IO 模型纵深解析 03:同步 IO 与异步 IO

同步 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 收尾

这不是概念辨析文章,而是源码级语义纠偏。你会看到:

  1. 同步 IO 并不等于阻塞 IO,阻塞只是同步语义的一种调度实现方式

  2. Linux 历史上的 AIO 为什么长期被骂"伪异步",以及这个骂名从何而来

  3. 为什么说 io_uring 才第一次把"异步 IO"这个词落在了内核实处

  4. 面试中高频出现的"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 kiocbstruct 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 filestruct 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,还是触发磁盘读,这条路径都从该线程开始。

第二,等待责任。

  1. 如果数据尚未准备好,调用线程是那个"被允许睡眠"的实体。
    它不是被动阻塞,而是被指定为等待主体,挂在对应的等待队列上。
  2. 即使对 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:

  1. 用户线程发起异步请求, 线程池中的工作线程接管实际 IO 任务

  2. 工作线程执行同步 IO, 完成后通过信号或回调通知用户线程

这种设计虽然绕过了主线程,但本质上依然在使用同步 IO。换句话说,线程池的引入,并没有改变内核中 IO 的同步模型 ,它仅仅是通过资源分摊来提高并发度,本质上并未突破 VFS 同步假设的束缚


3.2 Linux AIO 真正可用的领域:O_DIRECT + block IO

要理解 Linux AIO 何时能够"勉强成立",我们必须关注O_DIRECTblock 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.cinclude/linux/aio.h,这两个文件包含了 POSIX AIO 的大部分内部逻辑。

关键结构体:struct kiocbstruct 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 是通过 kiocbaio_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

readwrite 是同步 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/writeepoll 还是 io_uring,都可以通过这一原则来明确它们的"异步性"或"同步性"。

随着 io_uring 的出现,Linux 的异步 IO 真正得以解耦,它将成为未来 IO 设计的主流方向。


4.6 实用判断 checklist

  1. 调用线程是否必须亲自处理完成状态?

    • 是:同步 IO

    • 否:异步 IO

  2. 是否有事件通知机制存在?

    • 是:检查事件的"责任归属"

    • 否:同步 IO

  3. 是否有独立的完成队列(CQ)?

    • 是:异步 IO(如 io_uring)

    • 否:同步 IO

  4. 用户线程是否能够在 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 语义决定了三件事必须在一次调用中完成:

  1. IO 是否成功

  2. 数据是否可用

  3. 文件状态如何变化

这意味着:
完成点 = 返回点。

一旦你试图把"完成"推迟到调用路径之外,就必然破坏其中至少一个维度:

  • 文件偏移变得不可预测

  • 错误传播变得跨上下文

  • 并发语义需要额外协调

这正是 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"

到这里,可以把整个系列的核心认知压缩成三句话:

  1. 同步 / 异步解决的是:谁负责 IO 的完成

  2. 异步并不自动解决并发等待问题

  3. 如何高效地等待"多个 IO",是另一条完全不同的主线

这条主线,正是 IO 多路复用。

下一篇文章,将从最原始、最朴素的地方开始:

# select / poll ------ 线性扫描时代的 IO 多路复用原型

在那里,我们会看到:

在"完成责任"问题尚未解决的年代,人们是如何试图至少把"等"的成本降到最低的。

相关推荐
草莓熊Lotso8 小时前
Linux 文件描述符与重定向实战:从原理到 minishell 实现
android·linux·运维·服务器·数据库·c++·人工智能
历程里程碑8 小时前
Linux22 文件系统
linux·运维·c语言·开发语言·数据结构·c++·算法
wdfk_prog16 小时前
[Linux]学习笔记系列 -- [drivers][input]input
linux·笔记·学习
七夜zippoe16 小时前
CANN Runtime任务描述序列化与持久化源码深度解码
大数据·运维·服务器·cann
盟接之桥17 小时前
盟接之桥说制造:引流品 × 利润品,全球电商平台高效产品组合策略(供讨论)
大数据·linux·服务器·网络·人工智能·制造
忆~遂愿17 小时前
ops-cv 算子库深度解析:面向视觉任务的硬件优化与数据布局(NCHW/NHWC)策略
java·大数据·linux·人工智能
湘-枫叶情缘17 小时前
1990:种下那棵不落叶的树-第6集 圆明园的对话
linux·系统架构
Fcy64818 小时前
Linux下 进程(一)(冯诺依曼体系、操作系统、进程基本概念与基本操作)
linux·运维·服务器·进程
袁袁袁袁满18 小时前
Linux怎么查看最新下载的文件
linux·运维·服务器