某数据库团队在压测时发现,基于 libaio 的异步 IO 在高并发下经常返回 EAGAIN,而同步 IO 又会阻塞线程。io_uring 在 Linux 5.1 中引入后,同一个工作负载的 IOPS 提升了 3 倍,CPU 占用降低了一半。io_uring 正在重新定义 Linux 的 IO 模型。
一、引言:io_uring——Linux 异步 I/O 的新范式
在第 2 章:内核旁通技术全景中,我们勾勒了高性能 I/O 的技术谱系——从 DPDK 的完全旁通到 XDP 的内核态加速。而在第 8 章:XDP 与 eBPF 高性能网络中,我们看到了在内核中插入可编程快速路径的威力。但如果你不想完全绕过内核,也不想只处理网络包,而是想让所有类型的 I/O——文件读写、网络收发、定时器、信号——都变得真正异步且高效呢?
答案就是 io_uring。
io_uring 是 Linux 5.1(2019 年)由 Jens Axboe 设计并引入的统一异步 I/O 接口。它不是又一个 AIO 的补丁,而是一次彻底的重新设计:通过共享内存环形缓冲区实现用户空间与内核的零拷贝通信,通过批量提交消除系统调用开销,通过 SQPOLL 模式实现零系统调用的 I/O 操作。io_uring 不仅支持文件 I/O,还支持网络 I/O、定时器、信号、文件系统操作等几乎所有系统调用——它正在成为 Linux 的通用异步系统调用接口。
io_uring 的设计哲学可以概括为一句话:让异步 I/O 真正异步。Linux AIO 的最大问题是 buffered I/O 会退化为同步操作——提交时即阻塞,完全失去了”异步”的意义。io_uring 从架构层面保证了所有操作都是真正异步的:应用提交请求后立即返回,内核在后台执行 I/O,完成后通过完成队列异步通知应用。
本章将从 I/O 模型的演进出发,深入剖析 io_uring 的环形缓冲区架构、提交与完成流程、固定缓冲区与文件注册、网络 I/O 集成、SQPOLL 模式,最后与 epoll/AIO 进行全面的性能对比。
二、从同步到异步:I/O 模型的演进
1.1 四代 I/O 模型
Linux 的 I/O 模型经历了四代演进,每一代都在解决前一代的核心缺陷:
1.2 各代模型的局限
Blocking I/O 是最简单的模型:read() 在数据就绪前阻塞调用线程,write() 在数据写入前阻塞。问题——要服务 10000 个并发连接,就需要 10000 个线程,而每个线程的栈空间(默认 8MB)就要消耗 80GB 内存。更糟糕的是,线程上下文切换的开销随并发数线性增长。
Non-blocking I/O + epoll 是目前最主流的高并发模型。将 fd 设为非阻塞模式(O_NONBLOCK),然后用 epoll_wait 等待就绪事件。这解决了线程数量问题——一个线程可以管理数千个连接。但每次 I/O 操作仍需要至少一次系统调用(epoll_wait + read/write),在高 IOPS 场景下系统调用开销成为瓶颈。此外,epoll 只支持网络 I/O 和管道,不支持文件 I/O 的异步通知。
Linux AIO 是内核 2.5 引入的异步 I/O 接口,本意是解决系统调用开销和阻塞问题。但它有三个致命缺陷:
- Buffered I/O 退化为同步:对于不使用
O_DIRECT的文件 I/O,io_submit会直接在调用线程中执行 I/O 操作并阻塞——完全失去了”异步”的意义。这是因为 Page Cache 的读操作可能缺页,写操作可能需要等待脏页写回,这些操作无法在 AIO 的异步框架中完成 - API 复杂且低效:需要先
io_setup创建上下文,再io_submit提交 I/O,最后io_getevents获取完成事件——三次系统调用才能完成一次 I/O 操作 - 数据拷贝开销:I/O 提交和完成事件都需要在用户空间和内核之间拷贝数据,无法实现零拷贝通信
Linux AIO 的 buffered I/O 退化问题是一个架构性缺陷,无法通过修补解决。根本原因在于 AIO 的设计假设 I/O 操作可以在内核中独立完成,但 buffered I/O 的 Page Cache 命中/未命中行为依赖于调用者的内存上下文。io_uring 通过完全不同的架构——共享内存环形缓冲区——从根本上解决了这个问题。
1.3 io_uring 的设计目标
io_uring 的设计目标明确而激进:
| 目标 | 含义 |
|---|---|
| 真正异步 | 所有 I/O 类型(buffered/direct、文件/网络)都是异步的,提交永不阻塞 |
| 零拷贝 | 用户空间与内核通过共享内存通信,无数据拷贝 |
| 批量提交 | 一次系统调用提交多个 I/O 请求,摊薄 syscall 开销 |
| 零系统调用 | SQPOLL 模式下,应用无需发起任何系统调用即可完成 I/O |
| 统一接口 | 支持文件 I/O、网络 I/O、定时器、信号、文件系统操作等所有操作类型 |
| 可扩展 | 无锁设计,支持多线程并发提交和完成 |
二、io_uring 架构:环形缓冲区的精妙设计
2.1 三大核心数据结构
io_uring 的核心是两个共享内存环形缓冲区:提交队列(Submission Queue, SQ)和完成队列(Completion Queue, CQ)。应用和内核通过这两个环形缓冲区进行零拷贝通信。
三个核心数据结构:
- SQE(Submission Queue Entry):提交队列条目,描述一个 I/O 请求。大小 64 字节,包含操作码、fd、地址、长度、偏移量等字段
- CQE(Completion Queue Entry):完成队列条目,描述一个 I/O 完成事件。大小 16 字节,包含用户数据(user_data)和结果(res)
- SQ/CQ 环形缓冲区:由 head 和 tail 指针管理的环形数组,应用和内核通过移动 head/tail 指针来生产/消费条目
2.2 SQ 与 CQ 的内存布局
┌──────────────────────────────────────────────────────────────┐│ 共享内存区域 ││ ││ ┌─ SQ 环形缓冲区 ──────────────────────────────────────┐ ││ │ │ ││ │ SQE 数组 (每个 64 字节) │ ││ │ ┌──────┬──────┬──────┬──────┬──────┬──────┐ │ ││ │ │ SQE0 │ SQE1 │ SQE2 │ SQE3 │ SQE4 │ ... │ │ ││ │ └──────┴──────┴──────┴──────┴──────┴──────┘ │ ││ │ │ ││ │ SQ 索引数组 (uint32) │ ││ │ ┌─────┬─────┬─────┬─────┬─────┬─────┐ │ ││ │ │ 0 │ 1 │ 2 │ 3 │ 4 │ ... │ │ ││ │ └─────┴─────┴─────┴─────┴─────┴─────┘ │ ││ │ │ ││ │ head (uint32) ← 内核消费位置 │ ││ │ tail (uint32) ← 应用生产位置 │ ││ │ ring_mask (uint32) │ ││ │ ring_entries (uint32) │ ││ │ flags (uint32) │ ││ │ dropped (uint32) │ ││ └───────────────────────────────────────────────────────┘ ││ ││ ┌─ CQ 环形缓冲区 ──────────────────────────────────────┐ ││ │ │ ││ │ CQE 数组 (每个 16 字节) │ ││ │ ┌──────┬──────┬──────┬──────┬──────┬──────┐ │ ││ │ │ CQE0 │ CQE1 │ CQE2 │ CQE3 │ CQE4 │ ... │ │ ││ │ └──────┴──────┴──────┴──────┴──────┴──────┘ │ ││ │ │ ││ │ head (uint32) ← 应用消费位置 │ ││ │ tail (uint32) ← 内核生产位置 │ ││ │ ring_mask (uint32) │ ││ │ ring_entries (uint32) │ ││ │ overflow (uint32) │ ││ │ cqes (uint32) │ ││ └───────────────────────────────────────────────────────┘ ││ │└──────────────────────────────────────────────────────────────┘2.3 环形缓冲区的无锁交互
SQ 和 CQ 的核心是 head/tail 指针的单向移动,这种设计天然支持无锁的单生产者-单消费者交互:
关键规则:
- SQ:应用是生产者(写入 SQE,递增 tail),内核是消费者(读取 SQE,递增 head)
- CQ:内核是生产者(写入 CQE,递增 tail),应用是消费者(读取 CQE,递增 head)
- 满/空判断:
head == tail表示队列为空;(tail + 1) & ring_mask == head表示队列已满 - 内存序:应用递增 SQ tail 前必须确保 SQE 数据对内核可见(使用
smp_store_release);内核递增 CQ tail 前必须确保 CQE 数据对应用可见
SQ 的设计有一个巧妙之处:SQE 数组是固定的,但 SQ 索引数组是间接的。应用在索引数组中按顺序填写 SQE 的下标,内核按索引数组的顺序消费 SQE。这种间接层允许应用在任意位置填充 SQE(无需连续),而内核始终按提交顺序处理请求。
2.4 SQE 与 CQE 的结构
// include/uapi/linux/io_uring.h(简化)struct io_uring_sqe { __u8 opcode; /* 操作码,如 IORING_OP_READ, IORING_OP_WRITE */ __u8 flags; /* SQE 标志,如 IOSQE_FIXED_FILE */ __u16 ioprio; /* I/O 优先级 */ __s32 fd; /* 文件描述符 */ __u64 off; /* 偏移量(对文件 I/O)或 addr(对网络 I/O) */ __u64 addr; /* 缓冲区地址或目标地址 */ __u32 len; /* 缓冲区长度 */ union { __kernel_rwf_t rw_flags; /* read/write 标志 */ __u32 fsync_flags; /* fsync 标志 */ __u16 poll_events; /* poll 事件 */ __u32 sync_range_flags; /* sync_range 标志 */ __u32 msg_flags; /* send/recv 标志 */ __u32 timeout_flags; /* timeout 标志 */ __u32 accept_flags; /* accept 标志 */ /* ... 更多操作特定标志 ... */ }; __u64 user_data; /* 用户数据,完成时原样返回到 CQE */ union { __u16 buf_index; /* provided buffer 选择索引 */ __u16 buf_group; /* provided buffer 组 */ }; union { __s32 splice_fd_in; /* splice 输入 fd */ __u32 file_index; /* 固定文件索引 */ }; __u64 addr3; /* 额外地址字段 */ __u64 __pad2[1]; /* 填充到 64 字节 */};
struct io_uring_cqe { __u64 user_data; /* 对应 SQE 的 user_data,用于匹配请求 */ __s32 res; /* I/O 结果:成功返回字节数,失败返回 -errno */ __u32 flags; /* 完成标志(如 buffer 选择、multishot 等) */};关键字段解读:
opcode:操作码,决定这个 SQE 执行什么操作。io_uring 支持的操作码从最初的几个扩展到现在的数十个,覆盖了几乎所有系统调用user_data:用户自定义数据,内核在完成时将其原样拷贝到 CQE 中。这是应用关联请求与完成事件的唯一机制——不依赖 fd 或地址来匹配res:I/O 结果。成功时返回读写的字节数,失败时返回负的错误号(如-EAGAIN、-EINVAL)
user_data 是 64 位无符号整数,不是指针。虽然你可以将指针强制转换为 __u64 存储,但在 32 位系统上需要注意对齐问题。更重要的是,不要假设 user_data 有任何特定含义——它只是应用和内核之间的”回执编号”。
三、提交与完成:一次 I/O 的完整旅程
3.1 完整流程
一次 io_uring I/O 操作经历以下步骤:
3.2 步骤详解
步骤 1:准备 SQE
应用从 SQ 中获取一个空闲的 SQE 槽位。liburing 提供了 io_uring_get_sqe() 函数,它会返回下一个可用的 SQE 指针。如果 SQ 已满(所有 SQE 都在被内核处理),则返回 NULL。
步骤 2:填充 SQE
使用 liburing 的 io_uring_prep_* 系列函数填充 SQE 字段。每个操作都有对应的 prep 函数:
io_uring_prep_read(sqe, fd, buf, len, offset); // 异步读io_uring_prep_write(sqe, fd, buf, len, offset); // 异步写io_uring_prep_accept(sqe, fd, addr, addrlen, flags); // 异步 acceptio_uring_prep_send(sqe, fd, buf, len, flags); // 异步 sendio_uring_prep_recv(sqe, fd, buf, len, flags); // 异步 recvio_uring_prep_connect(sqe, fd, addr, addrlen); // 异步 connectio_uring_prep_openat(sqe, dfd, path, flags, mode); // 异步 openatio_uring_prep_close(sqe, fd); // 异步 closeio_uring_prep_fsync(sqe, fd, flags); // 异步 fsyncio_uring_prep_timeout(sqe, ts, count, flags); // 定时器io_uring_prep_poll_add(sqe, fd, poll_mask); // 异步 poll步骤 3:提交请求
应用递增 SQ 的 tail 指针,然后调用 io_uring_enter() 通知内核有新的请求需要处理。关键点:一次 io_uring_enter() 可以提交多个 SQE——应用可以先填充多个 SQE,然后一次性提交,摊薄系统调用开销。
步骤 4:内核处理
内核从 SQ 中消费 SQE,执行对应的 I/O 操作。对于文件 I/O,内核会提交 bio 请求到块设备层;对于网络 I/O,内核会调用协议栈的收发函数。I/O 完成后,内核将结果写入 CQE 并递增 CQ 的 tail 指针。
步骤 5:收割结果
应用检查 CQ 的 head 和 tail 指针。如果 tail != head,说明有新的完成事件。应用读取 CQE 获取结果,然后递增 CQ 的 head 指针(io_uring_cqe_seen()),告知内核这个 CQE 槽位可以被复用。
3.3 代码示例:基本的 io_uring 文件读取
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <fcntl.h>#include <unistd.h>#include <liburing.h>
#define QUEUE_DEPTH 4#define BLOCK_SIZE 4096
int main(int argc, char *argv[]){ if (argc < 2) { fprintf(stderr, "用法: %s <文件路径>\n", argv[0]); return 1; }
// 1. 初始化 io_uring struct io_uring ring; int ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0); if (ret < 0) { fprintf(stderr, "io_uring_queue_init 失败: %s\n", strerror(-ret)); return 1; }
// 2. 打开文件 int fd = open(argv[1], O_RDONLY); if (fd < 0) { perror("open"); io_uring_queue_exit(&ring); return 1; }
// 3. 分配读取缓冲区 char *buf = malloc(BLOCK_SIZE); if (!buf) { perror("malloc"); close(fd); io_uring_queue_exit(&ring); return 1; }
// 4. 获取空闲 SQE struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); if (!sqe) { fprintf(stderr, "无法获取 SQE\n"); free(buf); close(fd); io_uring_queue_exit(&ring); return 1; }
// 5. 准备异步读请求 io_uring_prep_read(sqe, fd, buf, BLOCK_SIZE, 0); // 设置 user_data,用于在完成时识别请求 io_uring_sqe_set_data64(sqe, 0x1234);
// 6. 提交请求 ret = io_uring_submit(&ring); if (ret < 0) { fprintf(stderr, "io_uring_submit 失败: %s\n", strerror(-ret)); free(buf); close(fd); io_uring_queue_exit(&ring); return 1; }
// 7. 等待完成 struct io_uring_cqe *cqe; ret = io_uring_wait_cqe(&ring, &cqe); if (ret < 0) { fprintf(stderr, "io_uring_wait_cqe 失败: %s\n", strerror(-ret)); free(buf); close(fd); io_uring_queue_exit(&ring); return 1; }
// 8. 处理结果 if (cqe->res >= 0) { printf("成功读取 %d 字节, user_data=0x%lx\n", cqe->res, (unsigned long)io_uring_cqe_get_data64(cqe)); // 可以处理 buf 中的数据 if (cqe->res > 0) { buf[cqe->res < BLOCK_SIZE ? cqe->res : BLOCK_SIZE - 1] = '\0'; printf("内容前 64 字节: %.64s\n", buf); } } else { fprintf(stderr, "读取失败: %s\n", strerror(-cqe->res)); }
// 9. 通知内核 CQE 已消费 io_uring_cqe_seen(&ring, cqe);
// 10. 清理 free(buf); close(fd); io_uring_queue_exit(&ring); return 0;}编译与运行:
# 编译(需要安装 liburing-dev)gcc -o io_uring_read io_uring_read.c -luring
# 运行./io_uring_read /etc/passwd# 输出示例:# 成功读取 2847 字节, user_data=0x1234# 内容前 64 字节: root:x:0:0:root:/root:/bin/bashio_uring_wait_cqe() 会阻塞等待直到有 CQE 可用。如果你不想阻塞,可以使用 io_uring_peek_cqe() 进行非阻塞检查——它立即返回,如果没有 CQE 则返回 -EAGAIN。在实际的高性能应用中,通常使用 io_uring_peek_cqe() 在事件循环中轮询 CQE,避免阻塞。
3.4 批量提交:io_uring 的性能关键
io_uring 的一个核心优势是批量提交。应用可以先填充多个 SQE,然后一次 io_uring_submit() 提交所有请求:
// 批量提交示例:同时发起多个读请求#define NR_IOS 16
for (int i = 0; i < NR_IOS; i++) { struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_read(sqe, fd, bufs[i], BLOCK_SIZE, i * BLOCK_SIZE); io_uring_sqe_set_data64(sqe, i); // 用索引标识请求}
// 一次系统调用提交所有请求int submitted = io_uring_submit(&ring);printf("提交了 %d 个请求\n", submitted);
// 批量收割完成事件for (int i = 0; i < NR_IOS; i++) { struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe); printf("请求 %ld 完成: %d 字节\n", (long)io_uring_cqe_get_data64(cqe), cqe->res); io_uring_cqe_seen(&ring, cqe);}批量提交的性能影响是巨大的:假设一次系统调用开销为 1μs,提交 16 个请求时,每个请求的均摊开销仅为 0.0625μs——比传统的每次 I/O 一次系统调用快 16 倍。
四、固定缓冲区与文件注册
4.1 为什么需要注册?
每次 I/O 操作,内核都需要完成以下工作:
- 查找文件描述符:从进程的 fd 表中查找
struct file,增加引用计数 - 映射用户缓冲区:通过
get_user_pages()将用户空间缓冲区映射到内核空间,建立 DMA 映射
这些操作虽然单次开销不大(几百纳秒到几微秒),但在高 IOPS 场景下(如 NVMe SSD 可达数百万 IOPS),累积开销非常可观。
io_uring 提供了两个注册机制来消除这些开销:
4.2 IORING_REGISTER_BUFFERS:固定缓冲区注册
通过 IORING_REGISTER_BUFFERS 预先注册一组缓冲区,内核会提前完成 get_user_pages() 和 DMA 映射。后续 I/O 操作直接使用预映射的缓冲区,跳过每次 I/O 的页表查找和映射操作。
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <fcntl.h>#include <unistd.h>#include <liburing.h>
#define QUEUE_DEPTH 4#define NR_BUFFERS 4#define BLOCK_SIZE 4096
int main(){ struct io_uring ring; io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
// 1. 分配对齐的缓冲区(注册缓冲区必须页对齐) struct iovec iovecs[NR_BUFFERS]; for (int i = 0; i < NR_BUFFERS; i++) { void *buf; posix_memalign(&buf, 4096, BLOCK_SIZE); iovecs[i].iov_base = buf; iovecs[i].iov_len = BLOCK_SIZE; }
// 2. 注册缓冲区 int ret = io_uring_register_buffers(&ring, iovecs, NR_BUFFERS); if (ret < 0) { fprintf(stderr, "注册缓冲区失败: %s\n", strerror(-ret)); return 1; } printf("成功注册 %d 个缓冲区\n", NR_BUFFERS);
// 3. 使用固定缓冲区进行 I/O int fd = open("data.bin", O_RDONLY | O_DIRECT); if (fd < 0) { perror("open"); return 1; }
// 使用 io_uring_prep_read_fixed 替代 io_uring_prep_read // 注意:buf_index 参数指定使用哪个注册缓冲区 struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_read_fixed(sqe, fd, iovecs[0].iov_base, BLOCK_SIZE, 0, 0); // buf_index = 0 io_uring_sqe_set_data64(sqe, 0);
sqe = io_uring_get_sqe(&ring); io_uring_prep_read_fixed(sqe, fd, iovecs[1].iov_base, BLOCK_SIZE, BLOCK_SIZE, 1); // buf_index = 1 io_uring_sqe_set_data64(sqe, 1);
io_uring_submit(&ring);
// 4. 收割结果 for (int i = 0; i < 2; i++) { struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe); printf("缓冲区 %ld: 读取 %d 字节\n", (long)io_uring_cqe_get_data64(cqe), cqe->res); io_uring_cqe_seen(&ring, cqe); }
// 5. 清理 io_uring_unregister_buffers(&ring); for (int i = 0; i < NR_BUFFERS; i++) free(iovecs[i].iov_base); close(fd); io_uring_queue_exit(&ring); return 0;}4.3 IORING_REGISTER_FILES:固定文件注册
通过 IORING_REGISTER_FILES 预先注册一组文件描述符,内核会提前完成 fd 查找和引用计数操作。后续 I/O 操作使用文件索引(而非 fd)来指定文件,跳过每次 I/O 的 fd 查找。
// 注册文件描述符int fds[NR_FILES];fds[0] = open("file1.bin", O_RDONLY | O_DIRECT);fds[1] = open("file2.bin", O_RDONLY | O_DIRECT);fds[2] = open("file3.bin", O_RDONLY | O_DIRECT);
int ret = io_uring_register_files(&ring, fds, NR_FILES);if (ret < 0) { fprintf(stderr, "注册文件失败: %s\n", strerror(-ret)); return 1;}
// 使用固定文件进行 I/Ostruct io_uring_sqe *sqe = io_uring_get_sqe(&ring);io_uring_prep_read(sqe, 0, buf, BLOCK_SIZE, 0); // fd=0 会被忽略sqe->flags |= IOSQE_FIXED_FILE; // 标记使用固定文件索引sqe->fd = 0; // 使用注册文件索引 0(而非 fd)
// liburing 提供了更方便的接口sqe = io_uring_get_sqe(&ring);io_uring_prep_read(sqe, 1, buf, BLOCK_SIZE, 0); // 索引 1sqe->flags |= IOSQE_FIXED_FILE;
// 清理io_uring_unregister_files(&ring);4.4 性能影响
固定缓冲区和文件注册的性能提升取决于 I/O 模式:
| 场景 | 无注册 | 有注册 | 提升幅度 |
|---|---|---|---|
| 随机 4K 读(NVMe SSD) | ~2.0M IOPS | ~2.6M IOPS | ~30% |
| 顺序 4K 读(NVMe SSD) | ~2.2M IOPS | ~2.8M IOPS | ~27% |
| 网络收发(小包) | ~1.5Mpps | ~1.8Mpps | ~20% |
注册缓冲区有一个重要限制:注册后,缓冲区的物理页会被钉住(pinned),内核无法将其换出到 swap 或迁移。这意味着注册大量缓冲区会占用大量不可回收的物理内存。在内存紧张的系统上,需要谨慎控制注册缓冲区的总量。
五、网络 I/O:io_uring 的网络栈集成
5.1 网络操作码
io_uring 支持完整的网络 I/O 操作,可以替代传统的 socket()/bind()/listen()/accept()/send()/recv()/connect() 系统调用链:
| 操作码 | 替代系统调用 | 说明 |
|---|---|---|
IORING_OP_SOCKET | socket() | 创建套接字(5.19+) |
IORING_OP_BIND | bind() | 绑定地址(5.19+) |
IORING_OP_LISTEN | listen() | 开始监听(5.19+) |
IORING_OP_ACCEPT | accept4() | 异步接受连接 |
IORING_OP_RECV | recv()/read() | 异步接收数据 |
IORING_OP_SEND | send()/write() | 异步发送数据 |
IORING_OP_CONNECT | connect() | 异步发起连接 |
IORING_OP_OPENAT | openat() | 异步打开文件 |
IORING_OP_CLOSE | close() | 异步关闭 fd |
IORING_OP_RECVMSG | recvmsg() | 异步 recvmsg |
IORING_OP_SENDMSG | sendmsg() | 异步 sendmsg |
IORING_OP_POLL_ADD | poll() | 异步 poll |
5.2 Multishot Accept:一次提交,持续接受
传统的 accept() 每次只能接受一个连接,接受后需要重新提交。在高并发连接场景下,这意味着大量的 SQE 填充和提交操作。
Multishot accept 是 io_uring 5.19 引入的特性:提交一个 SQE 后,内核会持续产生完成事件——每接受一个新连接就产生一个 CQE,无需应用重复提交。
// 传统方式:每次 accept 需要重新提交while (1) { struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_accept(sqe, listen_fd, (struct sockaddr *)&addr, &addrlen, 0); io_uring_sqe_set_data64(sqe, OP_ACCEPT); io_uring_submit(&ring);
// 等待完成 struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe); int client_fd = cqe->res; io_uring_cqe_seen(&ring, cqe);
if (client_fd >= 0) { // 处理新连接... }}
// Multishot 方式:一次提交,持续接受struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);io_uring_prep_multishot_accept(sqe, listen_fd, (struct sockaddr *)&addr, &addrlen, 0);io_uring_sqe_set_data64(sqe, OP_MULTISHOT_ACCEPT);io_uring_submit(&ring);
// 之后每接受一个连接,CQ 中就会出现一个 CQEwhile (1) { struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe);
if (cqe->res >= 0) { int client_fd = cqe->res; printf("新连接 fd=%d\n", client_fd); // 处理新连接... }
// 检查是否是 multishot 的最后一个 CQE if (!(cqe->flags & IORING_CQE_F_MORE)) { // multishot 请求结束,需要重新提交 break; }
io_uring_cqe_seen(&ring, cqe);}Multishot 不仅支持 accept,还支持 poll_add、recv 等操作。其核心思想是:一次提交,多次完成。CQE 的 flags 字段中的 IORING_CQE_F_MORE 标志表示”还有更多完成事件即将到来”,应用无需重新提交。当该标志未设置时,表示 multishot 请求已结束(可能是出错或被取消),需要重新提交。
5.3 Provided Buffers:按需分配缓冲区
网络服务器面临一个经典问题:不知道每个连接会发送多少数据,预先为每个连接分配大缓冲区浪费内存,分配小缓冲区又可能不够用。
Provided Buffers 机制允许应用预先向 io_uring 注册一组缓冲区,当 recv 操作有数据到达时,内核自动从缓冲区池中选择一个缓冲区来存放数据,并在 CQE 中告知应用使用了哪个缓冲区。
// 1. 注册 provided buffer 组struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);io_uring_prep_provide_buffers(sqe, bufs, BLOCK_SIZE, NR_BUFS, BGID, 0); // BGID = buffer group IDio_uring_submit(&ring);
// 2. 使用 provided buffer 接收数据sqe = io_uring_get_sqe(&ring);io_uring_prep_recv(sqe, fd, NULL, 0, 0); // addr=NULL, len=0sqe->flags |= IOSQE_BUFFER_SELECT; // 选择缓冲区sqe->buf_group = BGID; // 指定缓冲区组io_uring_sqe_set_data64(sqe, OP_RECV);
// 3. 收割时获取缓冲区信息struct io_uring_cqe *cqe;io_uring_wait_cqe(&ring, &cqe);if (cqe->res > 0 && (cqe->flags & IORING_CQE_F_BUFFER)) { int buf_idx = cqe->flags >> IORING_CQE_BUFFER_SHIFT; printf("接收到 %d 字节,缓冲区索引 %d\n", cqe->res, buf_idx); // 处理 bufs[buf_idx] 中的数据...
// 处理完毕后,将缓冲区归还给 io_uring sqe = io_uring_get_sqe(&ring); io_uring_prep_provide_buffers(sqe, bufs[buf_idx], BLOCK_SIZE, 1, BGID, buf_idx); io_uring_submit(&ring);}5.4 完整示例:io_uring TCP Echo 服务器
以下是一个使用 io_uring 实现的 TCP Echo 服务器,综合运用了 accept、recv、send 和 multishot 特性:
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <fcntl.h>#include <errno.h>#include <sys/socket.h>#include <netinet/in.h>#include <arpa/inet.h>#include <liburing.h>
#define QUEUE_DEPTH 256#define BUF_SIZE 4096#define BUF_GROUP_ID 1
// 操作类型标识enum { OP_ACCEPT = 1, OP_RECV, OP_SEND,};
// 连接上下文struct conn_ctx { int fd; char buf[BUF_SIZE];};
int main(int argc, char *argv[]){ int port = 8080; if (argc > 1) port = atoi(argv[1]);
// 1. 创建监听套接字 int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; }
int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(port), .sin_addr.s_addr = INADDR_ANY, };
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } if (listen(listen_fd, 128) < 0) { perror("listen"); return 1; } printf("监听端口 %d...\n", port);
// 2. 初始化 io_uring struct io_uring ring; int ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0); if (ret < 0) { fprintf(stderr, "io_uring_queue_init 失败: %s\n", strerror(-ret)); return 1; }
// 3. 提交初始 accept 请求 struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); io_uring_prep_accept(sqe, listen_fd, (struct sockaddr *)&client_addr, &client_len, 0); io_uring_sqe_set_data64(sqe, OP_ACCEPT);
// 4. 事件循环 while (1) { io_uring_submit(&ring);
struct io_uring_cqe *cqe; ret = io_uring_wait_cqe(&ring, &cqe); if (ret < 0) { fprintf(stderr, "wait_cqe 失败: %s\n", strerror(-ret)); continue; }
unsigned long op_type = io_uring_cqe_get_data64(cqe);
switch (op_type) { case OP_ACCEPT: { int client_fd = cqe->res; io_uring_cqe_seen(&ring, cqe);
if (client_fd < 0) { fprintf(stderr, "accept 失败: %s\n", strerror(-client_fd)); break; }
printf("新连接 fd=%d\n", client_fd);
// 为新连接提交 recv 请求 struct conn_ctx *ctx = malloc(sizeof(*ctx)); ctx->fd = client_fd;
sqe = io_uring_get_sqe(&ring); io_uring_prep_recv(sqe, client_fd, ctx->buf, BUF_SIZE, 0); io_uring_sqe_set_data64(sqe, OP_RECV); sqe->user_data = (unsigned long)ctx;
// 重新提交 accept sqe = io_uring_get_sqe(&ring); io_uring_prep_accept(sqe, listen_fd, (struct sockaddr *)&client_addr, &client_len, 0); io_uring_sqe_set_data64(sqe, OP_ACCEPT); break; }
case OP_RECV: { struct conn_ctx *ctx = (struct conn_ctx *)cqe->user_data; int nbytes = cqe->res; io_uring_cqe_seen(&ring, cqe);
if (nbytes <= 0) { // 连接关闭或出错 if (nbytes == 0) { printf("fd=%d 连接关闭\n", ctx->fd); } else { fprintf(stderr, "recv 失败 fd=%d: %s\n", ctx->fd, strerror(-nbytes)); } close(ctx->fd); free(ctx); break; }
// Echo 回去:提交 send 请求 sqe = io_uring_get_sqe(&ring); io_uring_prep_send(sqe, ctx->fd, ctx->buf, nbytes, 0); io_uring_sqe_set_data64(sqe, OP_SEND); sqe->user_data = (unsigned long)ctx; break; }
case OP_SEND: { struct conn_ctx *ctx = (struct conn_ctx *)cqe->user_data; int nbytes = cqe->res; io_uring_cqe_seen(&ring, cqe);
if (nbytes < 0) { fprintf(stderr, "send 失败 fd=%d: %s\n", ctx->fd, strerror(-nbytes)); close(ctx->fd); free(ctx); break; }
// 发送完成,继续接收 sqe = io_uring_get_sqe(&ring); io_uring_prep_recv(sqe, ctx->fd, ctx->buf, BUF_SIZE, 0); io_uring_sqe_set_data64(sqe, OP_RECV); sqe->user_data = (unsigned long)ctx; break; }
default: io_uring_cqe_seen(&ring, cqe); break; } }
io_uring_queue_exit(&ring); close(listen_fd); return 0;}编译与测试:
# 编译gcc -o io_uring_echo_server io_uring_echo_server.c -luring
# 启动服务器./io_uring_echo_server 9000# 输出:监听端口 9000...
# 在另一个终端测试echo "hello io_uring" | nc localhost 9000# 输出:hello io_uring
# 使用压测工具测试吞吐# 安装 wrksudo apt install wrkwrk -t4 -c1000 -d10s http://localhost:9000/5.5 异步 Connect 与 Open
io_uring 不仅支持 accept/recv/send,还支持 connect 和 openat/close——这意味着即使是连接建立和文件打开这些传统上阻塞的操作,也可以异步化:
// 异步 connectstruct sockaddr_in server_addr = { .sin_family = AF_INET, .sin_port = htons(80), .sin_addr.s_addr = inet_addr("93.184.216.34"), // example.com};
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
sqe = io_uring_get_sqe(&ring);io_uring_prep_connect(sqe, sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));io_uring_sqe_set_data64(sqe, OP_CONNECT);io_uring_submit(&ring);
// 异步 openatsqe = io_uring_get_sqe(&ring);io_uring_prep_openat(sqe, AT_FDCWD, "/tmp/output.dat", O_WRONLY | O_CREAT | O_TRUNC, 0644);io_uring_sqe_set_data64(sqe, OP_OPENAT);
// 异步 closesqe = io_uring_get_sqe(&ring);io_uring_prep_close(sqe, fd_to_close);io_uring_sqe_set_data64(sqe, OP_CLOSE);异步 connect 有一个微妙之处:CQE 返回 res=0 并不意味着连接已建立——它只表示 connect 操作已发起。对于非阻塞 connect,你需要等待 socket 可写后再检查 SO_ERROR 确认连接是否成功。不过,io_uring 的 IORING_OP_CONNECT 在完成时已经确认了连接状态,res=0 即表示连接成功,res=-EINPROGRESS 表示连接正在进行中。
六、SQPOLL:内核线程轮询模式
6.1 为什么需要 SQPOLL?
到目前为止,讨论的 io_uring 模式都需要应用调用 io_uring_enter() 来通知内核有新的 SQE 需要处理。虽然批量提交可以摊薄系统调用开销,但在超低延迟场景下(如高频交易、实时音视频),即使是偶尔的系统调用也可能导致延迟毛刺。
SQPOLL(Submission Queue Polling) 模式解决了这个问题:内核创建一个专用线程,持续轮询 SQ 中的新请求。应用只需填充 SQE 并递增 tail 指针,无需任何系统调用——内核线程会自动发现并处理新请求。
6.2 SQPOLL 的工作原理
SQPOLL 线程的生命周期:
- 创建:应用在
io_uring_setup()时设置IORING_SETUP_SQPOLL标志,内核创建一个专用内核线程 - 活跃轮询:SQPOLL 线程持续轮询 SQ 的 tail 指针,发现新请求后立即处理
- 空闲休眠:如果 SQ 一段时间内没有新请求(由
sq_thread_idle参数控制,默认 1 秒),SQPOLL 线程进入休眠 - 唤醒:SQPOLL 线程休眠后,SQ 的 flags 字段会设置
IORING_SQ_NEED_WAKEUP标志。此时应用需要调用io_uring_enter()并设置IORING_ENTER_SQ_WAKEUP标志来唤醒 SQPOLL 线程
6.3 SQPOLL 代码示例
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <fcntl.h>#include <unistd.h>#include <liburing.h>
#define QUEUE_DEPTH 64
int main(){ struct io_uring ring;
// 初始化 io_uring,启用 SQPOLL 模式 // sq_thread_idle: SQPOLL 线程空闲多少毫秒后休眠 struct io_uring_params params = {0}; params.flags = IORING_SETUP_SQPOLL; params.sq_thread_idle = 2000; // 2 秒空闲后休眠
int ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms); if (ret < 0) { fprintf(stderr, "io_uring_queue_init_params 失败: %s\n", strerror(-ret)); return 1; }
printf("SQPOLL 模式已启用,内核线程 TID=%d\n", params.sq_thread_cpu);
int fd = open("data.bin", O_RDONLY | O_DIRECT); if (fd < 0) { perror("open"); return 1; }
void *buf; posix_memalign(&buf, 4096, 4096);
// 提交 I/O 请求——无需 io_uring_submit()! // SQPOLL 线程会自动发现新请求 for (int i = 0; i < 16; i++) { struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_read(sqe, fd, buf, 4096, i * 4096); io_uring_sqe_set_data64(sqe, i); }
// 在 SQPOLL 模式下,io_uring_submit() 仍然可以调用 // 但它只检查是否需要唤醒 SQPOLL 线程,不会发起系统调用 // 如果 SQPOLL 线程正在轮询,这是一个空操作 ret = io_uring_submit(&ring); printf("提交返回值: %d\n", ret);
// 检查 SQPOLL 线程是否需要唤醒 if (io_uring_sq_ready(&ring) > 0) { // 还有未处理的 SQE,可能 SQPOLL 线程已休眠 unsigned int *sq_flags = io_uring_get_sqe_flags(&ring); // 需要手动唤醒 ret = io_uring_enter(&ring, 0, 0, IORING_ENTER_SQ_WAKEUP); if (ret < 0) { fprintf(stderr, "唤醒 SQPOLL 失败: %s\n", strerror(-ret)); } }
// 收割完成事件 for (int i = 0; i < 16; i++) { struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe); printf("请求 %ld: %d 字节\n", (long)io_uring_cqe_get_data64(cqe), cqe->res); io_uring_cqe_seen(&ring, cqe); }
free(buf); close(fd); io_uring_queue_exit(&ring); return 0;}6.4 SQPOLL 的适用场景
| 场景 | 是否推荐 SQPOLL | 原因 |
|---|---|---|
| 高频交易 | 推荐 | 超低延迟要求,系统调用毛刺不可接受 |
| NVMe SSD 高 IOPS | 推荐 | I/O 速率极高,系统调用开销占比大 |
| 普通网络服务器 | 需谨慎 | 独占一个 CPU 核心,资源开销大 |
| 低并发应用 | 不推荐 | 轮询空转浪费 CPU,不如默认模式 |
| 多线程共享 ring | 不推荐 | SQPOLL 设计为单线程独占 |
SQPOLL 模式有一个重要的资源代价:它独占一个 CPU 核心用于轮询。在 CPU 核心数有限的系统上,这意味着少了一个核心用于实际业务逻辑。此外,SQPOLL 线程绑定到特定 CPU 核心(由 sq_thread_cpu 参数指定),如果该核心被其他高优先级任务抢占,I/O 延迟会受影响。在使用 SQPOLL 前,务必通过基准测试验证其收益是否超过 CPU 占用代价。
七、与 epoll/AIO 的性能对比
7.1 io_uring vs epoll:网络 I/O
epoll 是目前 Linux 网络服务器的标准 I/O 多路复用机制。io_uring 在网络 I/O 场景下的性能优势主要来自三个方面:
- 批量提交:epoll 每次就绪事件后仍需逐个调用
read/write,io_uring 可以批量提交多个 recv/send 请求 - 零系统调用:SQPOLL 模式下,recv/send 完全不需要系统调用
- 减少上下文切换:epoll 的
epoll_wait→read→epoll_wait循环涉及多次用户态/内核态切换,io_uring 一次提交/收割循环只需一次
典型的基准测试结果(TCP 回显服务器,单核):
| 指标 | epoll | io_uring | io_uring + SQPOLL |
|---|---|---|---|
| 吞吐量 (req/s) | ~850K | ~1.8M | ~2.5M |
| P50 延迟 (μs) | ~35 | ~18 | ~12 |
| P99 延迟 (μs) | ~120 | ~55 | ~35 |
| 系统调用次数/秒 | ~1.7M | ~180K | ~0 |
| CPU 利用率 | 95% | 90% | 99%(含轮询线程) |
7.2 io_uring vs AIO:磁盘 I/O
Linux AIO 是 io_uring 的前身,在 Direct I/O 场景下可以提供异步行为。但 io_uring 在各方面都优于 AIO:
| 指标 | Linux AIO | io_uring |
|---|---|---|
| 4K 随机读 IOPS (NVMe) | ~1.5M | ~2.6M |
| 4K 随机写 IOPS (NVMe) | ~1.2M | ~2.4M |
| Buffered I/O | 退化为同步 | 真正异步 |
| 系统调用开销 | 3 次/请求 | 1 次/批量 |
| 数据拷贝 | 需要 | 零拷贝 |
| 操作类型 | read/write | read/write/fsync/open/close/… |
7.3 全面特性对比
| 特性 | epoll | Linux AIO | io_uring |
|---|---|---|---|
| 内核版本 | 2.5.44+ | 2.5.32+ | 5.1+ |
| Buffered I/O | (同步) | (退化为同步) | (真正异步) |
| Direct I/O | (同步) | (异步) | (异步) |
| 网络 I/O | |||
| 文件系统操作 | (openat/close/statx/…) | ||
| 定时器 | |||
| 信号 | |||
| 批量提交 | 有限 | ||
| 零拷贝通信 | |||
| 零系统调用 | (SQPOLL) | ||
| Multishot | |||
| 链式请求 | (IOSQE_IO_LINK) | ||
| 固定缓冲区 | |||
| 固定文件 | |||
| 取消请求 | 有限 | ||
| 代码复杂度 | 低 | 高 | 中(liburing 简化) |
| 生态成熟度 | 极高 | 低 | 快速增长中 |
7.4 何时选择 io_uring?
io_uring 并非在所有场景下都是最佳选择。以下是选型建议:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 高并发网络服务器(C10K+) | io_uring | 批量提交和零拷贝优势明显 |
| NVMe SSD 高 IOPS 存储 | io_uring + O_DIRECT | 固定缓冲区 + 批量提交 = 极致 IOPS |
| 普通网络应用(<1K 连接) | epoll | 生态成熟,调试工具丰富,性能够用 |
| 需要 POSIX 兼容性 | epoll/AIO | io_uring 是 Linux 专有接口 |
| 跨平台应用 | epoll/kqueue/IOCP | io_uring 仅限 Linux 5.1+ |
| 高频交易/超低延迟 | io_uring + SQPOLL | 零系统调用消除延迟毛刺 |
| 数据库 WAL 写入 | io_uring + O_DIRECT + fsync | 异步 fsync + 批量提交 |
io_uring 的生态正在快速发展。Linux 6.x 内核已经将越来越多的系统调用纳入 io_uring 的异步框架,包括 futex、ftruncate、mkdirat、symlinkat、linkat 等。同时,高级语言运行时也在逐步集成 io_uring:Rust 的 tokio-uring、Go 的 golang.org/x/sys/unix 中的 io_uring 绑定、Node.js 的 libuv 实验性支持等。可以预见,io_uring 将成为 Linux 异步 I/O 的事实标准。
九、动手实践
实践 1:构建 liburing 并运行示例
# 克隆 liburing 仓库git clone https://github.com/axboe/liburing.gitcd liburing
# 编译安装make -j$(nproc)sudo make install
# 确认内核版本(io_uring 需要 5.1+,推荐 5.10+)uname -r# 建议使用 5.15+ 以获得完整特性支持
# 检查 io_uring 是否可用cat /proc/sys/kernel/io_uring_disabled# 输出 0 表示可用,1 表示禁用
# 如果被禁用,可以临时启用sudo sysctl -w kernel.io_uring_disabled=0
# 编译运行 liburing 自带的示例cd examplesgcc -o io_uring-cp io_uring-cp.c -luring./io_uring-cp /etc/passwd /tmp/passwd_copydiff /etc/passwd /tmp/passwd_copy && echo "文件复制成功!"实践 2:编写 io_uring 文件复制程序
cat > /tmp/io_uring_cp.c << 'CEOF'#include <stdio.h>#include <stdlib.h>#include <string.h>#include <fcntl.h>#include <unistd.h>#include <liburing.h>
#define QUEUE_DEPTH 32#define BLOCK_SIZE 4096
int main(int argc, char *argv[]){ if (argc != 3) { fprintf(stderr, "用法: %s <源文件> <目标文件>\n", argv[0]); return 1; }
int src_fd = open(argv[1], O_RDONLY); if (src_fd < 0) { perror("open src"); return 1; }
int dst_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644); if (dst_fd < 0) { perror("open dst"); close(src_fd); return 1; }
// 获取源文件大小 off_t file_size = lseek(src_fd, 0, SEEK_END); lseek(src_fd, 0, SEEK_SET); printf("文件大小: %ld 字节\n", (long)file_size);
struct io_uring ring; io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
off_t offset = 0; int pending = 0; long total_copied = 0;
while (offset < file_size || pending > 0) { // 提交读请求 while (offset < file_size && pending < QUEUE_DEPTH / 2) { struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); char *buf = malloc(BLOCK_SIZE); if (!buf) break;
size_t to_read = BLOCK_SIZE; if (offset + to_read > file_size) to_read = file_size - offset;
io_uring_prep_read(sqe, src_fd, buf, to_read, offset); // 将 offset 和 buf 指针编码到 user_data sqe->user_data = (unsigned long)buf; offset += to_read; pending++; } io_uring_submit(&ring);
// 收割读完成事件,提交写请求 int nr_complete = 0; while (nr_complete < pending) { struct io_uring_cqe *cqe; if (io_uring_peek_cqe(&ring, &cqe) < 0) break;
char *buf = (char *)cqe->user_data; int bytes_read = cqe->res; io_uring_cqe_seen(&ring, cqe); nr_complete++;
if (bytes_read <= 0) { free(buf); continue; }
// 提交写请求 struct io_uring_sqe *wsqe = io_uring_get_sqe(&ring); io_uring_prep_write(wsqe, dst_fd, buf, bytes_read, total_copied); wsqe->user_data = (unsigned long)buf; total_copied += bytes_read; } pending -= nr_complete; io_uring_submit(&ring);
// 收割写完成事件 for (int i = 0; i < nr_complete; i++) { struct io_uring_cqe *cqe; if (io_uring_peek_cqe(&ring, &cqe) < 0) break; char *buf = (char *)cqe->user_data; free(buf); io_uring_cqe_seen(&ring, cqe); } }
printf("复制完成: %ld 字节\n", total_copied);
io_uring_queue_exit(&ring); close(src_fd); close(dst_fd); return 0;}CEOF
gcc -o /tmp/io_uring_cp /tmp/io_uring_cp.c -luring
# 测试dd if=/dev/urandom of=/tmp/test_src.bin bs=1M count=10/tmp/io_uring_cp /tmp/test_src.bin /tmp/test_dst.bindiff /tmp/test_src.bin /tmp/test_dst.bin && echo "复制验证通过!"实践 3:编写 io_uring TCP Echo 服务器
# 使用本章第五节的完整代码cat > /tmp/io_uring_echo.c << 'CEOF'# 此处粘贴第五节的 TCP Echo 服务器代码# 为节省篇幅,请参考上文完整代码CEOF
# 实际操作时,将第五节的代码保存为文件gcc -o /tmp/io_uring_echo /tmp/io_uring_echo.c -luring
# 启动服务器/tmp/io_uring_echo 9000 &
# 测试echo "hello io_uring" | nc localhost 9000
# 使用多连接压测for i in $(seq 1 100); do echo "test $i" | nc -q 1 localhost 9000 &donewait
# 清理kill %1实践 4:基准测试 io_uring vs epoll
# 安装 fio(灵活的 I/O 基准测试工具,支持 io_uring)sudo apt install fio
# 创建测试配置:io_uring 随机读cat > /tmp/fio_io_uring.fio << 'EOF'[global]ioengine=io_uringiodepth=64numjobs=1size=1Gdirectory=/tmpbs=4k
[randread-io_uring]rw=randreadEOF
# 创建测试配置:libaio 随机读cat > /tmp/fio_libaio.fio << 'EOF'[global]ioengine=libaioiodepth=64numjobs=1size=1Gdirectory=/tmpbs=4kdirect=1
[randread-libaio]rw=randreadEOF
# 创建测试配置:epoll 网络基准(使用 wrk)# 先启动一个简单的 echo 服务器# 然后用 wrk 压测
# 运行磁盘 I/O 基准测试echo "=== io_uring 随机读 ==="fio /tmp/fio_io_uring.fio --output-format=terse
echo "=== libaio 随机读 ==="fio /tmp/fio_libaio.fio --output-format=terse
# 使用 fio 的 SQPOLL 模式cat > /tmp/fio_sqpoll.fio << 'EOF'[global]ioengine=io_uringiodepth=128numjobs=1size=1Gdirectory=/tmpbs=4kdirect=1fixedbufs=1registerfiles=1sqthread_poll=1
[randread-sqpoll]rw=randreadEOF
echo "=== io_uring + SQPOLL 随机读 ==="fio /tmp/fio_sqpoll.fio --output-format=terse
# 网络基准:使用 wrk 对比 epoll 和 io_uring 服务器# 终端 1:启动 epoll 服务器(如 nginx)# 终端 2:启动 io_uring echo 服务器# 终端 3:运行 wrk
# epoll 服务器基准wrk -t4 -c1000 -d10s http://localhost:8080/
# io_uring 服务器基准(需要 HTTP 包装)wrk -t4 -c1000 -d10s http://localhost:9000/实践 5:观察 io_uring 内核行为
# 查看 io_uring 相关的内核参数cat /proc/sys/kernel/io_uring_disabled# 0 = 启用, 1 = 禁用
# 查看进程的 io_uring 实例# Linux 5.19+ 可以通过 /proc 查看ls /proc/self/fdinfo/ | head
# 使用 perf 追踪 io_uring 事件sudo perf list | grep io_uring# 可用的追踪点:# io_uring:io_uring_create# io_uring:io_uring_submit# io_uring:io_uring_complete# io_uring:io_uring_cqe_overflow
# 追踪 io_uring 操作sudo perf record -e 'io_uring:*' -a sleep 5sudo perf script
# 使用 bpftrace 实时观察sudo bpftrace -e 'tracepoint:io_uring:io_uring_complete { printf("完成: user_data=%llx res=%d flags=%x\n", args->user_data, args->res, args->cflags);}'
# 使用 perf stat 统计系统调用次数perf stat -e 'syscalls:sys_enter_io_uring_enter' \ -e 'syscalls:sys_enter_io_uring_setup' \ -e 'syscalls:sys_enter_io_uring_register' \ ./your_io_uring_app小结
本章从 I/O 模型的演进出发,深入剖析了 io_uring 的架构设计与使用方法。以下是关键要点回顾:
-
io_uring 是 Linux 异步 I/O 的终极方案:它通过共享内存环形缓冲区实现了用户空间与内核的零拷贝通信,彻底解决了 Linux AIO 的 buffered I/O 退化问题,让所有类型的 I/O 操作都成为真正异步的。
-
环形缓冲区是无锁交互的核心:SQ 和 CQ 通过 head/tail 指针的单向移动实现单生产者-单消费者的无锁交互。应用是 SQ 的生产者、CQ 的消费者;内核是 SQ 的消费者、CQ 的生产者。这种设计天然避免了锁竞争。
-
批量提交是性能的关键:io_uring 允许应用先填充多个 SQE,然后一次
io_uring_submit()提交所有请求。在高 IOPS 场景下,批量提交将系统调用开销摊薄到几乎可忽略的程度。 -
固定缓冲区和文件注册消除重复开销:通过
IORING_REGISTER_BUFFERS和IORING_REGISTER_FILES,应用可以预先注册缓冲区和文件描述符,跳过每次 I/O 的页表映射和 fd 查找操作,在高 IOPS 场景下可获得 10-30% 的性能提升。 -
网络 I/O 是 io_uring 的重要应用场景:io_uring 支持 accept/recv/send/connect 等完整的网络操作,multishot accept 和 provided buffers 进一步优化了网络服务器的性能。与 epoll 相比,io_uring 在网络 I/O 场景下可获得 2-3 倍的吞吐提升。
-
SQPOLL 实现零系统调用的 I/O:通过内核线程轮询 SQ,SQPOLL 模式消除了
io_uring_enter()系统调用,适用于超低延迟场景。但它独占一个 CPU 核心,需要权衡资源代价。 -
io_uring 正在成为 Linux 的通用异步接口:从最初的 read/write/fsync,到现在的 openat/close/statx/connect/accept/send/recv/timeout/futex,io_uring 支持的操作类型不断扩展。它不再只是一个异步 I/O 接口,而是正在成为 Linux 的通用异步系统调用接口。
io_uring 与本系列其他技术的关系:io_uring 是内核态加速方案,与 DPDK 的完全旁通不同,它仍在内核中执行 I/O 操作,但通过共享内存和批量提交极大降低了交互开销。在第 2 章的技术谱系中,io_uring 位于”内核态优化”一端——不离开内核,但让内核路径更高效。对于需要极致性能的场景,io_uring + O_DIRECT + 固定缓冲区可以接近 DPDK 的性能水平,同时保留内核协议栈的完整功能。
参考资料
内核源码
| 文件 | 内容 |
|---|---|
io_uring/io_uring.c | io_uring 核心实现:ring 初始化、SQE 提交、CQE 完成、SQPOLL 线程 |
io_uring/rw.c | io_uring 读写操作:IORING_OP_READ/IORING_OP_WRITE 实现 |
io_uring/net.c | io_uring 网络操作:accept/recv/send/connect 实现 |
io_uring/filetable.c | 固定文件注册与管理 |
io_uring/rsrc.c | 固定缓冲区注册与管理 |
io_uring/sqpoll.c | SQPOLL 内核线程实现 |
io_uring/timeout.c | 定时器操作实现 |
io_uring/poll.c | poll 操作与 multishot poll 实现 |
include/uapi/linux/io_uring.h | io_uring 用户态 API 定义:SQE/CQE 结构、操作码、标志 |
权威文档与论文
- Efficient IO with io_uring (PDF) — Jens Axboe 的 io_uring 设计文档,阐述设计动机与架构决策
- Linux 内核官方文档 — io_uring — io_uring 的权威文档
- io_uring 源码仓库与示例 — Jens Axboe 维护的 liburing 库,包含大量示例代码
- LWN.net: io_uring 系列 — LWN 对 io_uring 的系列深度分析
- io_uring Wiki — io_uring 接口说明与使用示例
在线资源
- Bootlin Elixir Cross Referencer — 在线浏览 io_uring 内核源码
- io_uring 性能基准测试 — fio I/O 基准测试工具,支持 io_uring 引擎
- io_uring-echo-server 示例 — 多个 io_uring 网络服务器示例
- Unix Network Programming — 网络编程经典,理解 epoll 与传统 I/O 模型的基础
- Brendan Gregg’s Linux Performance — I/O 性能分析方法
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






