想象你走进一家生意火爆的餐厅。你点完菜后只能坐在座位上,眼睛死死盯着厨房门——菜不出,你哪儿也去不了,这叫“同步阻塞”。如果换一家餐厅,你点完菜可以玩手机、回消息,手里的呼叫器一响再去取餐,这叫“异步 IO”。Linux 内核的 AIO(Asynchronous I/O)正是那个“呼叫器系统”:进程提交一个读写请求后立刻返回,继续做别的事,等到磁盘把数据准备好,内核以事件通知的方式喊你“菜好了”。本文还会讲到,老式 AIO 呼叫器有点钝(要自己反复确认),于是内核团队又发明了 io_uring——相当于把餐厅改造成旋转寿司传送带,点餐和取餐都在一条共享传送带上完成。读完这篇文章,你会彻底搞懂 AIO 的工作原理、事件通知的几种方式,以及在实际系统中怎么用它榨干磁盘的每一分性能。

岗位关联

对 AI 从业者的价值:AI 训练与推理依赖海量数据读写,掌握 Linux AIO 与事件通知,能帮你设计出高速数据管道,是理解存储引擎、分布式系统性能底座的必备技能。

目录
  1. 可视化演示
  2. AIO基础概念
  3. 系统调用接口
  4. 事件通知机制
  5. 性能对比分析
  6. 内核实现原理
  7. io_uring演进
  8. 工程实践问题
  9. 优化策略
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

AIO基础概念

老式餐厅的顾客点完菜,只能坐在位子上盯着厨房,哪怕茶水喝了三杯、手机玩到没电,菜不来就哪儿也去不了。这种“干等”让时间白白流走——它就像 Linux 的同步 IO:调用 read() 后,进程只能阻塞在系统调用里,磁盘不给数据就什么都做不了。更麻烦的是,如果要读 10 个文件,就得按顺序干等 10 次。

与之相对,现代餐厅给顾客一个呼叫器。点完菜你可以回去改代码,听见提示音再回来取餐。Linux AIO(异步 IO)正是这种模式:应用发起读或写请求后,系统调用立刻返回,内核在后台完成磁盘 IO,完成后通过事件通知应用。于是进程有了“等菜时间”去做别的事,多个 IO 可以同时“在锅里煮着”。

AIO 本质上是把“提交请求”和“收割结果”拆成两个动作。可视化演示中,同步串行处理 4 个请求,总耗时约等于 4 个请求耗时之和;而 AIO 并行发出 4 个请求,总耗时约等于最慢的那个。注意,AIO 不等同于非阻塞 IO——非阻塞 read() 返回 EAGAIN 后仍需重复发起,AIO 则是一次提交、完成时通知。

回到餐厅里:点得越多、厨师越忙,呼叫器模式省下的时间越可观;可如果每道菜只需一口(请求太小),点单写纸条的功夫反而可能超过“干等”。这就是 AIO 的适用边界——它最适合大块、并发、可重叠的磁盘 IO。

S=n×tts+tmax⁡\displaystyle S = \frac{n \times t}{t_s + t_{\max}}
加速比公式:点 n 个菜、每道菜平均做 t 秒、提交菜单耗时 t_s、最慢一道菜耗时 t_max。同步要 n×t 秒,AIO 只需提交时间加最慢菜的耗时。
  1. 分子 n×t同步模式下 n 个请求串行完成的总耗时,每个请求都要等上一位做完。
    n×tn \times t
  2. 分母 t_sAIO 将 n 个请求一次性提交给内核的系统调用总开销。
    tst_s
  3. 分母 t_maxn 个请求并行执行时,最慢那个请求的耗时决定了整体等待。
    tmax⁡t_{\max}
  4. 整体含义S 越大,AIO 相对同步的收益越明显;若 t_s 接近 n×t(请求极小且极多),收益会被提交成本吃掉。
    S=n×tts+tmax⁡S = \frac{n \times t}{t_s + t_{\max}}

系统调用接口

到呼叫器餐厅就餐,完整流程是:找服务员确认台号(创建上下文)、按呼叫器号码下单(提交请求)、呼叫器响了去取餐(收割完成事件)、最后结账离席(销毁上下文)。Linux AIO 的用户态接口 libaio 把这四步映射成四个系统调用,它们像餐厅的四个岗位一样各司其职。

io_setup 先创建 AIO 上下文,参数 nr_events 决定队列最多容纳多少并发请求,类似餐厅最多同时开放多少张桌。io_submit 接受一个 iocb 数组,批量提交请求——相当于服务员一次把一沓菜单送进厨房。iocb 里包含了文件描述符、操作码(读/写)、缓冲区指针和字节数,是请求的“菜品规格单”。

io_getevents 阻塞等待并收割完成事件,可以指定最小完成数和超时;io_destroy 销毁上下文并回收内核资源。值得强调的是,io_setup 的 nr_events 不是越大越好:每个并发槽位都要占用内核内存,设得过高反而拖累性能。一次完整的异步 IO 请求,时间上由四段构成。

这四步环环相扣。设计良好的程序会把 setup 放在初始化阶段、把 submit 做成批量提交,从而让 T 中的“固定成本”被几千个请求均摊——餐厅还是那家餐厅,但翻台率高了不止一倍。

T=tsetup+tsubmit+twait+thandle\displaystyle T = t_{\mathrm{setup}} + t_{\mathrm{submit}} + t_{\mathrm{wait}} + t_{\mathrm{handle}}
单请求总耗时 = 创建上下文 + 提交请求 + 等待完成 + 处理结果。其中 t_wait 在同步模式下是“干等”,在 AIO 下被压缩到一次事件通知。
  1. t_setup 创建上下文io_setup 只做一次,摊销到大量请求上,相当于餐厅装修只花一次钱。
    tsetupt_{\mathrm{setup}}
  2. t_submit 提交请求io_submit 把 iocb 数组交给内核,返回值是成功提交的数量。
    tsubmitt_{\mathrm{submit}}
  3. t_wait 等待完成io_getevents 阻塞直到事件到达,这是异步 IO 中被优化的核心部分。
    twaitt_{\mathrm{wait}}
  4. t_handle 处理结果应用从 event 结构中取出字节数和错误码,相当于核对菜品是否上齐。
    thandlet_{\mathrm{handle}}

事件通知机制

呼叫器响了,可你怎么知道它响了?有人每隔几秒就低头看一眼(忙轮询);有人竖起耳朵等铃响(阻塞等待);还有人雇了个服务员,铃一响就喊他(信号通知)。AIO 的事件通知也有这几种方式,选择不同,CPU 的消耗天差地别。

最朴素的是忙轮询:反复调用 io_getevents 并传入非阻塞参数,没有完成事件就立即返回。这就像顾客每隔十秒按一次呼叫器,虽然不会漏,但每次“按”都要消耗电量和注意力——CPU 空转在浪费。另一种极端是阻塞等待:进程挂起睡去,直到事件到达被唤醒,省 CPU 但少了灵活性。

更精巧的是 eventfd:它创建一个“铃铛文件描述符”,内核每完成一个请求就往这个 fd 里写入计数,fd 变成可读状态。应用可以把它丢进 epoll 里,与网络事件统一监听——呼叫器的铃铛接进了总控台。此外还有信号通知(SIGIO),内核用信号主动打断进程,适合特定场景。直觉地看,通知方式的效率差异可以这样理解。

在真实服务器里,eventfd 之所以流行,正是因为它让 AIO 完成事件“接入”了 epoll 事件循环。厨师做好菜不需要满店喊人,只需把“菜好了”的信号挂到总台上,谁在等谁取。

Epoll=N×c,Eeventfd=ce\displaystyle E_{\mathrm{poll}} = N \times c, \quad E_{\mathrm{eventfd}} = c_e
忙轮询 N 次、每次代价 c,总代价是 N×c;eventfd 方式只需 epoll 等待一次唤醒,代价是常数 c_e。当 N 很大时,eventfd 优势碾压。
  1. N 次轮询无事件期间每次非阻塞 io_getevents 都算一次无效探测,N 与等待时间成正比。
    N×cN \times c
  2. c 单次轮询代价包含系统调用进入内核、检查队列、返回用户态的完整往返。
    cc
  3. c_e 事件唤醒代价epoll 等待时进程睡眠,内核只在完成事件写入 eventfd 时唤醒一次。
    cec_e
  4. 对比结论只要 N×c 远大于 c_e(即无事可做时轮询很多次),事件通知就更划算。
    Epoll≫EeventfdE_{\mathrm{poll}} \gg E_{\mathrm{eventfd}}

性能对比分析

单车道餐厅只有一条出菜口,所有顾客的菜必须排成一队,后到的只能等前面的菜先出;多车道餐厅有四个出菜口,四份菜同时出锅。同步 IO 像是单车道,AIO 则是把请求铺到多个“车道”上同时跑。分别从延迟、吞吐、CPU、上下文切换四个维度看,差别一目了然。

延迟方面,同步模式每个请求的平均延迟等于请求本身耗时,而 AIO 把多个请求的重叠时间摊薄,整体延迟接近最慢请求。吞吐量上,同样的时间窗口内,AIO 能处理的请求数远多于同步——可乐机一次只能接一杯,但你有四台可乐机。CPU 占用上,同步阻塞时线程被挂起看似省电,但线程频繁阻塞和唤醒会带来调度开销;忙轮询则更糟,直接烧满一个核。

最大的暗藏开销是上下文切换:每个同步阻塞请求至少触发一次进程睡眠和一次唤醒,两次用户态/内核态切换;AIO 让线程留在用户态处理业务,切换次数大幅下降。用 IOPS 可以量化这个差距。

把四条车道同时打开,吞吐自然上去了。但注意,AIO 的收益不是免费的——它需要应用端有足够的并发请求来“填满车道”,如果同一时刻只有一两个 IO,单车道效率反而更高。

IOPS=Btsubmit+tevent\displaystyle \mathrm{IOPS} = \frac{B}{t_{\mathrm{submit}} + t_{\mathrm{event}}}
每秒能完成的 IO 次数 = 每批请求数 B ÷ (提交一批的时间 + 处理完成事件的常数)。同步模式下 B 恒等于 1;AIO 让 B 变成几十上百。
  1. B 批量大小一次 io_submit 提交的请求数,AIO 可以轻松做到数十上百。
    BB
  2. t_submit 批次提交耗时一次系统调用提交整批请求,均摊到每个请求上的成本降为原来的 1/B。
    tsubmitt_{\mathrm{submit}}
  3. t_event 单事件处理耗时从完成队列取出事件并回调用户代码的固定开销。
    teventt_{\mathrm{event}}
  4. 整体含义B 越大,每个请求的平摊成本越低,IOPS 越高;同步模式的 B=1,所以天花板天然低。
    IOPS=Btsubmit+tevent\mathrm{IOPS} = \frac{B}{t_{\mathrm{submit}} + t_{\mathrm{event}}}

内核实现原理

走进餐厅后厨,你会发现一沓订单并没有被厨师随手抓取,而是挂在挂钩上按顺序排队;做好一道菜会放在出菜口,由传菜员端走。Linux AIO 的内核实现正是这样一套“后厨流水线”:请求队列、块设备调度、完成队列、内核线程各司其职。

当 io_submit 把 iocb 交给内核后,请求进入请求队列。块设备层像经验丰富的厨师长,会对队列进行合并和排序(电梯算法),把相邻的扇区请求合成大块,减少磁盘寻道。请求发给磁盘控制器后,DMA 把数据直接搬到用户缓冲区,完成后一个“完成事件”被放入完成队列——这就是出菜口。

内核可能用辅助线程或软中断来处理完成事件,具体取决于请求类型和内核配置。这里有个绕不开的关键点:页缓存交互。默认情况下,文件 IO 会先经过页缓存,同步 read 若命中直接返回;而 AIO 为了不与页缓存中的“半成品”纠缠,常配合 O_DIRECT 标志绕过页缓存,让 DMA 直接读写用户缓冲区。用 Little's law 可以刻画队列中的请求数量关系。

后厨的流水线设计决定了 AIO 能承受多少“在途订单”。让队列保持合理的深度,既能让磁盘一直有活干,又不至于让等待时间膨胀——这是所有高性能存储引擎的调优核心。

L=λ×W\displaystyle L = \lambda \times W
队列中的平均请求数 L = 到达率 λ × 每个请求的平均等待时间 W。队列越深、到达率越高,等待时间越长。
  1. L 队列平均长度同时在内核中排队等待磁盘响应的请求数,近似等于 AIO 上下文中的 in-flight 请求数。
    LL
  2. λ 请求到达率单位时间内应用提交的请求数,相当于点单速率。
    λ\lambda
  3. W 平均等待时间请求从提交到完成所需的平均时长,包含排队和磁盘服务时间。
    WW
  4. 含义想要高吞吐(λ 大),要么接受更长的等待 W,要么增加队列深度 L,三者必须平衡。
    L=λ×WL = \lambda \times W

io_uring演进

传统呼叫器餐厅有个痛点:顾客点一次菜要按一次铃,每道菜都要一次“提交→等待→确认”的往返。旋转寿司餐厅则彻底改变了交互方式——所有菜品放在一条环形传送带上,顾客想吃什么直接拿,厨师不断往传送带上补菜。io_uring 就是 Linux 内核的“旋转寿司传送带”。

io_uring 的核心创新是共享内存环形队列:内核和用户态共用一块内存,应用把请求写入提交队列(SQ),内核消费后把完成结果写入完成队列(CQ)。由于队列在共享内存里,应用不需要每次提交都陷入内核——可以一口气把 100 个请求塞进 SQ,然后只做一次系统调用通知内核“有新菜了”。

相比 libaio,io_uring 带来了两个革命性改进:一是系统调用次数从“每请求一次”降到“每批一次”;二是打破了 AIO 对 O_DIRECT 的束缚,支持缓冲 IO、poll 等多种模式——传送带不仅传寿司,还传刺身和甜品。系统调用次数的降低可以直接量化。

传送带一旦转起来,效率提升是结构性的。io_uring 把“每次交互的固定成本”压缩到极致,这正是它在高 IOPS 场景下全面碾压老式 AIO 的原因。

syscalls=⌈NB⌉\displaystyle \text{syscalls} = \left\lceil \frac{N}{B} \right\rceil
处理 N 个请求,每批提交 B 个,需要的系统调用次数为 N/B 向上取整。B 越大,陷入内核的次数越少。
  1. N 请求总数应用要完成的所有 IO 请求数量。
    NN
  2. B 每批请求数一次内核交互可提交的请求数量,io_uring 的批量能力远强于 libaio。
    BB
  3. 向上取整即使最后一批不满 B 个,也要一次系统调用收尾。
    ⌈NB⌉\left\lceil \frac{N}{B} \right\rceil
  4. 含义同步模式 syscalls=N,libaio 模式下 syscalls≈N,io_uring 模式下 syscalls≈N/B,数量级差异一目了然。
    ⌈NB⌉≪N\left\lceil \frac{N}{B} \right\rceil \ll N

工程实践问题

开了呼叫器餐厅,才发现坑一个接一个:有的调料必须放在固定位置、有的顾客一次点了太多菜把厨房挤爆、还有的下错了单不知道找谁。工程上使用 AIO 同样绕不开几个硬性约束,踩中任何一个都会让性能崩塌。

第一个坑是 O_DIRECT。Linux 的 libaio 在非 O_DIRECT 模式下,某些文件系统上可能退化为同步行为——你以为菜已下锅,其实厨师还在等你确认。使用 O_DIRECT 打开文件,是让 AIO 真正“异步”的前提。第二个坑是缓冲区对齐:DMA 直接读写用户内存,要求缓冲区的地址、长度和文件偏移都按 512 字节(或更大扇区)对齐,否则返回 EINVAL。

第三个坑是并发控制。io_setup 的 nr_events 决定了 in-flight 请求上限,超出后 io_submit 会阻塞或用完队列;同时系统对每个进程的 AIO 上下文数量和内存也有上限。第四个坑是错误处理:io_getevents 返回的事件数可能小于请求数,必须循环收割;单个请求失败不影响其他请求,需要逐事件检查错误码。

对齐要求可以用一个简单的关系来表达:凡是和磁盘扇区打交道的数据,都必须是扇区大小的整数倍。

这些约束看似繁琐,背后是 DMA 硬件的物理限制。理解了“传送带凹槽”的道理,你就能在写代码时主动遵守对齐规则,而不是等线上报错再去抓头发。

offset mod 512=0,len mod 512=0,buf mod 512=0\displaystyle \text{offset} \bmod 512 = 0, \quad \text{len} \bmod 512 = 0, \quad \text{buf} \bmod 512 = 0
偏移、长度、缓冲区地址三个值都必须能被 512 整除。这就像传送带上每个餐盘必须严丝合缝地卡进凹槽,歪一点就卡住。
  1. 偏移对齐文件读写的起点位置必须落在扇区边界上,否则 DMA 无法直接操作。
    offset mod 512=0\text{offset} \bmod 512 = 0
  2. 长度对齐读写字节数必须是 512 的整数倍,最后不足一块会被拒绝或截断。
    len mod 512=0\text{len} \bmod 512 = 0
  3. 地址对齐用户态缓冲区首地址必须 512 字节对齐,posix_memalign 可保证这一点。
    buf mod 512=0\text{buf} \bmod 512 = 0
  4. 含义三条同时满足,DMA 才能“一把梭”地从磁盘搬到内存,否则只能走慢速拷贝路径。
    offset≡len≡buf≡0(mod512)\text{offset} \equiv \text{len} \equiv \text{buf} \equiv 0 \pmod{512}

优化策略

定位到坑之后,怎么让餐厅在高峰期跑得更快?经验丰富的店长会做四件事:把菜单批量下厨房(批量提交)、根据客流调整桌数(队列深度)、让厨师做完直接送餐不摇铃(内核轮询)、固定传菜员走固定路线(CPU 绑定)。

批量提交是最直观的优化:一次 io_submit 塞入几十个 iocb,把系统调用摊销成本降到最低。其次是队列深度调优:太浅导致磁盘空闲,太深导致等待时间长,经验法则是让队列深度等于磁盘的并发能力(NVMe 可达几百)。io_uring 还提供 IOPOLL 模式,应用主动轮询完成队列而不是依赖中断,省去“摇铃”的延迟。

CPU 绑定则更精细:将发起 IO 和消费完成事件的线程绑定到固定 CPU 核,避免核间缓存抖动和 NUMA 访问延迟。这些优化共同作用时,加速效果可以用 Amdahl 公式估算。

四条策略环环相扣。传送带的转速、盘子的间隔、厨师的步点全部调优到位后,AIO 的潜力才算真正释放出来——这时候你拥有的不再是一家普通餐厅,而是一条高速运转的寿司流水线。

S=1(1−p)+pn\displaystyle S = \frac{1}{(1 - p) + \frac{p}{n}}
若优化前的串行部分占比 1-p、可并行部分占比 p,并行度提升到 n 倍后,整体加速比 S 是这个公式。p 越接近 1,收益越大。
  1. p 可并行比例IO 请求中真正能重叠执行的部分,AIO 让 p 接近 1。
    pp
  2. n 并行度提升倍数由队列深度、批量大小共同决定。
    nn
  3. 1-p 串行部分必须顺序执行的代码,如事件循环的公共逻辑。
    1−p1 - p
  4. 含义优化 IO 时先提高 p(让更多请求重叠),再增大 n(加深队列),才能逼近线性加速。
    S=1(1−p)+pnS = \frac{1}{(1 - p) + \frac{p}{n}}

AI 应用场景

高性能数据库存储引擎

阶段:数据准备与特征读取

解决的问题:数据库要频繁读取磁盘上的数据页,同步 IO 让查询线程大量阻塞在磁盘等待上,吞吐量受限。AI 训练管道需要从海量样本文件中快速读取,MySQL InnoDB 等存储引擎使用 AIO 批量发起数据页读写,让查询线程在等待磁盘时继续处理其他请求,大幅提升每秒查询数。

具体案例:某推荐系统特征数据库面对每秒上万次随机读,使用 libaio + O_DIRECT 后,单机 IOPS 从 5 万提升到 20 万,查询延迟 P99 从 50ms 降到 15ms。

分布式日志与消息队列

阶段:训练数据流式写入

解决的问题:Kafka 等服务需要把海量日志顺序追加到磁盘,传统 write() 每写一条就要等落盘,吞吐上不去。使用 AIO/io_uring 批量提交追加写请求,并利用事件通知在写入完成时统一确认,让磁盘始终保持“满负荷写”状态。

具体案例:某日志平台用 io_uring 重写了刷盘路径,单节点写入吞吐从 800MB/s 提升到 1.3GB/s,CPU 占用反而下降 20%。

AI 训练数据管道

阶段:样本加载与预处理

解决的问题:GPU 训练时数据加载成了瓶颈,CPU 被大量阻塞在磁盘读上。深度学习框架的数据加载器用 AIO 同时预取多个训练样本,让“读下一个 batch”和“训练当前 batch”重叠,接近零等待。

具体案例:某 CV 训练任务使用 AIO 预取图像数据后,GPU 利用率从 62% 提升到 91%,训练周期缩短近三分之一。

Python 演示

python55 行
import threadingimport time  class AIOSimulator:    """用线程模拟异步IO:提交后立即返回,完成时通过事件通知"""     def __init__(self):        self.completed = []        self.done_event = threading.Event()     def _do_io(self, req_id, cost):        time.sleep(cost)  # 模拟磁盘IO耗时        self.completed.append(req_id)        self.done_event.set()     def submit(self, req_id, cost):        t = threading.Thread(target=self._do_io, args=(req_id, cost), daemon=True)        t.start()  # 类似io_submit,提交后立即返回     def wait_all(self, total):        """类似io_getevents:阻塞直到所有请求完成"""        while len(self.completed) < total:            self.done_event.wait(timeout=0.01)            self.done_event.clear()  def sync_io(requests):    """同步IO:串行执行,一个完成才发下一个"""    for req in requests:        time.sleep(req)  def async_io(requests):    """异步AIO:全部提交,用事件通知等待完成"""    aio = AIOSimulator()    for i, cost in enumerate(requests):        aio.submit(i, cost)    aio.wait_all(len(requests))  if __name__ == "__main__":    io_times = [0.2, 0.3, 0.1, 0.25]     t0 = time.time()    sync_io(io_times)    sync_cost = time.time() - t0     t0 = time.time()    async_io(io_times)    async_cost = time.time() - t0     print(f"同步串行耗时: {sync_cost:.3f}s")    print(f"异步并行耗时: {async_cost:.3f}s")    print(f"加速比: {sync_cost / async_cost:.1f}x")
目的:用线程库模拟同步 IO 与异步 AIO 的耗时差异,直观展示“提交后返回 + 事件通知”带来的加速效果。
  1. class AIOSimulator模拟 AIO 上下文,内部维护完成列表和通知事件。
  2. self.done_event = threading.Event()对应 eventfd,完成事件到达时置位唤醒等待者。
  3. t.start()对应 io_submit,提交后线程继续往下走,不阻塞。
  4. def wait_all对应 io_getevents 的阻塞收割,用事件循环等待全部完成。
  5. sync_io同步版本逐个 sleep,总耗时是四个请求之和。
  6. async_io异步版本同时发起四个 sleep,总耗时约等于最慢请求。
  7. sync_cost / async_cost计算出加速比,验证可视化演示中的结论。

常见误区

误区我以为 AIO 就是程序里创建一堆线程,每个线程阻塞读数据,反正都是“同时干活”,效果都一样。

正解AIO 与多线程有本质区别。多线程同步 IO 中,每个线程依然在 read() 内部阻塞,操作系统要维护大量线程的栈、调度和切换;而 AIO 是单线程内提交多个异步请求,内核直接管理 IO 流程,完成时通知即可。它像呼叫器,而不是请一堆服务员干等。

为何常见:两者“并发执行”的外观相似,且多线程方案更容易实现,初学者容易混淆“并发”和“异步”的概念。

误区既然 AIO 是异步的,那我项目里所有 IO 都换成 AIO,性能一定翻倍。

正解AIO 的收益来自请求的重叠。如果同一时刻只有一两个请求,AIO 的提交、对齐、事件通知等额外开销反而让性能下降;小文件、缓存命中率高的访问用同步 IO 就够了。它更像高速公路,车太少时走普通公路更省油。

为何常见:性能测试里 AIO 的高吞吐数据让人产生“万能药”错觉,忽略了测试场景中的高并发前提。

误区我把 read(fd, buf, len) 换成 io_submit,传入同样的 fd 和 buf,怎么老是报错?

正解libaio 对参数有严格约束:fd 必须用 O_DIRECT 打开(否则在某些文件系统上退化为同步),buf 必须按 512 字节对齐,len 和 offset 也必须是 512 的整数倍。它像传送带上的餐盘,每个盘都必须卡进标准凹槽,否则整条线停摆。

为何常见:接口形式上与 read() 相似,文档却晦涩难懂,新手极易忽略对齐和 O_DIRECT 这些前置条件。

误区我把 io_setup 的 nr_events 设为 100 万,想着队列越深并发越高,肯定没错。

正解每个 nr_events 槽位都要消耗内核内存,盲目调大会导致内存浪费甚至创建失败;队列过深还会让请求排队时间变长。需要根据磁盘的实际并发能力(NVMe 可达数百)选择合适深度,找到吞吐和延迟的平衡点。

为何常见:“队列深度越大吞吐越高”的直觉在大并发下不成立,磁盘硬件本身有并发上限,超过后只会增加等待。

误区io_uring 无非是 libaio 换了个马甲,接口改一改,原理都一样。

正解io_uring 是全新的异步 IO 框架:用共享内存环形队列替代每次系统调用提交,支持缓冲 IO、IOPOLL、多路通知等 libaio 没有的能力。它不只是“新版本”,而是把点餐方式从“按铃呼叫”升级成了“旋转传送带”,交互模型完全不同。

为何常见:两者都叫“异步 IO 框架”,功能又高度重叠,粗看确实容易当成同一个东西的迭代。

面试题

Q1. Linux AIO 与同步 IO 的最大区别是什么?

单选题 · easy

  • A.AIO 提交后立即返回并通知← 正确答案
  • B.AIO 必须等待请求完成再返回
  • C.AIO 完全不需要操作系统的参与
  • D.AIO 只能用在一个 CPU 核上运行

答案:A。AIO 的核心特征是“提交即返回、完成即通知”,就像呼叫器点完餐可以走开。B 是同步 IO 的描述;C 错误,AIO 恰恰依赖内核完成磁盘操作;D 是无关限制,AIO 可跨核调度。

Q2. 下列哪个是 libaio 中提交 IO 请求的接口?

单选题 · easy

  • A.io_submit 提交一组 I/O 请求← 正确答案
  • B.io_getevents 完成事件收割
  • C.io_setup 创建异步上下文
  • D.io_destroy 销毁异步上下文

答案:A。io_submit 负责把 iocb 数组交给内核,是“下单”动作。io_getevents 是等菜,io_setup 是定台号,io_destroy 是结账离开,各有分工。

Q3. io_getevents 函数的主要职责是什么?

单选题 · easy

  • A.等待并收割已经完成的请求← 正确答案
  • B.向内核提交新的 IO 请求
  • C.创建内核事件循环线程
  • D.将用户缓冲区锁进内存

答案:A。io_getevents 是“取餐口”,阻塞等待并取出完成事件。提交请求是 io_submit 的活;它既不建线程也不锁内存,那些是内核的其他机制。

Q4. eventfd 在 AIO 事件通知中所起的作用是?

单选题 · mid

  • A.让完成事件变成可读的 fd← 正确答案
  • B.直接存储磁盘读取的数据
  • C.替代 DMA 完成数据传输
  • D.为进程分配新的地址空间

答案:A。eventfd 是内核提供的“铃铛”,完成事件写入后变成可读状态,应用能用 epoll 监听它。它不存数据、不做 DMA、不管内存分配,只负责把“菜好了”的通知挂到总台上。

Q5. 为什么 AIO 常用 O_DIRECT 打开文件?

单选题 · mid

  • A.绕过页缓存避免无谓的数据拷贝← 正确答案
  • B.让数据可以缓存在系统页缓存中
  • C.让请求变得更快完成且更省内存
  • D.让进程拥有独立的内核地址空间

答案:A。O_DIRECT 让 DMA 直接读写用户缓冲区,绕过页缓存,避免一次不必要的内存拷贝。B 正相反;C 不是 O_DIRECT 的本质目的;D 与文件打开标志无关。

Q6. 下列哪个场景最适合使用 AIO 加速?

单选题 · mid

  • A.数据库大量随机读写的场景← 正确答案
  • B.仅读取几十字节小文件场景
  • C.频繁调用 sleep 等待场景
  • D.纯 CPU 密集型计算场景

答案:A。数据库随机读写请求量大且可重叠,AIO 能把多个请求同时压给磁盘。几十字节小请求的提交开销占比太高,收益有限;AIO 不解决 sleep 等待和 CPU 计算问题。

Q7. 以下哪些属于 AIO 的事件通知方式?

多选题 · mid

  • A.忙轮询 io_getevents 非阻塞检查← 正确答案
  • B.eventfd 配合 epoll 进行唤醒← 正确答案
  • C.使用信号来通知完成事件← 正确答案
  • D.修改文件大小来通知完成

答案:ABC。忙轮询、eventfd、信号是三种典型通知方式。D 是干扰项,没有任何内核机制会通过改文件大小来通知 AIO 完成,那只会破坏文件元数据。

Q8. io_uring 相比传统 libaio 的关键改进是什么?

单选题 · hard

  • A.共享环形队列减少系统调用← 正确答案
  • B.每次提交都多一次系统调用
  • C.全部请求都在内核线程执行
  • D.只支持 DIO 不支持缓冲 IO

答案:A。io_uring 通过共享内存中的 SQ/CQ 环形队列,让应用批量填请求、内核批量消费,系统调用次数从每个请求一次降为每批一次。B 说反了;C、D 均与 io_uring 特性不符,它恰恰支持缓冲 IO。

Q9. 使用 AIO 时缓冲区需要按什么对齐?

单选题 · hard

  • A.512 字节或其整数倍对齐← 正确答案
  • B.1 字节对齐即可满足要求
  • C.8 位对齐即可满足要求
  • D.无需对齐只要分配即可

答案:A。DMA 硬件要求缓冲区地址、偏移和长度都按扇区大小(通常 512B)对齐,否则返回 EINVAL。1 字节和 8 位对齐都太宽松,无法满足 DMA 传输要求。

Q10. 以下关于 Linux AIO 的说法正确的是?

多选题 · extreme

  • A.提交后内核会异步执行磁盘 IO← 正确答案
  • B.io_uring 可以降低系统调用开销← 正确答案
  • C.AIO 在任何场景都比同步快
  • D.事件通知只有信号这一种方式

答案:AB。A 描述了 AIO 的基本行为,B 是 io_uring 的核心优势,两者正确。C 过于绝对,小请求和低并发下 AIO 额外开销反而拖慢性能;D 错误,事件通知还有 eventfd、忙轮询、阻塞等待等多种方式。