为什么个人站长也该关心 io_uring
绝大多数 Linux 运维教程讲 I/O 多路复用,讲到 epoll 就收尾了。epoll 确实是过去十几年高性能网络服务的基石,Nginx、Redis、Node.js 这一类事件驱动程序都建立它之上。但从 Linux 5.1 开始,内核正式引入了一套全新的异步 I/O 接口 —— io_uring,它解决的问题恰恰是 epoll 一直没能优雅解决的那部分:真正的异步磁盘 I/O。
这里要先厘清一个容易被混淆的概念。epoll 解决的是「网络 socket 何时可读可写」的就绪通知问题,它本身并不发起 I/O。真正读写数据时,程序仍然要调用 read/write,而普通的文件读写(尤其是磁盘文件)在 Linux 上是同步阻塞的 —— 你调用 read,线程就卡在那里等磁盘返回。对于一个既要处理网络请求、又要读写本地文件(比如日志写入、静态文件读取、SQLite 落盘)的个人站服务进程来说,这就是性能瓶颈的根源:磁盘慢的时候,整个事件循环被拖住。
io_uring 的意义在于:它提供了一套提交 / 完成队列(SQ / CQ)机制,让应用程序把「我要读这个文件」的请求塞进提交队列,然后立刻返回去做别的事;内核在自己的线程里真正执行磁盘 I/O,完成后把结果放进完成队列。应用程序只需定期检查完成队列拿结果。整个过程可以是零系统调用的(配合 SQPOLL 模式),也可以是极少量系统调用的,大幅降低了用户态与内核态之间的切换开销。
io_uring 与 epoll、AIO 的本质区别
为了讲清楚为什么 io_uring 值得关注,有必要把三代 I/O 模型摆在一起对比。
第一代:阻塞 I/O + 多线程 / 多进程。最朴素的方案,每个连接一个线程。Apache prefork 模式就是这个思路。问题很明显:线程数一多,内存和上下文切换开销暴涨,C10K 问题就是这么来的。
第二代:epoll + 非阻塞 I/O + 事件循环。单线程(或少量线程)就能管理成千上万个连接。但注意,epoll 只对「可轮询的就绪状态」友好。网络 socket 有就绪语义,磁盘文件没有 —— 你无法「等磁盘文件可读」,它永远返回可读,但 read 时又要等真实数据从盘上取回来。这就是 Linux 经典的异步 I/O 困境:AIO(libaio)虽然提供了异步接口,但它是为数据库裸设备场景设计的,对普通文件支持很差,还要求 O_DIRECT,用起来坑很多,所以绝大多数应用宁愿用线程池去模拟异步。
第三代:io_uring。它对文件和网络统一建模,不要求 O_DIRECT,几乎支持所有系统调用的异步版本,而且通过共享内存环形缓冲区把系统调用开销降到接近零。它本质上是把「内核当作一个可以批量提交任务的执行器」。
| 模型 | 网络就绪 | 磁盘异步 | 系统调用开销 | 适用场景 |
|---|---|---|---|---|
| 阻塞 + 多线程 | 差 | 假异步 | 极高 | 低并发、开发简单 |
| epoll | 优秀 | 不支持 | 低 | 高并发网络 |
| libaio | 不支持 | 需 O_DIRECT | 中 | 数据库裸设备 |
| io_uring | 优秀 | 原生支持 | 近零 | 高吞吐文件 + 网络混合 |
SQ 与 CQ:理解 io_uring 的核心结构
io_uring 的名字就来自两个队列:Submission Queue(提交队列)和 Completion Queue(完成队列)。这两个队列都是内核与用户态共享内存的环形缓冲区(ring buffer),这意味着用户态程序把请求写进 SQ 时,不需要系统调用 —— 它就是往一块共享内存写数据而已。
每个请求叫一个 SQE(Submission Queue Entry),每个结果叫一个 CQE(Completion Queue Entry)。SQE 里描述了「要做什么操作」(比如 IORING_OP_READ)、「操作哪个文件描述符」、「缓冲区地址和长度」、「偏移量」等信息。内核取走 SQE 执行完,把结果(成功读取的字节数或负的错误码)写进 CQE。
io_uring 环形缓冲区结构示意:
用户态 内核态
┌──────────┐ ┌──────────┐
│ SQ ring │───────────>│ 取 SQE │
│ (共享内存)│ 提交请求 │ 执行 I/O │
└──────────┘ └──────────┘
┌──────────┐ ┌──────────┐
│ CQ ring │<───────────│ 写 CQE │
│ (共享内存)│ 返回结果 │ 完成通知 │
└──────────┘ └──────────┘
用户态提交 N 个请求 = 写 N 次共享内存,0 次系统调用
仅当需要内核「注意到」时,才调用一次 io_uring_enter
关键在于「批量」二字。传统模式下,读取 100 个文件要发起 100 次 read 系统调用,每次都要用户态 / 内核态来回切换;io_uring 模式下,你可以一次性往 SQ 里塞 100 个 SQE,然后调用一次 io_uring_enter 告诉内核「有活干了」,内核批量处理完,你再一次从 CQ 里取走 100 个结果。系统调用数量从 100 降到 1,这是数量级的差异。
三种运行模式与各自的取舍
io_uring 有两个关键的行为开关,组合出不同的使用姿势。
中断驱动 vs 轮询驱动。默认是中断驱动:内核完成一个请求后产生中断通知你。但在极高 IOPS 场景下,中断本身就成了负担(每秒几百万次中断会打满 CPU)。于是有了 IORING_SETUP_IOPOLL 模式:内核用忙轮询代替中断去检查设备状态,延迟更低,但会吃掉一个核的 CPU,且只对支持轮询的块设备有效。
是否需要内核线程。默认情况下,每次提交还是得调用 io_uring_enter,内核才知道有新 SQE。IORING_SETUP_SQPOLL 更进一步:内核起一个专用内核线程(sqpoll thread)常驻轮询 SQ 环形缓冲区,你自己往里写 SQE,内核线程自动发现并执行,完全不需要系统调用。代价是那个内核线程会一直烧 CPU,所以适合长时间高速率的场景,不适合低频操作。
# 查看内核是否支持 io_uring(5.1+)
uname -r
# 查看 io_uring 相关统计(需要较新内核)
cat /proc/sys/kernel/io_uring_disabled
# 0 = 允许所有进程使用
# 1 = 只允许有 CAP_SYS_ADMIN 的进程
# 2 = 完全禁用
# 简单的 liburing 编译检测
pkg-config --exists liburing && echo "liburing 已安装"
pkg-config --modversion liburing
需要特别提醒一点:很多云厂商和内核发行版出于安全考虑,会收紧 io_uring 的可用性。kernel.io_uring_disabled 这个 sysctl 在较新的内核里就是干这个的,某些加固过的系统甚至默认设为 2(完全禁用),因为 io_uring 早期爆出过数个提权漏洞(比如 CVE-2023-0461、CVE-2024-0582 等)。所以在生产服务器上启用 io_uring 之前,务必确认内核版本已打补丁,不要为了性能牺牲安全。
动手:一个最小的 io_uring 读取程序
真正手写 io_uring 的原始接口很繁琐(要自己 mmap 环形缓冲区、管理 sqe 索引),生产上一般直接用 liburing 这个封装库。下面是一个读取文件并打印内容的极简示例,帮助理解调用流程。
// gcc -o uring_read uring_read.c -luring
#include <liburing.h>
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
int main(void) {
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
char buf[4096] = {0};
int fd, ret;
// 初始化 ring,队列深度 8
ret = io_uring_queue_init(8, &ring, 0);
if (ret < 0) {
fprintf(stderr, "queue_init failed: %s", strerror(-ret));
return 1;
}
fd = open("/etc/hostname", O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
// 从 SQ 中取一个空位
sqe = io_uring_get_sqe(&ring);
// 填充请求:读 fd 到 buf
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
// 提交(这一次会触发 io_uring_enter)
io_uring_submit(&ring);
// 等待完成
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) { fprintf(stderr, "wait_cqe failed"); return 1; }
if (cqe->res < 0) {
fprintf(stderr, "read error: %s", strerror(-cqe->res));
} else {
printf("read %d bytes: %s", cqe->res, buf);
}
// 必须归还 CQE,否则 CQ 会满
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
return 0;
}
这个程序虽然简单,但已经把 io_uring 的完整生命周期走了一遍:初始化 ring → 取 SQE → 填充请求 → 提交 → 等待 CQE → 归还 CQE → 销毁 ring。真正的性能收益在小程序里看不出来,但当你要同时读取几千个文件、或者一个服务既要处理网络又频繁写日志时,把所有请求批量塞进 SQ 再一次性提交,优势才会显现。
真实场景:日志写入为什么值得改用 io_uring
假设你维护的一个个人站跑着一个自定义的 PHP / Python 应用,每天产生几 GB 的访问日志,每次请求都要同步写一条日志。传统做法是直接 fwrite,落到磁盘那一刻线程就阻塞了 —— 磁盘是机械盘或者云盘在高峰期 IOPS 抖动时,单次写可能卡几十毫秒。请求一多,日志写就把整个服务的响应时间拖垮,你在监控上看到的现象是「CPU 和内存都不高,但 P99 延迟莫名飙升」,排查半天查不到原因。
改成异步写日志后,应用只管把日志内容丢进提交队列立刻返回,真正落盘交给内核异步完成。即使磁盘偶尔卡顿,也不会阻塞请求处理线程。这就是为什么很多高性能日志库(包括一些新版的异步运行时)开始把 io_uring 作为底层 I/O 引擎。
不过要客观地说:io_uring 不是银弹。对于个人站这种流量规模(日 PV 几千到几万),传统方案配上一个好的文件系统挂载参数、合理的日志切割策略,往往已经足够了。io_uring 的价值在你真正遇到「磁盘 I/O 拖慢服务」的瓶颈时才会体现。把它当成工具箱里的一件进阶武器,而不是见谁都要用的万能钥匙。
在内核参数上配合 io_uring 调整
io_uring 的使用也会受一些内核资源限制影响,尤其是当进程数和队列数都很高的时候。相关的可调参数主要来自内存锁定和文件描述符两块。
# io_uring 的环形缓冲区需要锁定内存(mlock),
# 检查进程可锁定的内存上限
ulimit -l
# 如果程序报 EPERM/ENOMEM,可能需要提高
# /etc/security/limits.conf 里的 memlock
# 例如:
# * soft memlock unlimited
# * hard memlock unlimited
# 文件描述符上限(io_uring 内部也用 fd)
ulimit -n
# 查看 io_uring 内核工作线程情况
ps -eLf | grep iou-
另外,如果开启了 SQPOLL 模式,内核会额外起一个名为 iou-sqp-* 的线程,可以用上面的命令观察到。它常驻轮询会占用 CPU,务必结合自己的负载特征决定是否开启 —— 低频场景下开 SQPOLL 只会白白浪费一个核。
安全与兼容性的现实考量
最后必须认真谈谈安全。io_uring 自 5.1 引入以来,由于它把大量内核操作暴露给了用户态、且涉及复杂的共享内存交互,历史上出现过多个高危漏洞,其中不少是本地提权(LPE)。这也是为什么不少发行版和云厂商一度默认禁用它。作为服务器运维者,正确的态度是:
其一,保持内核更新,及时跟进安全公告,不要在一个长期不更新的老内核上折腾 io_uring。其二,如果业务用不到,干脆通过 kernel.io_uring_disabled=2 关掉,缩小攻击面 —— 对于纯粹的 Nginx + PHP 站点,几乎用不上它。其三,只有在确实需要、且确认内核已修复相关 CVE 的前提下,才在关键服务里引入。
兼容性方面,io_uring 对内核版本有硬性要求(核心功能 5.1+,大量实用特性要到 5.6+ 甚至 6.x)。很多还在用 Debian 10 / CentOS 7 这种老系统(内核 4.x)的站长,根本无法使用。这一点在选型时要有清醒认识。
常见问题
问:io_uring 会取代 epoll 吗?短期内不会完全取代。epoll 对于纯网络的高并发场景依然非常成熟高效,而且生态完善。io_uring 的优势主要在「文件 I/O 与网络混合」以及「极致的系统调用削减」上。很多现代运行时(如新版 Nginx、部分数据库)采用「epoll 处理网络 + io_uring 处理磁盘」的混合策略,而不是全盘替换。
问:我现在就该把服务改成 io_uring 吗?不建议盲目改造。先做性能剖析,用 strace -c 看系统调用分布、用 iostat -x 1 看磁盘 await 是否偏高。只有当确认瓶颈确实在磁盘异步能力上,改造才划算。个人站大多数时候瓶颈在数据库查询和 PHP-FPM 配置上,轮不到 I/O 引擎。
问:io_uring 影响普通网站的 SEO 和访问速度吗?间接地。服务器处理请求更快、更稳,用户感知的 TTFB 会更低,这对 Core Web Vitals 里的 LCP 是有利的。但它属于底层优化,优先级应排在缓存、数据库索引、CDN 这些立竿见影的手段之后。
总结
io_uring 是 Linux I/O 模型的一次重要演进。它用共享内存的 SQ / CQ 环形缓冲区,把「提交请求」这个动作的成本降到接近零,并且第一次让普通文件的异步 I/O 变得优雅可用。对于个人站长来说,理解它的价值不在于马上去改代码,而在于当服务器出现「CPU 内存都不高但延迟抖动」这类疑难问题时,你能多一个排查和优化的方向。同时也要清醒地看到它的门槛:内核版本要求、安全历史包袱、以及绝大多数个人站其实用不上的现实。
把它收进工具箱,判断清楚什么时候该掏出来,这就够了。