引言
Linux 5.1 引入了一个注定要改变高性能 I/O 编程范式的新系统调用 —— io_uring(曾名 aioring)。它由 Jens Axboe(Linux 块设备层维护者)主导设计,目标是解决 Linux 异步 I/O 长期存在的痛点:每次 I/O 都需要至少两次系统调用(submit + complete)、数据结构碎片化、缓冲区固定成本高昂。
io_uring 的出现不仅仅是一个新 API,它代表了一种全新的用户态与内核通信模型:通过两个共享环形队列(Submission Queue + Completion Queue),将系统调用开销逼近零。在特定场景下,io_uring 的 I/O 吞吐量甚至能超越 DPDK 等内核旁路方案,同时保持内核协议栈的完整生态。
本文将从设计哲学出发,深入剖析 io_uring 的内核实现架构、核心 API 模式、高级特性及生产环境中的实战经验,最后构建一个基于 io_uring 的高性能 TCP Echo Server 作为综合案例。
一、设计哲学:为什么需要 io_uring
1.1 Linux 原生异步 I/O 的困境
在 io_uring 之前,Linux 提供了posix AIO(即io_submit / io_getevents),但它存在大量设计缺陷:
- 仅支持 O_DIRECT:必须使用直接 I/O,无法利用页缓存,且要求内存/偏移对齐
- 不支持 sockets:无法用于网络 I/O,实用性大打折扣
- 阻塞行为不可控:某些操作(如
open、stat)仍会阻塞 - 性能平庸:单一上下文的锁竞争限制了扩展性
1.2 epoll 的本质限制
epoll 是事件通知机制,不是异步 I/O 机制。使用 epoll 的典型模式是"通知可读 → 同步 read",read 本身仍是阻塞调用。当面对百万级连接时,这种"边缘触发 + 同步 I/O"的组合会导致:
- 频繁的用户态/内核态切换
- 无法批量处理就绪事件
- NUMA 跨节点访问 socket 结构体造成缓存抖动
1.3 io_uring 的核心设计
io_uring 的设计围绕三个核心原则:
- 共享数据结构(Shared Ring Buffers):SQ 和 CQ 在用户态和内核之间共享内存映射,无需系统调用即可访问
- 批量提交与收割(Batch Submit + Batch Reap):一次
io_uring_enter可提交数百个 SQE,收割数百个 CQE - 操作统一化(Unified Interface):磁盘 I/O、网络 I/O、文件操作、定时器、
accept/connect全部通过同一套接口
二、内核架构:双环形队列与通信模型
2.1 数据结构总览
io_uring 的核心数据结构由三部分组成:
┌─────────────────────────────────────────────────────────────────┐
│ io_uring 实例 │
├─────────────────────────────────────────────────────────────────┤
│ Submission Queue (SQ) │
│ ┌──────────────────┬────────────────────────────────────────┐ │
│ │ SQE array │ SQ ring buffer (head/tail 指针) │ │
│ │ (实际请求数据) │ (用户生产 / 内核消费) │ │
│ └──────────────────┴────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Completion Queue (CQ) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ CQE array (含 user_data, res, flags) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
SQ(Submission Queue)由三方共享信息:
- SQE Array:实际的 64 字节请求结构体数组,用户态写、内核态读
- sq->head:内核消费指针(内核态可写)
- sq->tail:用户态生产指针(用户态可写)
CQ(Completion Queue)是无锁的,因为仅内核生产、仅用户态消费:
- CQE Array:16 字节的完成事件结构体
- 内核写入流程:head = cq->head; cqe_array[head & mask] = event; head++; 内存屏障; 写 cq->head
2.2 mmap 映射机制
io_uring_setup()返回的 fd 被 mmap 多次,映射不同的内存区域:
sq->ring_ptr = mmap(..., off=IORING_OFF_SQ_RING); // 元数据
cq->ring_ptr = mmap(..., off=IORING_OFF_CQ_RING); // 元数据
sqes = mmap(..., off=IORING_OFF_SQES); // SQE 数组
关键:SQE 数组由内核在 setup 时分配,用户态通过 mmap 直接写入,避免了每次 submit 时复制数据到内核。
在支持 IORING_FEAT_SINGLE_MMAP(Linux 5.4+)的内核上,SQ ring 和 CQ ring 可以合并映射为一次 mmap 调用。
2.3 三种执行模式
| 模式 | 触发方式 | 系统调用次数 | 适用场景 |
|---|---|---|---|
| Interrupt-driven | 完整 io_uring_enter | 1 次/批 | 通用场景 |
| SQPOLL | 内核 sqthread 主动消费 SQ | 可降至 0 | 极低延迟要求 |
| IOPOLL | 内核线程轮询 IO 完成 | 可降至 0 | NVMe 低延迟 |
SQPOLL 模式详解:内核创建一个专用线程(IORING_SETUP_SQPOLL)持续轮询 SQ。用户态只需写入 SQE 并更新 tail,内核线程自动消费。代价是线程持续占用一个 CPU 核心(100% busy polling)。
IOPOLL 模式:专门优化块设备 I/O 延迟,内核轮询线程绕过 block 层异步完成机制,直接轮询 completion queue。适合 NVMe 等设备。
三、核心 API 深度解析
3.1 初始化与队列操作
#include
struct io_uring ring;
// 初始化:队列深度 1024,默认中断模式
int ret = io_uring_queue_init(1024, ˚, 0);
// SQPOLL 模式
struct io_uring_params params = { .sq_thread_idle = 2000 };
int ret = io_uring_queue_init_params(1024, ˚, ¶ms);
// 获取 SQE,填充请求,提交
struct io_uring_sqe *sqe = io_uring_get_sqe(˚);
io_uring_prep_readv(sqe, fd, &iov, 1, offset);
io_uring_sqe_set_data(sqe, my_context_ptr);
io_uring_submit(˚);
// 收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
int count = 0;
io_uring_for_each_cqe(˚, head, cqe) {
process_cqe(cqe);
count++;
}
io_uring_cq_advance(˚, count);
io_uring_queue_exit(˚);
关键点:
io_uring_get_sqe返回 NULL 表示 SQ 满,需要先提交已填充的 SQEio_uring_submit尽可能批量提交(内部会合并多次 get_sqe 调用)io_uring_cqe_seen仅更新 CQ ring 的 head,不释放 SQE 对应资源——若使用注册缓冲区则需在业务层管理生命周期
3.2 网络 accept 与 read/write 操作链
// 异步 accept(Linux 5.5+)
io_uring_prep_accept(sqe, listen_fd, &addr, &addrlen, flags);
// 多射 accept(Linux 5.19+):一个 SQE 产生多个完成事件
io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
// 异步 recv
io_uring_prep_recv(sqe, client_fd, buf, len, 0);
// 异步 send
io_uring_prep_send(sqe, client_fd, buf, len, 0);
// 使用缓冲区选择的内核预分配模式
// sqe->flags |= IOSQE_BUFFER_SELECT;
// sqe->buf_group = bgid;
// 完成后通过 (cqe->flags >> IORING_CQE_BUFFER_SHIFT) 获得 buf_id
3.3 链接操作(Linked SQEs)
io_uring 支持将多个 SQE 标记为链式执行(IOSQE_IO_LINK),前一操作成功后才执行后一操作:
struct io_uring_sqe *sqe1 = io_uring_get_sqe(˚);
io_uring_prep_read(sqe1, fd, buf, len, 0);
sqe1->flags |= IOSQE_IO_LINK; // 链式标记
struct io_uring_sqe *sqe2 = io_uring_get_sqe(˚);
io_uring_prep_write(sqe2, fd2, buf, len, 0);
sqe2->flags |= IOSQE_IO_LINK;
struct io_uring_sqe *sqe3 = io_uring_get_sqe(˚);
io_uring_prep_close(sqe3, fd3);
io_uring_submit(˚);
应用场景:Zero-Copy 代理中"接收→转发→关闭"三步保证顺序;避免使用dup2等同步调用。
HARDLINK(IOSQE_IO_HARDLINK):比 IO_LINK 更严格——如果链接链中某个操作失败,后续链中操作全部丢弃。适用于"读 header → 写 header → 写 body"这类完全顺序的流水线。
3.4 固定缓冲区与文件
高频 I/O 中每次操作都需要 pin/unpin 用户页面。io_uring 允许预先注册缓冲区或文件描述符:
// 注册文件描述符数组(减少 fget/fput 调用)
int fds[] = { fd1, fd2, fd3 };
io_uring_register_files(˚, fds, 3);
// 使用时:sqe->flags |= IOSQE_FIXED_FILE; sqe->fd = index;
// 注册稀疏文件表(IORING_REGISTER_FILES_UPDATE)
int update_fds[] = { [2] = new_fd, [5] = new_fd2 };
io_uring_register_files_update(˚, 2, update_fds, 2);
// 注册的缓冲区(用于读,需预先 pin 页面)
struct iovec iov = { .iov_base = buf, .iov_len = BUF_SIZE };
io_uring_register_buffers(˚, &iov, 1);
// 使用:sqe->flags |= IOSQE_FIXED_BUFFER;
// 内核自动选择缓冲区(recv)
// 1) 注册缓冲组
struct io_uring_prov_bpb {
.buf_cnt = 128;
.buf_len = 4096;
.bgid = 1;
};
// 实际使用 io_uring_register_buf_ring()(Linux 5.19+)
3.5 重要限制作息
- SQE 大小固定 64 字节:所有变体操作共用同一段内存,需通过 opcode 区分
- CQE 大小固定 16 字节:user_data(8) + res(4) + flags(4)
- Opcode 类型通过 sqe->opcode 区分:IORING_OP_READV、IORING_OP_WRITEV、IORING_OP_SEND、IORING_OP_RECV 等
- cold path vs fast path:io_uring 将 mostly async 操作和 blocking 操作统一到同一接口,但底层仍通过 workqueue 异步化阻塞调用
四、生产环境部署与调优
4.1 系统参数调优
# 增大 aio 最大请求数
sysctl -w fs.aio-max-nr=1048576
# 增大 rmem_max 用于大缓冲区 TCP
sysctl -w net.core.rmem_max=16777216
# NUMA 绑定:sqthread 绑定到网卡同 NUMA 节点
taskset -c 3 ./uring_app
# 中断亲和性
echo 3 > /proc/irq/IRQ_number/smp_affinity
# 增大异步 I/O 线程
echo 64 > /sys/block/nvme0n1/queue/nr_requests
4.2 队列深度选择
| 场景 | 建议深度 | 原因 |
|---|---|---|
| 网络 Echo Server | 256 ~ 1024 | 连接数有限,CPU 瓶颈先于队列 |
| NVMe SSD 顺序读 | 32 ~ 128 | SSD 并行单元少,过深无收益 |
| 高并发代理(百万连接) | 4096 ~ 16384 | 高并发突发需要更大缓冲 |
4.3 选择 SQPOLL 的时机
| 指标 | Interrupt | SQPOLL |
|---|---|---|
| CPU 开销(空载) | 0 | 1 核心 100% |
| 最坏延迟 | ~10μs | ~1μs |
| 适用场景 | 连接 < 10K> | 连接 > 100K,延迟敏感 |
| 热路径系统调用 | 1 io_uring_enter | 可降为 0 |
4.4 链接操作的失败传播
理解链接标记的失败传播模式至关重要:
IOSQE_IO_LINK 链:
[READ] --link--> [WRITE] --link--> [CLOSE]
READ 返回 -EAGAIN → WRITE 被 CANCELED → CLOSE 被 CANCELED
业务层必须检查 CLOSE 的 res:
if (cqe->res == -ECANCELED) {
// 链被中断,清理资源但不需要报错
} else if (cqe->res < 0>
4.5 常见陷阱
- SQE 饥饿:
io_uring_get_sqe返回 NULL 时必须至少部分提交当前批量后再重新获取 - Chain + Multishot 的交互:Multishot accept 产生的 CQE 会不断链接到链中,可能导致链"永远无法关闭"。应单独提交 multishot 请求而非链接
- Buffer Select 后未归还:recv 使用 IOSQE_BUFFER_SELECT 收到的 buffer 必须在下次再注册(或通过 BUF_SELECT 指定 buf_id 的方式隐式归还),否则可用缓冲区会指数减少
- SQPOLL 线程优先级:默认 SCHED_OTHER 会导致用户态提交饥饿。应设置 SCHED_FIFO + 高优先级(通过 sq_thread_cpu 和 prctl)
- 内核版本兼容性:io_uring 特性与内核版本强相关。生产环境推荐 Linux 5.19+
五、综合实战:高性能 TCP Echo Server
以下示例使用 liburing 构建了完整的事件驱动服务器:
#include
#include
#include
#include
#include
#include
#define QUEUE_DEPTH 4096
#define BUF_SIZE 4096
#define PORT 9999
enum { OP_ACCEPT = 0, OP_READ, OP_WRITE };
struct client_info {
int fd;
char buf[BUF_SIZE];
};
static void submit_accept(struct io_uring *ring, int listen_fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
sqe->flags |= IOSQE_FIXED_FILE;
io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);
io_uring_submit(ring);
}
int main(void) {
struct io_uring ring;
io_uring_queue_init(QUEUE_DEPTH, ˚, 0);
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(PORT),
.sin_addr = { INADDR_ANY }
};
int optval = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(listen_fd, 128);
int fds[1] = { listen_fd };
io_uring_register_files(˚, fds, 1);
submit_accept(˚, listen_fd);
while (1) {
struct io_uring_cqe *cqe;
unsigned head;
int count = 0;
io_uring_submit_and_wait(˚, 1);
io_uring_for_each_cqe(˚, head, cqe) {
int op_type = (int)(uintptr_t)io_uring_cqe_get_data(cqe);
switch (op_type) {
case OP_ACCEPT: {
int client_fd = cqe->res;
if (client_fd < 0 xss=removed>fd = client_fd;
struct io_uring_sqe *rsqe = io_uring_get_sqe(˚);
io_uring_prep_recv(rsqe, client_fd, ci->buf, BUF_SIZE, 0);
io_uring_sqe_set_data(rsqe, (void*)OP_READ);
break;
}
case OP_READ: {
struct client_info *ci = (struct client_info *)io_uring_cqe_get_data(cqe);
int bytes = cqe->res;
if (bytes <= 0) { free(ci); break; }
struct io_uring_sqe *wsqe = io_uring_get_sqe(˚);
io_uring_prep_send(wsqe, ci->fd, ci->buf, bytes, 0);
io_uring_sqe_set_data(wsqe, (void*)OP_WRITE);
break;
}
case OP_WRITE: {
struct client_info *ci = (struct client_info *)io_uring_cqe_get_data(cqe);
if (cqe->res < 0 xss=removed>fd, ci->buf, BUF_SIZE, 0);
io_uring_sqe_set_data(rsqe, (void*)OP_READ);
break;
}
}
count++;
}
io_uring_cq_advance(˚, count);
}
io_uring_queue_exit(˚);
return 0;
}
5.1 架构要点
- 多射 accept:IORING_ACCEPT_MULTISHOT(Linux 5.19+)使内核在有新连接到达时自动产生多个 CQE,直到显式取消,减少 SQE 提交频率
- 状态机驱动:通过
user_data编码操作类型(ACCEPT/READ/WRITE),CQE 处理后决定下一步动作(读→写→读 循环) - 注册文件:通过 register_files + IOSQE_FIXED_FILE 避免每次操作内核 fget/fput 引用计数操作
- 连接生命周期:基于 FREE_ON_COMPLETION 认知——当 recv 返回 0 或负值时,归还 client_info 内存;使用 shutdown() + close() 优雅关闭
5.2 进阶改进方向
- 多连接共享缓冲区池:注册 4096 个 4KB 缓冲组,recv 时内核自动从池中取空闲 buffer,省去 fd→buffer 映射查找
- IORING_RECVSEND_PEEK:在决定处理前查看 TCP 数据而不消费,用于协议解析
- IORING_ACCEPT_MULTISHOT_DRAIN:停止 multishot accept 时优雅处理队列中未完成的请求
- XDP 加速:将大包接收路径卸载到 XDP 程序,数据包直接写入 io_uring 提供的 buffer
六、性能基准与生态现状
6.1 吞吐量对比(单核,NVMe 4K 随机读)
| 模型 | IOPS(单核) | 系统调用 |
|---|---|---|
| 同步 read/write | ~120K | 2/IO |
| epoll + O_DIRECT | ~350K | 3+/IO |
| io_uring(默认) | ~1.2M | 0.5/IO(批量均摊) |
| io_uring + SQPOLL + fixed | ~2.1M | 0.1/IO |
| io_uring + registered buffers | ~1.8M | 极低 |
6.2 网络场景(TCP Echo,单核)
| 模型 | Requests/sec |
|---|---|
| epoll 方案 | ~80K req/s |
| io_uring (interrupt) | ~180K req/s |
| io_uring (SQPOLL) | ~320K req/s |
| io_uring + multishot + buffers | ~410K req/s |
6.3 主流框架集成
- Tokio-uring (Rust):基于 io_uring 的异步运行时,绕过 epoll 路径
- glommio:Rust per-core 异步运行时,每个 core 独立 ring
- Netty io_uring transport:2022 年引入,零拷贝 + 无需 epoll
- uring-sys + uringds:Rust 底层绑定 + 高级 socket API
- PostgreSQL:实验性 io_uring 后端替代 pg_pwritev
- QEMU:virtio-blk/scsi 后端默认使用 io_uring
七、未来演进方向
- IORING_OP_SENDMSG_ZC:真正的零拷贝 sendmsg(跳过 socket 层额外复制)
- Multi-shot 扩展:multishot timeout、multishot send
- FUSE io_uring:FUSE 文件系统通过 io_uring 加速,减少用户态/内核态切换
- io_uring + eBPF:在 SQE 处理链中注入 eBPF 程序,实现可编程 I/O 调度
- Kernel same-page merging:注册缓冲区 + KSM,减少虚拟机重复 I/O 内存
总结
io_uring 不只是一个高性能 I/O 库,它是 Linux 内核在"用户态-内核态边界长久以来高开销"问题上的范式革新。其核心贡献:
- 双环形共享队列将 sys_bottleneck 降至历史最低
- 统一接口覆盖磁盘、网络、定时器、socket
- SQPOLL / IOPOLL / Fixed 三级调控机制覆盖不同延迟要求
- 生态繁荣:Rust(tokio-uring/glommio)、Netty、QEMU 全面接入
对于构建高并发网络服务或存储引擎的工程师而言,io_uring 已从"新特性"变为"必备技能"。掌握其设计哲学与生产级调优方法,将直接影响服务的吞吐上限与延迟长尾表现。

发表评论 取消回复