io_uring 深度实战:Linux 异步 I/O 如何用零拷贝和共享内存把网络吞吐量推到极限
引言:Linux 异步 I/O 的世纪之痛
如果你在 Linux 上写过高性能网络服务,你一定经历过这样的困境:epoll 的边缘触发模式让你小心翼翼地处理 EAGAIN,磁盘 I/O 仍然阻塞在同步调用上,而 Linux AIO(libaio)的限制让人抓狂——只支持 O_DIRECT、回调模型反人类、错误码语义混乱。这不是你的问题,这是 Linux 内核几十年来在异步 I/O 领域的历史欠债。
2019 年,Linux 5.1 合入了一个全新的子系统:io_uring。它不是对现有 AIO 的修修补补,而是从零开始设计的一套完整异步 I/O 框架。通过共享内存环形队列实现内核与用户态的零拷贝通信,通过批量化提交把系统调用开销摊薄到几乎为零,通过原生支持文件和网络的统一异步接口终结了 epoll + 线程池的缝合怪架构。
三年后的今天,io_uring 已经成为 Linux 内核最活跃的子系统之一,每个内核版本都在持续迭代。从 Rust 的 tokio-uring 到 Go 的 golang.org/x/sys/unix,从 Nginx 的 io_uring 模块到 RocksDB 的 io_uring 后端,主流生态正在全面拥抱这一技术。
本文将从底层架构出发,用大量代码实战带你理解 io_uring 的设计哲学,最后给出生产级的性能优化策略。读完本文,你不仅能写出基于 io_uring 的高性能服务,还能理解为什么 Jens Axboe(io_uring 作者、Linux 块设备维护者)说"这是 Linux I/O 的未来"。
一、历史包袱:为什么我们需要 io_uring
1.1 同步 I/O:最简单的模型,最致命的瓶颈
传统的同步 I/O 模型(read/write)在低并发场景下工作良好,但当你的服务需要同时处理上万连接时,问题就暴露了:
// 传统的同步读取——一个连接一个线程
void* handle_client(void* arg) {
int fd = *(int*)arg;
char buf[4096];
while (1) {
// 如果对端不发送数据,线程就卡在这里
ssize_t n = read(fd, buf, sizeof(buf));
if (n <= 0) break;
// 处理数据...
process(buf, n);
}
close(fd);
return NULL;
}
每个连接一个线程的模型在 C10K 问题面前彻底崩溃。线程切换开销、栈内存消耗、锁竞争——每一个都是性能杀手。
1.2 epoll:事件驱动的折中方案
epoll 是 Linux 对 C10K 问题的回答,但它的本质是 I/O 多路复用,不是真正的异步 I/O:
// epoll 的经典用法——事件通知,但读写仍是同步的
int epfd = epoll_create1(0);
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET, // 边缘触发
.data.fd = client_fd
};
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
while (1) {
struct epoll_event events[1024];
int nfds = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
// ⚠️ 这里仍然是同步读取!
// 边缘触发下必须读完所有数据,否则会丢失事件
while (1) {
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN) break; // 数据读完了
perror("read");
break;
}
process(buf, n);
}
}
}
epoll 的核心问题:
- 磁盘 I/O 不适用:磁盘文件总是"可读"的,epoll 对文件 I/O 无能为力
- 通知 ≠ 完成:epoll 告诉你"可以读了",但你还是要自己调 read,这仍然是同步阻塞
- 系统调用开销:每次 epoll_wait 都是一次系统调用,频繁调用时开销不可忽视
1.3 Linux AIO:一个失败的尝试
Linux 内核在 2.6 版本引入了 AIO(libaio),但它的设计存在根本性缺陷:
// Linux AIO 的使用——限制重重
#include <libaio.h>
io_context_t ctx;
io_queue_init(128, &ctx);
struct iocb cb;
io_prep_pread(&cb, fd, buf, size, offset);
// ⚠️ 必须使用 O_DIRECT 打开文件!
// ⚠️ 缓冲区必须对齐!
// ⚠️ 不支持 socket!
struct iocb* cbs[1] = { &cb };
io_submit(ctx, 1, cbs);
struct io_event events[1];
io_getevents(ctx, 1, 1, events, NULL);
Linux AIO 的致命缺陷:
- 只支持 O_DIRECT:必须绕过页缓存,对齐要求苛刻
- 不支持网络 I/O:只能做文件操作,网络还得用 epoll
- 系统调用密集:io_submit 和 io_getevents 都是系统调用
- API 反人类:iocb 结构体字段繁多,回调模型难以使用
这就是 2019 年之前 Linux 异步 I/O 的窘境:epoll 不支持文件,AIO 不支持网络,生产环境只能 epoll + 线程池搞缝合。
二、架构解析:io_uring 如何重新定义异步 I/O
2.1 核心设计哲学:共享内存 + 环形队列
io_uring 的设计可以用一句话概括:用共享内存上的两个环形队列,实现用户态和内核态之间的零拷贝、零系统调用通信。
┌─────────────────────────────────────────────────┐
│ 用户空间 │
│ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Submission │ │ Completion │ │
│ │ Queue (SQ) │ │ Queue (CQ) │ │
│ │ │ │ │ │
│ │ ┌─┬─┬─┬─┬─┐ │ │ ┌─┬─┬─┬─┬─┐ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ └─┴─┴─┴─┴─┘ │ │ └─┴─┴─┴─┴─┘ │ │
│ │ SQE 数组 │ │ CQE 数组 │ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ 写入 │ 读取 │
├───────────┼──────────────────────┼────────────────┤
│ │ │ │
│ ▼ │ │
│ 内核消费 SQE 内核写入 CQE │
│ │
│ 内核空间 │
└─────────────────────────────────────────────────┘
关键设计点:
- SQ(Submission Queue):用户态写入,内核态读取。用户把 I/O 请求封装成 SQE(Submission Queue Entry)放进 SQ。
- CQ(Completion Queue):内核态写入,用户态读取。I/O 完成后,内核把结果封装成 CQE(Completion Queue Entry)放进 CQ。
- 共享内存:SQ 和 CQ 通过 mmap 映射到用户空间,内核和用户态直接读写同一块内存,零拷贝。
- Memory Barrier:用内存屏障代替锁,实现无锁的环形队列操作。
2.2 三个系统调用的极简接口
io_uring 只有三个系统调用,这是它的全部接口:
// 1. 初始化 io_uring 实例
int io_uring_setup(u32 entries, struct io_uring_params *params);
// 2. 提交和等待完成
int io_uring_enter(unsigned int fd, unsigned int to_submit,
unsigned int min_complete, unsigned int flags,
sigset_t *sig);
// 3. 注册共享资源(文件描述符、缓冲区等)
int io_uring_register(unsigned int fd, unsigned int opcode,
void *arg, unsigned int nr_args);
就这三个。对比一下:
- epoll:epoll_create + epoll_ctl + epoll_wait + read/write = 至少 4 种系统调用
- AIO:io_setup + io_submit + io_getevents + io_destroy = 4 种系统调用
- io_uring:3 种系统调用,而且通过 SQPOLL 模式可以完全消除系统调用
2.3 SQE 和 CQE:请求与响应的数据结构
// 提交队列条目(SQE)—— 用户填写,内核消费
struct io_uring_sqe {
__u8 opcode; /* I/O 操作类型:read、write、send、recv... */
__u8 flags; /* 操作标志 */
__u16 ioprio; /* I/O 优先级 */
__s32 fd; /* 文件描述符 */
union {
__u64 off; /* 文件偏移量 */
__u64 addr2;
};
union {
__u64 addr; /* 缓冲区地址 */
__u64 splice_off_in;
};
__u32 len; /* 缓冲区长度 */
union {
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
__u16 poll_events;
__u32 poll32_events;
__u32 sync_range_flags;
__u32 msg_flags;
__u32 timeout_flags;
__u32 accept_flags;
__u32 cancel_flags;
__u32 open_flags;
__u32 statx_flags;
__u32 fadvise_advice;
};
__u64 user_data; /* 用户自定义标识,完成时原样返回 */
union {
__u16 buf_index; /* 固定缓冲区索引 */
__u16 buf_group;
};
__u16 personality;
union {
__s32 splice_fd_in;
__u32 file_index;
};
__u64 addr3;
__u64 __pad2[1];
};
// 完成队列条目(CQE)—— 内核填写,用户消费
struct io_uring_cqe {
__u64 user_data; /* 对应 SQE 中的 user_data */
__s32 res; /* 操作结果:返回字节数或错误码 */
__u32 flags; /* 完成标志 */
};
注意 user_data 字段——这是 io_uring 的"回执机制"。你在提交请求时设置一个唯一标识,内核完成 I/O 后原样返回。这意味着你不需要维护任何"请求-响应"的映射关系,内核帮你做到了。
2.4 数据流:一次 I/O 操作的完整生命周期
以一次 read 操作为例:
1. 用户态:准备 SQE
┌─────────────────────────────┐
│ sqe->opcode = IORING_OP_READ│
│ sqe->fd = client_fd │
│ sqe->addr = buffer │
│ sqe->len = buffer_size │
│ sqe->user_data = req_id │
└──────────┬──────────────────┘
│ 写入 SQ
▼
2. 用户态:通知内核(可选,SQPOLL 模式下不需要)
io_uring_enter(fd, 1, 0, 0, NULL)
│
▼
3. 内核态:从 SQ 取出 SQE,执行 I/O 操作
┌─────────────────────────────┐
│ vfs_read() → 文件系统 → 磁盘 │
│ 或 │
│ sock_recvmsg() → 网络栈 │
└──────────┬──────────────────┘
│ I/O 完成
▼
4. 内核态:写入 CQE
┌─────────────────────────────┐
│ cqe->user_data = req_id │
│ cqe->res = bytes_read │
│ cqe->flags = 0 │
└──────────┬──────────────────┘
│ 写入 CQ
▼
5. 用户态:从 CQ 取出 CQE,处理结果
if (cqe->res < 0) {
// 错误处理
} else {
// 处理读取到的数据
}
整个过程的关键:用户态和内核态之间没有数据拷贝,只有索引的传递。
三、代码实战:从 liburing 到裸系统调用
3.1 用 liburing 快速上手
liburing 是 io_uring 的官方封装库,隐藏了 mmap、环形队列管理等复杂性:
#include <liburing.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#define QUEUE_DEPTH 4
int main(int argc, char *argv[]) {
struct io_uring ring;
int fd, ret;
// 1. 初始化 io_uring
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. 打开文件
fd = open(argv[1], O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
// 3. 准备并提交读请求
char buffers[QUEUE_DEPTH][4096];
struct iovec iov[QUEUE_DEPTH];
for (int i = 0; i < QUEUE_DEPTH; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) break;
iov[i].iov_base = buffers[i];
iov[i].iov_len = sizeof(buffers[i]);
io_uring_prep_readv(sqe, fd, &iov[i], 1, i * 4096);
io_uring_sqe_set_data(sqe, (void*)(long)i); // 设置请求标识
}
// 4. 提交所有请求
ret = io_uring_submit(&ring);
if (ret < 0) {
fprintf(stderr, "io_uring_submit: %s\n", strerror(-ret));
return 1;
}
// 5. 等待完成
for (int i = 0; i < QUEUE_DEPTH; i++) {
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));
continue;
}
int req_id = (int)(long)io_uring_cqe_get_data(cqe);
printf("Request %d: read %d bytes\n", req_id, cqe->res);
io_uring_cqe_seen(&ring, cqe); // 标记为已处理
}
// 6. 清理
close(fd);
io_uring_queue_exit(&ring);
return 0;
}
编译运行:
gcc -o io_uring_read io_uring_read.c -luring
./io_uring_read /tmp/testfile
3.2 网络服务:用 io_uring 替代 epoll
这是 io_uring 最令人兴奋的应用场景——用统一的异步接口处理网络 I/O:
#include <liburing.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdlib.h>
#define QUEUE_DEPTH 256
#define BUFFER_SIZE 4096
#define MAX_CONNECTIONS 1024
// 连接状态
struct conn_state {
int fd;
char read_buf[BUFFER_SIZE];
char write_buf[BUFFER_SIZE];
int write_len;
enum {
CONN_ACCEPTING,
CONN_READING,
CONN_WRITING,
CONN_CLOSING
} state;
};
static struct io_uring ring;
static struct conn_state conns[MAX_CONNECTIONS];
// 提交 accept 请求
static void submit_accept(int listen_fd) {
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, CONN_ACCEPTING);
}
// 提交 read 请求
static void submit_read(int conn_idx) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct conn_state *cs = &conns[conn_idx];
io_uring_prep_recv(sqe, cs->fd, cs->read_buf, BUFFER_SIZE, 0);
io_uring_sqe_set_data64(sqe, conn_idx);
cs->state = CONN_READING;
}
// 提交 write 请求
static void submit_write(int conn_idx, int len) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct conn_state *cs = &conns[conn_idx];
cs->write_len = len;
io_uring_prep_send(sqe, cs->fd, cs->write_buf, len, 0);
io_uring_sqe_set_data64(sqe, conn_idx | 0x10000); // 高位标记写操作
cs->state = CONN_WRITING;
}
// Echo 服务器主循环
int main() {
int listen_fd;
struct sockaddr_in addr;
// 创建监听 socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8080);
bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(listen_fd, 128);
// 初始化 io_uring
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
// 提交初始 accept
submit_accept(listen_fd);
printf("Echo server listening on :8080 (io_uring mode)\n");
// 事件循环
while (1) {
struct io_uring_cqe *cqe;
int ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) continue;
int user_data = io_uring_cqe_get_data64(cqe);
int res = cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (res < 0) {
// 错误处理
if (user_data == CONN_ACCEPTING) {
submit_accept(listen_fd); // 重新提交 accept
}
continue;
}
if (user_data == CONN_ACCEPTING) {
// 新连接到达
int conn_fd = res;
int idx = -1;
for (int i = 0; i < MAX_CONNECTIONS; i++) {
if (conns[i].fd == 0) { idx = i; break; }
}
if (idx >= 0) {
conns[idx].fd = conn_fd;
submit_read(idx);
} else {
close(conn_fd); // 连接数已满
}
submit_accept(listen_fd); // 继续接受新连接
} else if (user_data & 0x10000) {
// 写操作完成
int conn_idx = user_data & 0xFFFF;
struct conn_state *cs = &conns[conn_idx];
if (res == cs->write_len) {
submit_read(conn_idx); // 继续读
} else {
// 写不完整,继续写
memmove(cs->write_buf, cs->write_buf + res,
cs->write_len - res);
cs->write_len -= res;
submit_write(conn_idx, cs->write_len);
}
} else {
// 读操作完成
int conn_idx = user_data;
struct conn_state *cs = &conns[conn_idx];
if (res == 0) {
// 连接关闭
close(cs->fd);
memset(cs, 0, sizeof(*cs));
} else {
// Echo:把读到的数据写回去
memcpy(cs->write_buf, cs->read_buf, res);
submit_write(conn_idx, res);
}
}
}
io_uring_queue_exit(&ring);
return 0;
}
注意这个代码的核心优势:
- 没有 epoll:不再需要 epoll_create + epoll_ctl + epoll_wait
- 没有线程池:所有 I/O 操作都是真正的异步
- 统一接口:accept、recv、send 全都用 io_uring 的 SQE/CQE 机制
- 批量提交:可以在一次 io_uring_submit 中提交多个操作
3.3 高级特性:Multishot Accept
Linux 6.0 引入了一个杀手级特性——Multishot Accept。传统模式下,每次 accept 完成后需要重新提交一个新的 accept 请求;而 Multishot Accept 只需提交一次,内核就会持续为你接收新连接:
// 传统模式:每次 accept 后需要重新提交
static void submit_accept(int listen_fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, listen_fd, ...);
io_uring_sqe_set_data64(sqe, CONN_ACCEPTING);
}
// 每次 CQE 返回后都要调 submit_accept()
// Multishot 模式:一次提交,持续生效
static void submit_multishot_accept(int listen_fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
io_uring_sqe_set_data64(sqe, CONN_ACCEPTING);
}
// 一次调用,后续每个新连接都会产生一个 CQE
Multishot 不止 accept,还支持:
- IORING_OP_READ_MULTISHOT:持续读取,每次有数据就产生 CQE
- IORING_OP_ACCEPT_MULTISHOT:持续接受连接
- IORING_POLL_MULTISHOT:持续监听事件
这些特性让"提交一次、持续回调"成为可能,极大减少了 SQE 的提交量。
四、性能深度剖析:io_uring 为什么这么快
4.1 系统调用消除:SQPOLL 模式
传统模式下,每次提交 I/O 请求都需要调用 io_uring_enter() 进入内核态。对于高频 I/O 场景(如 NVMe SSD 的百万 IOPS),系统调用本身就是瓶颈。
io_uring 的 SQPOLL(Submission Queue Polling)模式彻底消除了这个问题:
// 初始化时启用 SQPOLL
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_cpu = 3; // 绑定到 CPU 3
params.sq_thread_idle = 2000; // 空闲 2 秒后休眠
io_uring_setup(256, ¶ms);
SQPOLL 模式下,内核会启动一个内核线程(sq thread),持续轮询 SQ。当用户态写入 SQE 后,sq thread 会自动发现并处理——不需要任何系统调用。
传统模式:
用户写入 SQE → io_uring_enter() → 内核处理 → CQE
SQPOLL 模式:
用户写入 SQE → sq thread 自动发现 → 内核处理 → CQE
^^^^^^^^^^^^^^^^^^^^
无系统调用!内核线程在轮询!
sq thread 在空闲一段时间后会进入休眠(由 sq_thread_idle 控制),此时用户态需要通过 io_uring_enter() 唤醒它。但只要 I/O 请求持续到达,sq thread 就一直在运行,完全零系统调用。
4.2 批量提交:摊薄开销
io_uring 的另一个性能利器是批量提交。你可以在 SQ 中攒一批请求,然后一次 io_uring_submit() 全部提交:
// 批量提交:一次提交多个 I/O 请求
#define BATCH_SIZE 64
void batch_submit_reads(int fd, off_t start, int count) {
// 1. 准备一批 SQE
for (int i = 0; i < count && i < BATCH_SIZE; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buffers[i], BUFFER_SIZE,
start + i * BUFFER_SIZE);
io_uring_sqe_set_data64(sqe, i);
}
// 2. 一次性提交——只产生一次系统调用
int submitted = io_uring_submit(&ring);
printf("Submitted %d requests in one syscall\n", submitted);
}
对比一下:
| 方式 | 提交 1000 个 read 请求 | 系统调用次数 |
|---|---|---|
| 同步 read | 1000 次 read | 1000 |
| epoll + read | 1 次 epoll_wait + N 次 read | 1 + N |
| io_uring(逐个) | 1000 次 io_uring_submit | 1000 |
| io_uring(批量) | 1 次 io_uring_submit | 1 |
| io_uring(SQPOLL) | 0 次 | 0 |
4.3 固定资源注册:减少内核查找开销
每次 I/O 请求都需要内核通过文件描述符查找对应的 file 结构体。对于高频操作,这个查找开销不小。io_uring 允许你提前注册资源:
// 注册固定文件描述符
int fds[MAX_FDS] = {fd1, fd2, fd3, ...};
io_uring_register(ring_fd, IORING_REGISTER_FILES, fds, MAX_FDS);
// 使用固定文件描述符——不再通过 fd 查找
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0 /* 直接用索引而非 fd */, buf, len, off);
sqe->flags |= IOSQE_FIXED_FILE; // 标记使用固定文件
// 注册固定缓冲区
struct iovec iov[BUFFER_COUNT] = {
{ .iov_base = buf0, .iov_len = BUF_SIZE },
{ .iov_base = buf1, .iov_len = BUF_SIZE },
// ...
};
io_uring_register(ring_fd, IORING_REGISTER_BUFFERS, iov, BUFFER_COUNT);
// 使用固定缓冲区
io_uring_prep_read_fixed(sqe, fixed_fd_idx, buf0, BUF_SIZE, off, 0);
注册资源的好处:
- 固定文件:内核直接通过索引访问,跳过 fd → file 的查找
- 固定缓冲区:内核提前 pin 住内存页,避免每次 I/O 都要做 get_user_pages
4.4 零拷贝发送:IOSQE_IO_HARDLINK 与 sendmsg_zc
Linux 6.1 引入了 IORING_OP_SENDMSG_ZC(Zero-Copy Sendmsg),让数据直接从用户缓冲区发送到网卡,不经过内核缓冲区:
// 零拷贝发送
struct msghdr msg = {
.msg_iov = &iov,
.msg_iovlen = 1,
};
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_sendmsg_zc(sqe, fd, &msg, 0);
io_uring_sqe_set_data64(sqe, req_id);
// 零拷贝会产生两个 CQE:
// 1. IORING_CQE_F_NOTIF 表示数据已发送完成
// 2. IORING_CQE_F_ZC_NOTIFY 表示网卡确认数据已刷出
这对高性能网络服务(如 HTTP 服务器、消息队列)意义重大——大文件发送时避免了内核态的内存拷贝。
4.5 性能基准测试:io_uring vs epoll vs AIO
以下是在 NVMe SSD 上的 4K 随机读 IOPS 对比(libaio 的官方基准数据):
| 方式 | IOPS | CPU 利用率 | 系统调用次数/s |
|---|---|---|---|
| 同步 read | ~800K | 100% | 800K |
| libaio (O_DIRECT) | ~1.5M | 85% | 300K |
| io_uring (默认) | ~2.0M | 60% | 100K |
| io_uring (SQPOLL) | ~2.5M | 45% | 0 |
| io_uring (SQPOLL + fixed) | ~2.8M | 40% | 0 |
网络吞吐量对比(Echo Server,单核):
| 方式 | 请求/秒 | P99 延迟 |
|---|---|---|
| epoll (ET) | ~350K | 120μs |
| io_uring (默认) | ~520K | 85μs |
| io_uring (SQPOLL) | ~680K | 55μs |
| io_uring (SQPOLL + multishot) | ~750K | 42μs |
五、生产级优化策略
5.1 CQ 批量收割:减少 io_uring_wait_cqe 的调用
// ❌ 低效:逐个等待 CQE
while (1) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
process_cqe(cqe);
io_uring_cqe_seen(&ring, cqe);
}
// ✅ 高效:批量收割 CQE
while (1) {
struct io_uring_cqe *cqes[BATCH_SIZE];
// 1. 先尝试非阻塞地收割已完成的 CQE
int count = io_uring_peek_batch_cqe(&ring, cqes, BATCH_SIZE);
if (count == 0) {
// 2. 没有已完成的,等待至少一个
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
cqes[0] = cqe;
count = 1;
// 3. 可能还有更多已完成的,继续收割
count += io_uring_peek_batch_cqe(&ring, cqes + 1,
BATCH_SIZE - 1);
}
// 4. 批量处理
for (int i = 0; i < count; i++) {
process_cqe(cqes[i]);
}
// 5. 批量标记已处理
io_uring_cq_advance(&ring, count);
}
5.2 合理选择 Polling 策略
io_uring 有三种 Polling 模式,适用于不同场景:
┌──────────────────────────────────────────────────┐
│ 场景 │ 推荐 Polling 模式 │
├──────────────────────────────────────────────────┤
│ 低延迟 NVMe 存储 │ IOPOLL + SQPOLL │
│ 高吞吐网络服务 │ SQPOLL │
│ 通用混合 I/O │ 默认(中断模式) │
│ 低功耗场景 │ 默认 + 适当 cq_entries │
└──────────────────────────────────────────────────┘
// IOPOLL:内核主动轮询 I/O 完成状态(适用于 NVMe 等高速设备)
struct io_uring_params params = {0};
params.flags = IORING_SETUP_IOPOLL;
io_uring_setup(256, ¶ms);
// SQPOLL:内核线程轮询提交队列
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // ms
// 两者结合:极致性能
params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_IOPOLL;
5.3 缓冲区自动选择:Provided Buffers
在网络服务中,每个连接需要独立的读缓冲区。传统方式是预先分配或动态分配,io_uring 提供了更优雅的方案——Provided Buffers:
// 1. 注册缓冲区池
struct io_uring_probe *probe = io_uring_get_probe_ring(&ring);
// 检查是否支持 IORING_OP_PROVIDE_BUFFERS
#define BUF_GROUP 1
#define BUF_SIZE 4096
#define BUF_COUNT 256
// 注册缓冲区
for (int i = 0; i < BUF_COUNT; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_provide_buffers(sqe, buffers[i], BUF_SIZE,
1, BUF_GROUP, i);
}
io_uring_submit(&ring);
// 2. 提交 recv 请求时,指定缓冲区组
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, fd, NULL, 0, 0); // 不指定缓冲区!
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = BUF_GROUP;
// 3. 从 CQE 中获取内核选择的缓冲区 ID
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int buf_id = cqe->flags >> 16; // IOSQE_BUFFER_SELECT 的结果
char *data = buffers[buf_id];
int len = cqe->res;
// 4. 处理完毕后归还缓冲区
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_provide_buffers(sqe, buffers[buf_id], BUF_SIZE,
1, BUF_GROUP, buf_id);
io_uring_submit(&ring);
这个机制的美妙之处在于:内核在数据到达时自动从缓冲区池中选择一个,你不需要预分配 连接数 × 缓冲区大小 的内存。
5.4 超时与取消:优雅的错误处理
// 提交一个带超时的读请求
struct __kernel_timespec ts = {
.tv_sec = 5,
.tv_nsec = 0,
};
// 方法一:使用 LINK_TIMEOUT(推荐)
struct io_uring_sqe *read_sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(read_sqe, fd, buf, buf_size, 0);
read_sqe->flags |= IOSQE_IO_LINK; // 链接下一个 SQE
io_uring_sqe_set_data64(read_sqe, REQ_READ);
struct io_uring_sqe *timeout_sqe = io_uring_get_sqe(&ring);
io_uring_prep_link_timeout(timeout_sqe, &ts, 0);
io_uring_sqe_set_data64(timeout_sqe, REQ_TIMEOUT);
io_uring_submit(&ring);
// 如果 5 秒内读完成,timeout 自动取消
// 如果 5 秒超时,read 自动取消,CQE 中 res = -ECANCELED
// 方法二:手动取消
struct io_uring_sqe *cancel_sqe = io_uring_get_sqe(&ring);
io_uring_prep_cancel64(cancel_sqe, req_id_to_cancel, 0);
io_uring_submit(&ring);
5.5 连接链(IOSQE_IO_LINK):保证操作顺序
io_uring 默认不保证请求的执行顺序——这正是异步的本质。但有些场景需要顺序保证(比如先 write 再 fsync):
// write → fsync 顺序执行
struct io_uring_sqe *write_sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(write_sqe, fd, data, len, offset);
write_sqe->flags |= IOSQE_IO_LINK; // ← 链接下一个操作
io_uring_sqe_set_data64(write_sqe, REQ_WRITE);
struct io_uring_sqe *fsync_sqe = io_uring_get_sqe(&ring);
io_uring_prep_fsync(fsync_sqe, fd, IORING_FSYNC_DATASYNC);
io_uring_sqe_set_data64(fsync_sqe, REQ_FSYNC);
io_uring_submit(&ring);
// fsync 一定在 write 完成后执行
// 如果 write 失败,fsync 不会执行,直接返回 -ECANCELED
5.6 CQE 溢出处理
当 CQ 队列满了但内核仍有完成事件时,会发生 CQE 溢出。这是生产环境最容易踩的坑:
// 检测溢出
if (io_uring_cq_ready(&ring) == 0 &&
(*ring.cq.koverflow > 0 ||
io_uring_cq_ready(&ring) > 0)) {
// 处理溢出
printf("CQ overflow detected: %u events dropped\n",
*ring.cq.koverflow);
}
// 预防措施:
// 1. 给 CQ 分配足够大的空间(通常 SQ 深度的 2-4 倍)
struct io_uring_params params = {0};
params.cq_entries = QUEUE_DEPTH * 4; // CQ 是 SQ 的 4 倍
params.flags |= IORING_SETUP_CQSIZE;
// 2. 及时收割 CQE,不要积压
// 3. 使用 IORING_SETUP_COOP_TASKRUN 减少内核中断风暴
六、生态现状与框架集成
6.1 Rust:tokio-uring
Rust 生态对 io_uring 的支持最为活跃。tokio-uring 基于 io_uring 提供了完全异步的运行时:
use tokio_uring::{start, net::TcpListener};
use tokio_uring::buf::IoBuf;
async fn echo_server() -> std::io::Result<()> {
let listener = TcpListener::bind("0.0.0.0:8080")?;
loop {
let (stream, addr) = listener.accept().await?;
println!("New connection from {}", addr);
tokio_uring::spawn(async move {
let mut stream = stream;
loop {
// 使用 io_uring 的 provided buffers
let buf = vec![0u8; 4096];
let (res, buf) = stream.read(buf).await;
match res {
Ok(n) if n > 0 => {
let (res, _) = stream.write_all(buf.slice(..n)).await;
if res.is_err() { break; }
}
_ => break,
}
}
});
}
}
fn main() {
start(echo_server()).unwrap();
}
6.2 Go:golang.org/x/sys/unix
Go 标准库的 net 包使用 epoll,但你可以通过 golang.org/x/sys/unix 直接使用 io_uring 系统调用:
package main
import (
"fmt"
"golang.org/x/sys/unix"
)
func initIOUring(entries uint32) (int, *unix.IoUringParams, error) {
params := &unix.IoUringParams{}
fd, err := unix.IoUringSetup(entries, params)
if err != nil {
return -1, nil, err
}
return fd, params, nil
}
func main() {
fd, params, err := initIOUring(256)
if err != nil {
panic(err)
}
defer unix.Close(fd)
fmt.Printf("io_uring fd=%d, features=0x%x\n", fd, params.Features)
}
6.3 Nginx:ngx_http_iouring_module
Nginx 社区已经开发了 io_uring 模块,可以在不改动 Nginx 架构的情况下用 io_uring 替代 epoll 处理文件 I/O:
# nginx.conf
events {
io_uring on; # 启用 io_uring
io_uring_sq_entries 512; # SQ 深度
io_uring_cq_entries 2048; # CQ 深度
io_uring_thread_poll on; # SQPOLL 模式
}
6.4 数据库:RocksDB 与 QEMU
- RocksDB 从 7.x 版本开始支持 io_uring 作为 PosixEnv 的替代后端,在 NVMe SSD 上可获得 15-30% 的吞吐提升。
- QEMU 使用 io_uring 替代 Linux AIO 作为 virtio-blk 的 I/O 引擎,减少 guest I/O 延迟。
七、踩坑实录:生产环境的血泪教训
7.1 内核版本选择
io_uring 在不同内核版本上的功能差异巨大:
| 内核版本 | 关键特性 | 生产推荐 |
|---|---|---|
| 5.1 | 基础 io_uring(文件读写) | ❌ 不推荐 |
| 5.5 | 网络操作支持(send/recv) | ⚠️ 勉强可用 |
| 5.6 | 提供缓冲区、超时、取消 | ✅ 最低推荐版本 |
| 5.11 | SQPOLL + IOPOLL 改进 | ✅ 稳定 |
| 5.19 | CQE 批量收割优化 | ✅ 推荐 |
| 6.0 | Multishot Accept | ✅ 推荐 |
| 6.1 | 零拷贝发送 | ✅ 强烈推荐 |
| 6.6+ | 稳定 + 大量性能优化 | ✅✅ 最佳选择 |
教训一:不要在 5.x 内核上使用 io_uring 做生产级网络服务。至少 5.10 LTS,推荐 6.1+。
7.2 CQ 队列溢出导致数据丢失
这是最常见的问题。当你的 CQ 队列太小而 I/O 完成速度太快时,CQE 会被丢弃:
[ 123.456] io_uring: CQ ring overflow, 42 CQEs dropped
解决方案:
- CQ 队列至少是 SQ 的 2 倍
- 及时收割 CQE(不要在业务逻辑中做重计算)
- 使用
IORING_SETUP_COOP_TASKRUN减少中断风暴
7.3 SQPOLL 的 CPU 亲和性问题
SQPOLL 的内核线程绑定到指定 CPU,如果你的进程也在同一个 CPU 上运行,会导致 CPU 争抢:
// ❌ 错误:sq_thread_cpu 和进程运行在同一个 CPU
params.sq_thread_cpu = 0; // 和主线程争 CPU 0
// ✅ 正确:把 sq_thread 放到专用 CPU
params.sq_thread_cpu = 2; // 主线程在 CPU 0-1,sq_thread 在 CPU 2
7.4 与信号处理的冲突
io_uring 的信号通知机制与传统 signal 有冲突。如果你的程序依赖 SIGIO/SIGUSR1 等信号:
// 使用 IORING_SETUP_SQPOLL 时,sq_thread 不响应信号
// 需要改用 io_uring 的信号通知机制
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_poll_add(sqe, signal_fd, POLLIN);
// 用 signalfd 把信号转换为 fd 事件
八、未来展望:io_uring 的演进方向
8.1 io_uring 在内核中的持续进化
io_uring 仍然是 Linux 内核最活跃的子系统之一,每个版本都有新特性合入:
- 6.6+:io_uring 支持
IORING_OP_WAITID(等待子进程)、IORING_OP_FUTEX_WAIT(futex 异步化),让进程管理也能走 io_uring - 6.8+:
IORING_OP_READ_BUNDLE——批量读优化,把多个小 read 合并为一次大 read - 6.12+:
IORING_OP_SENDMSG_ZC稳定化,支持更多零拷贝场景 - 未来:io_uring + eBPF 融合,允许在内核中用 eBPF 程序处理 I/O 完成事件
8.2 标准化趋势
io_uring 正在成为 Linux 异步 I/O 的事实标准:
- glibc 已经在 2.36+ 中添加了 io_uring 的底层支持
- FreeBSD 社区正在开发兼容层
- 语言运行时(Rust、Go、Python)都在积极集成
8.3 对开发者的启示
io_uring 不仅仅是一个新的 I/O 接口,它代表了一种新的编程模型:
- 从"系统调用驱动"到"共享内存驱动":I/O 请求不再通过系统调用传递,而是通过共享内存队列
- 从"通知+同步"到"提交+完成":不再需要"通知可读→同步读取"的两步走,而是一步到位
- 从"文件和网络分离"到"统一异步接口":epoll 处理网络、AIO 处理文件的历史结束了
九、总结
io_uring 是 Linux 内核近十年来最革命性的 I/O 子系统。它用共享内存 + 环形队列 + 批量提交的三板斧,从根本上解决了 Linux 异步 I/O 的历史问题:
| 维度 | epoll + 线程池 | io_uring |
|---|---|---|
| 系统调用 | 高频(epoll_wait + read/write) | 零(SQPOLL 模式) |
| 内存拷贝 | 有(内核↔用户态数据拷贝) | 最小化(共享内存 + 零拷贝发送) |
| 文件 I/O | 不支持异步 | 原生支持 |
| 网络 I/O | 通知+同步读取 | 真正异步 |
| 接口统一 | 否(epoll + AIO 缝合) | 是(统一 SQE/CQE) |
| 延迟 | 高(系统调用 + 两步走) | 低(零系统调用 + 一步到位) |
如果你在 2026 年还在用 epoll + 线程池做高性能 I/O,是时候认真考虑 io_uring 了。内核 6.6+ 已经足够稳定,liburing 的 API 也足够成熟。从 Echo Server 开始,逐步替换你的 I/O 热点路径——你的 CPU 和延迟指标会感谢你。
参考资源:
- io_uring 官方文档 — Jens Axboe 的原始设计文档
- liburing GitHub — 官方用户态库
- io_uring 参考手册
- LWN io_uring 文章合集
- Linux 内核源码 io_uring.c