2019年5月,Linux 5.1内核合并了一个名为io_uring的新子系统。三年后,这项技术已经成为高性能数据库、存储引擎和网络服务的标配。从Redis到PostgreSQL,从Nginx到Envoy,越来越多的系统开始迁移到这个新接口。
但io_uring究竟解决了什么问题?为什么它能让某些场景的性能提升数倍?这篇文章将从传统I/O模型的困境出发,深入解析io_uring的设计哲学、核心机制与工程权衡。
Unix I/O的历史包袱
Unix系统的I/O模型从诞生之日起就带着一个根本性的设计选择:同步阻塞。当你调用read()时,进程会一直等待,直到数据从磁盘或网络到达用户空间的缓冲区。这种模型简单直观,但在高并发场景下暴露出严重问题。
每秒百万级IOPS的NVMe SSD已经普及,但传统系统调用仍然是串行的。一次read()系统调用需要:用户态到内核态的上下文切换、内核态到用户态的数据拷贝、内核态返回用户态的另一次切换。在现代CPU上,一次系统调用的开销大约在几百纳秒到几微秒之间,加上Spectre/Meltdown缓解措施带来的额外开销。当设备延迟本身只有几十微秒时,系统调用开销就成了无法忽视的瓶颈。
Linux并非没有尝试过解决这个问题。早在2003年,Linux AIO(异步I/O)子系统就被引入内核。但它的设计存在根本性缺陷:只能处理以O_DIRECT方式打开的文件,对普通缓冲I/O完全无效。这意味着它对大多数应用场景毫无意义——谁会为了异步I/O而放弃操作系统的页缓存?
更糟糕的是,Linux AIO的API设计本身就很别扭。你需要先调用io_setup()创建上下文,然后用io_submit()提交请求,最后用io_getevents()获取结果。每个步骤都是独立的系统调用,批处理能力有限。而且,当请求无法立即完成时,内核会创建工作线程来处理,这又引入了额外的调度开销。
网络I/O的处境略有不同。epoll作为Linux的事件通知机制,已经统治了网络编程近二十年。但epoll采用的是"就绪通知"模型:它告诉你哪个文件描述符可以读写了,但你仍然需要自己调用read()或write()来完成实际操作。这意味着每个就绪事件至少触发一次系统调用。
更关键的是,epoll和AIO是完全独立的子系统。如果你想同时处理网络和存储I/O,就需要维护两套完全不同的代码路径。这不是工程上的小问题——Redis、Nginx等高性能系统都曾为此付出过沉重的设计代价。
共享内存的革命性设计
io_uring的核心创新可以用一句话概括:用共享内存中的环形缓冲区取代系统调用作为内核与用户态的主要通信方式。
这不是简单的优化,而是范式的根本转变。在传统模型中,每次I/O操作都需要调用系统调用,让内核代为执行。而在io_uring中,应用程序只需要往共享内存中的提交队列(Submission Queue,SQ)写入请求描述符,内核会异步处理这些请求,并将结果写入完成队列(Completion Queue,CQ)。整个过程可能完全不需要系统调用。
┌─────────────────────────────────────────────────────────────────┐
│ 用户空间 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Submission Queue (SQ) │ │
│ │ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │ │
│ │ │ SQE │ SQE │ SQE │ SQE │ SQE │ SQE │ SQE │ SQE │ │ │
│ │ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ │ │
│ │ ↑ │ │ │
│ │ tail │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Completion Queue (CQ) │ │
│ │ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │ │
│ │ │ CQE │ CQE │ CQE │ CQE │ CQE │ CQE │ CQE │ CQE │ │ │
│ │ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ │ │
│ │ ↑ │ │ │
│ │ head │ │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↕ 共享内存
┌─────────────────────────────────────────────────────────────────┐
│ 内核空间 │
│ io_uring 子系统处理 SQ 中的请求 │
│ 将结果写入 CQ │
└─────────────────────────────────────────────────────────────────┘
图片来源: 作者根据io_uring架构原理绘制
SQE与CQE的结构
提交队列条目(SQE)是io_uring中描述I/O请求的基本单元。它的结构设计需要同时满足两个目标:足够通用以支持各种类型的操作,又足够紧凑以优化缓存效率。
struct io_uring_sqe {
__u8 opcode; /* 操作码:READ、WRITE、ACCEPT等 */
__u8 flags; /* IOSQE_*标志位 */
__u16 ioprio; /* I/O优先级 */
__s32 fd; /* 文件描述符 */
__u64 off; /* 文件偏移量 */
__u64 addr; /* 缓冲区地址或iovec数组指针 */
__u32 len; /* 缓冲区长度或iovec数量 */
__u64 user_data; /* 用户自定义数据,完成时原样返回 */
union {
__u32 rw_flags; /* 读/写标志 */
__u32 fsync_flags;
__u32 poll_events;
__u32 msg_flags;
/* ... 其他操作特定标志 */
};
__u64 __pad2[1];
};
完成队列条目(CQE)则简单得多,因为完成通知只需要返回结果:
struct io_uring_cqe {
__u64 user_data; /* 来自SQE的user_data */
__s32 res; /* 操作结果:成功返回字节数,失败返回-errno */
__u32 flags; /* 完成标志 */
};
这种设计的一个关键细节是user_data字段。由于I/O请求可能以任意顺序完成,应用程序需要一个机制来关联提交和完成。user_data正是为此而生——你可以在提交时存入任何值,内核会在完成时原样返回。常见的做法是存入请求ID或指向请求上下文的指针。
内存屏障:无锁队列的基石
共享环形缓冲区的核心挑战是同步:如何确保内核和用户态对队列状态的修改能够正确地互相看到,而又不引入锁的开销?
io_uring采用了经典的单生产者-单消费者无锁队列模式。对于SQ,应用程序是生产者(写入tail),内核是消费者(读取head)。对于CQ,角色相反。在这种模式下,只需要内存屏障就能保证正确性。
具体来说,当应用程序向SQ添加请求时:
// 获取当前tail
unsigned tail = *sqring->tail;
unsigned index = tail & (*sqring->ring_mask);
// 写入SQE
sqring->sqes[index] = my_request;
// 更新tail,使用release语义确保SQE写入对内核可见
atomic_store_explicit(sqring->tail, tail + 1, memory_order_release);
当内核读取请求时,使用memory_order_acquire语义来确保看到应用程序写入的SQE内容。这种acquire-release配对是无锁编程的标准模式,能够在不使用原子指令的情况下实现正确的同步。
从代码看io_uring的工作流程
理解io_uring的最佳方式是通过一个实际的例子。以下代码展示了如何使用io_uring读取文件:
#include <linux/io_uring.h>
#include <sys/mman.h>
#include <unistd.h>
// 初始化io_uring
int setup_io_uring(struct io_uring *ring, unsigned entries) {
struct io_uring_params params = {0};
// 第一步:调用io_uring_setup创建实例
ring->ring_fd = syscall(__NR_io_uring_setup, entries, ¶ms);
// 第二步:mmap共享内存区域
void *sq_ptr = mmap(NULL, params.sq_off.array + params.sq_entries * sizeof(unsigned),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring->ring_fd, IORING_OFF_SQ_RING);
void *cq_ptr = mmap(NULL, params.cq_off.cqes + params.cq_entries * sizeof(struct io_uring_cqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring->ring_fd, IORING_OFF_CQ_RING);
void *sqes = mmap(NULL, params.sq_entries * sizeof(struct io_uring_sqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring->ring_fd, IORING_OFF_SQES);
// 保存指针供后续使用
ring->sq.kring = sq_ptr;
ring->sq.sqes = sqes;
ring->cq.kring = cq_ptr;
ring->cq.cqes = cq_ptr + params.cq_off.cqes;
return 0;
}
// 提交读请求
void submit_read(struct io_uring *ring, int fd, void *buf, size_t len, off_t offset) {
struct io_uring_sqe *sqe = get_sqe(ring); // 获取空闲SQE
sqe->opcode = IORING_OP_READ;
sqe->fd = fd;
sqe->addr = (unsigned long)buf;
sqe->len = len;
sqe->off = offset;
sqe->user_data = (unsigned long)buf; // 用于关联完成
submit_sqe(ring); // 更新tail
}
// 等待并处理完成
int wait_and_process(struct io_uring *ring) {
// 调用io_uring_enter等待完成
syscall(__NR_io_uring_enter, ring->ring_fd,
0, 1, // 提交0个,等待1个完成
IORING_ENTER_GETEVENTS, NULL, 0);
struct io_uring_cqe *cqe = get_cqe(ring);
int result = cqe->res;
consume_cqe(ring); // 更新head
return result;
}
这个例子展示了io_uring的基本工作流程:
- 初始化:调用
io_uring_setup()创建实例,然后mmap()共享内存 - 提交请求:获取空闲SQE,填充请求参数,更新SQ的tail
- 通知内核:调用
io_uring_enter()让内核开始处理 - 等待完成:从CQ获取CQE,处理结果,更新CQ的head
当然,实际生产代码会使用liburing库提供的更高级接口,避免直接处理这些底层细节。
批处理:性能倍增的关键
io_uring最显著的性能优势来自批处理能力。在传统模型中,每个I/O操作都需要一次系统调用。即使使用了epoll的多事件返回,实际的read()或write()仍然是逐个调用的。
io_uring改变了这一切。你可以在一次io_uring_enter()调用中提交任意数量的请求:
// 批量提交100个读请求
for (int i = 0; i < 100; i++) {
struct io_uring_sqe *sqe = get_sqe(ring);
sqe->opcode = IORING_OP_READ;
sqe->fd = fds[i];
// ... 填充其他字段
}
// 一次性提交所有请求
syscall(__NR_io_uring_enter, ring->ring_fd, 100, 0, 0, NULL, 0);
根据TU Darmstadt和VLDB的研究论文,批处理16个操作可以将每个操作的CPU开销降低5-6倍。这不是魔法——而是将系统调用和上下文切换的固定开销分摊到了多个操作上。
┌──────────────────────────────────────────────────────────────┐
│ 传统I/O模型 │
│ syscall → 内核 → 返回 → syscall → 内核 → 返回 → ... │
│ 每次操作都有完整开销 │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ io_uring批处理 │
│ 写入SQE × N → 一次syscall → 内核批量处理 → 批量完成通知 │
│ 开销分摊到N个操作 │
└──────────────────────────────────────────────────────────────┘
图片来源: 作者根据io_uring批处理原理绘制
SQPoll:消灭系统调用的终极方案
如果批处理是"减少"系统调用,那么SQPoll就是"消灭"系统调用。当你在创建io_uring实例时设置IORING_SETUP_SQPOLL标志,内核会创建一个专门的内核线程来轮询提交队列。
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 空闲2毫秒后休眠
int fd = syscall(__NR_io_uring_setup, 256, ¶ms);
在这种模式下,应用程序只需要往SQ写入请求并更新tail,内核线程会自动发现并处理这些请求。完成结果同样会异步出现在CQ中。整个流程完全不需要系统调用——除非你需要等待特定数量的完成。
SQPoll的代价是CPU资源。即使没有工作,内核线程也会消耗CPU周期。因此,它只适合I/O密集型、长时间运行的工作负载。对于间歇性的I/O模式,传统的io_uring_enter()方式可能更高效。
值得注意的是,SQPoll还有另一个重要用途:绕过某些安全监控机制。由于不经过系统调用入口,传统的seccomp过滤器无法拦截io_uring提交的操作。这既是特性也是潜在风险,我们稍后会详细讨论。
注册资源:减少内核开销
io_uring允许应用程序预先"注册"常用的资源,避免每次操作都进行查找和验证。主要支持三类资源的注册:
文件描述符注册
如果你会频繁操作同一组文件描述符,可以预先注册:
int fds[100] = {fd1, fd2, fd3, ...};
syscall(__NR_io_uring_register, ring_fd, IORING_REGISTER_FILES, fds, 100);
注册后,在SQE中使用索引而非实际的文件描述符:
sqe->fd = 0; // 使用索引0,对应fds[0]
sqe->flags |= IOSQE_FIXED_FILE;
这避免了内核在每次操作时从进程的文件描述符表中查找和验证fd的开销。
缓冲区注册
对于零拷贝操作,需要预先注册缓冲区:
struct iovec iovs[4] = {
{.iov_base = buf0, .iov_len = 4096},
{.iov_base = buf1, .iov_len = 4096},
// ...
};
syscall(__NR_io_uring_register, ring_fd, IORING_REGISTER_BUFFERS, iovs, 4);
注册缓冲区会锁定这些内存页(防止被换出),允许内核直接在这些缓冲区上进行DMA操作。这是实现真正零拷贝的基础。
零拷贝网络:突破内存带宽瓶颈
2024年,io_uring引入了零拷贝网络接收(Zero-Copy Receive)功能,这是网络I/O性能的一次重大突破。传统网络接收流程需要将数据从内核网络栈拷贝到用户空间缓冲区,在高速网络(100Gbps+)环境下,这个拷贝会成为严重的瓶颈。
零拷贝接收的工作原理:
// 注册提供缓冲区环
struct io_uring_buf_reg reg = {
.ring_addr = (unsigned long)buf_ring,
.ring_entries = 256,
.bgid = 1
};
io_uring_register(ring, IORING_REGISTER_PBUF_RING, ®, 0);
// 发起零拷贝接收
sqe->opcode = IORING_OP_RECV_ZC;
sqe->fd = socket_fd;
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = 1;
当数据到达时,内核直接将其放入预注册的缓冲区中,CQE会指示使用了哪个缓冲区。应用程序处理完数据后,将缓冲区归还给缓冲区环即可。整个过程避免了内核到用户空间的数据拷贝。
根据Linux内核开发者David Wei在2023年的演讲,零拷贝网络接收可以将内存带宽消耗降低50%以上,对于内存带宽受限的系统意义重大。
链接操作:构建复杂的I/O流水线
在实际应用中,I/O操作往往存在依赖关系。例如,你可能需要先打开文件,然后读取,最后关闭。io_uring的链接操作功能允许你将这些操作作为一个原子序列提交:
// 操作1:打开文件
sqe1->opcode = IORING_OP_OPENAT;
sqe1->flags = IOSQE_IO_LINK; // 链接到下一个操作
// 操作2:读取文件(使用操作1返回的fd)
sqe2->opcode = IORING_OP_READ;
sqe2->flags = IOSQE_IO_LINK;
sqe2->fd = -1; // 将使用前一个操作的结果
// 操作3:关闭文件
sqe3->opcode = IORING_OP_CLOSE;
sqe3->flags = 0; // 链条结束
链接操作的关键特性是短路执行:如果链条中的某个操作失败,后续操作会自动被取消。这避免了需要手动处理复杂错误场景的问题。
2024年的研究论文显示,正确使用链接操作可以显著简化高并发I/O密集型应用的代码复杂度,同时保持接近原生io_uring的性能水平。
io_uring vs epoll:何时选择哪个?
这是一个经常被讨论的问题。简短的回答是:对于新项目,io_uring通常是更好的选择;但对于特定场景,epoll仍有其价值。
| 维度 | epoll | io_uring |
|---|---|---|
| 通知模型 | 就绪通知 | 完成通知 |
| 系统调用次数 | 每次操作至少一次 | 批量操作可减少到零 |
| 学习曲线 | 较低 | 较高 |
| 内核版本要求 | 2.6+ | 5.1+ |
| 存储I/O支持 | 差(需配合其他API) | 完整原生支持 |
| 资源消耗 | 低 | SQPoll模式下可能较高 |
就绪通知vs完成通知是两种模型最根本的区别。epoll告诉你"可以读写了",但实际读写仍需你来做。io_uring告诉你"读完了"或"写完了",数据已经就绪。
这种差异在高延迟场景(如NVMe SSD)尤其明显。在epoll模型中,线程需要等待读取完成才能进行下一步;而在io_uring模型中,线程可以在读取完成前继续处理其他任务,实现真正的计算与I/O重叠。
但在低内存环境或需要兼容旧内核的场景,epoll仍然是务实的选择。而且,epoll的编程模型更简单直接,对于简单的网络服务器可能更易维护。
安全争议:性能与安全的权衡
io_uring的强大能力也带来了安全挑战。2024年,多个与io_uring相关的CVE漏洞被披露,包括:
- CVE-2024-0582:io_uring中的内存泄漏漏洞,可能导致权限提升
- CVE-2024-22017:setuid()无法正确清理io_uring资源,导致权限边界问题
- CVE-2024-35880:缓冲区环的引用计数问题
更根本的问题在于,io_uring绕过了传统的系统调用路径。seccomp-bpf过滤器工作在系统调用入口,无法拦截通过io_uring提交的操作。这给容器安全策略带来了挑战。
内核社区已经采取了一系列措施:
- io_uring特定的seccomp支持:新增
SECCOMP_USER_NOTIF_ADDFD等机制 - 更严格的权限检查:在操作执行时进行,而非提交时
- 可选禁用:允许系统管理员通过sysctl或内核参数禁用io_uring
对于需要严格安全隔离的环境(如多租户容器平台),建议:
# 通过sysctl禁用io_uring
sysctl -w kernel.io_uring_disabled=2
# 或通过seccomp配置禁止
实际应用案例
PostgreSQL
PostgreSQL从16版本开始支持io_uring作为实验性功能。根据2024年TU Darmstadt的研究,通过正确配置批处理和缓冲区注册,TPC-C基准测试性能提升了14%。关键优化包括:
- 使用批处理提交WAL(Write-Ahead Log)写入
- 利用注册缓冲区减少检查点开销
- 启用DEFER_TASKRUN模式避免中断干扰
Redis
Redis的io_uring支持仍在开发中。初步测试显示,在高吞吐写入场景下,io_uring可以比传统AOF(Append-Only File)提升约10-15%的性能。但由于Redis的I/O模式相对简单,收益不如数据库系统显著。
Envoy服务网格
2024年的一项研究测量了io_uring在Envoy服务网格中的性能影响。结果显示,在启用零拷贝和批处理的情况下,P99延迟降低了约20%,CPU利用率降低了约15%。这主要得益于减少了内核-用户态的数据拷贝和系统调用开销。
多线程编程模型
io_uring的多线程使用是一个需要仔细设计的领域。核心原则是:一个线程一个io_uring实例。
虽然技术上可以在多个线程间共享一个io_uring实例,但这需要使用锁来保护SQ和CQ的访问,会抵消大部分性能优势。推荐的模式是:
// 每个线程创建自己的io_uring实例
thread_local struct io_uring local_ring;
void thread_init() {
io_uring_queue_init(256, &local_ring, 0);
}
void thread_work() {
// 所有I/O操作使用local_ring
// 无需同步
}
对于需要协调多个I/O源的场景,可以考虑使用多个独立的io_uring实例,每个专注于特定类型的操作(如一个用于存储,一个用于网络),然后通过线程间通信来协调。
与其他平台的对比
io_uring并非第一个采用完成通知模型的接口。Windows的IOCP(I/O Completion Ports)早在1990年代就采用了类似的设计。但两者有重要区别:
IOCP vs io_uring:
- IOCP的完成端口是中心化的,线程从同一个端口获取完成事件
- io_uring的环形缓冲区是分散的,每个实例独立
- IOCP不支持批处理提交,每个操作仍需独立系统调用
- io_uring的SQPoll模式可以完全消除系统调用
BSD系统的kqueue采用的是与epoll类似的通知模型,但API设计更加一致。FreeBSD社区曾讨论过引入类似io_uring的机制,但截至2024年尚未实现。
有趣的是,Windows在2022年的21H2版本中引入了自己的"IoRing"API,设计与io_uring高度相似,这被广泛认为是对Linux创新的一种回应。
性能调优指南
基于前面的分析,以下是使用io_uring的最佳实践:
队列深度选择
队列深度应该根据工作负载特性选择。对于存储I/O,建议使用256-1024的深度;对于网络I/O,可能需要更大的深度以处理大量并发连接。过小的深度会限制并发度,过大的深度会浪费内存。
// 存储密集型
io_uring_queue_init(512, &ring, 0);
// 网络密集型
io_uring_queue_init(4096, &ring, 0);
批处理策略
不要盲目追求最大批处理量。批处理会引入延迟——你需要等待凑够一批才能提交。自适应批处理是更好的策略:
int batch_size = 0;
while (has_pending_requests()) {
add_to_batch(get_next_request());
batch_size++;
if (batch_size >= MAX_BATCH ||
time_since_first_batch() > LATENCY_THRESHOLD) {
submit_batch();
batch_size = 0;
}
}
选择正确的执行模式
- 默认模式:适合通用场景,简单可靠
- SQPoll:适合高吞吐、低延迟的I/O密集型负载
- IOPoll:适合低延迟存储设备,避免中断开销
未来展望
io_uring的发展远未结束。内核社区正在推进多个激动人心的方向:
eBPF集成:允许在io_uring操作链中嵌入eBPF程序,实现更灵活的数据处理流水线。这将使"在内核中处理完所有事情"成为可能。
更广泛的操作支持:每个内核版本都在增加新的操作类型。从最初的读写操作,到现在支持open、stat、splice、futex等,io_uring正在演变成一个通用的异步系统调用接口。
更好的容器支持:改进与namespace、cgroup等容器技术的集成,使io_uring在云原生环境中更安全可用。
结语
io_uring代表了Linux I/O子系统的重大范式转变。它通过共享内存环形缓冲区、批处理、零拷贝等技术,突破了传统系统调用模型的性能瓶颈。对于高性能数据库、存储引擎、网络服务等I/O密集型应用,io_uring已经成为不可或缺的基础设施。
但它并非银弹。学习曲线陡峭、安全考量复杂、需要深度理解内核行为——这些都是使用io_uring的代价。正如任何强大的工具,正确使用需要专业知识与经验的积累。
从更宏观的视角看,io_uring的出现反映了一个趋势:硬件性能的飞速提升正在倒逼软件接口的革新。当NVMe SSD能达到每秒数百万IOPS,当网络带宽突破100Gbps,传统的同步阻塞I/O模型已经无法适应。io_uring正是这种变革的产物,它重新定义了应用与内核协作的方式。
对于系统开发者而言,理解io_uring不仅是掌握一个新API,更是理解现代计算机系统I/O栈的演进方向。在未来的高性能系统设计中,io_uring相关的思维模式——异步、批处理、零拷贝——将成为必备的知识基础。
参考资料
- Jens Axboe. “Efficient IO with io_uring.” kernel.dk, 2019.
- LWN.net. “The rapid growth of io_uring.” January 2020.
- LWN.net. “Ringing in a new asynchronous I/O API.” January 2019.
- TU Darmstadt. “High-Performance DBMSs with io_uring: When and How to Use It.” VLDB 2025.
- Linux Manual Pages. “io_uring(7).” man7.org.
- Unixism. “Lord of the io_uring” tutorial series.
- David Wei. “Zero Copy Receive using io_uring.” NetDev 0x17.
- Paul Moore (Microsoft). “io_uring: So Fast. It’s Scary.” Kernel Recipes 2019.
- ScyllaDB. “How io_uring and eBPF Will Revolutionize Programming in Linux.” 2020.
- Windows Internals. “IoRing vs. io_uring: a comparison.” 2021.