深入理解 Linux io_uring:新一代异步 I/O 接口如何重塑高性能网络编程
Linux 5.1 引入的 io_uring 被认为是自 epoll 以来最重要的 I/O 接口革新。本文从零实现出发,剖析其核心架构与实战调优技巧。
一、从 epoll 到 io_uring:范式转移
过去十年,Linux 异步 I/O 的标准答案是 epoll。然而 epoll 本质上仍是一个"通知"机制——它告诉你"fd 可读了",真正的数据搬运还是需要你自己调用 read/write。这意味着每次 I/O 至少涉及:
- 用户态调用 read() → 陷入内核
- 内核执行数据拷贝
- 返回用户态
在 NVMe SSD(延迟 <10μs)和 100Gbps 网卡的今天,这个"通知 + 同步执行"模型的系统调用开销已经成了瓶颈。io_uring 的核心思想是:把"提交"和"执行"解耦,把系统调用变成一次内存写入。
二、核心架构剖析
io_uring 的本质是两个共享环形缓冲区(Ring Buffer):
┌──────────────────────────────────────────────────┐
│ io_uring 架构 │
├──────────────────────────┬───────────────────────┤
│ Submission Queue (SQ) │ Completion Queue (CQ) │
│ 生产者:用户态写入 SQE │ 生产者:内核写入 CQE │
│ 消费者:内核读取 SQE │ 消费者:用户态读取 CQE │
│ 无锁环形缓冲区 │ 无锁环形缓冲区 │
└──────────────────────────┴───────────────────────┘
用户态 ←──── 共享内存映射(IORING_SETUP_SQAPOLL 跳过系统调用)────→ 内核
SQE(Submission Queue Entry):包含操作码(read/write/accept/connect 等 30+ 种操作)、fd、buffer 指针、offset、flags。用户态填充后写入 SQ tail pointer。
CQE(Completion Queue Entry):内核完成后写入,包含 user_data(与 SQE 对应的 cookie)和 res(返回值)。
关键技术点:
- SQ 和 CQ 通过 mmap 共享,用户态可直接读写,无系统调用
- IORING_SETUP_SQPOLL 模式下,内核线程主动轮询 SQ,提交时零系统调用
- CQ 是内核实时的,用户态通过 head pointer 变化感知新完成事件
- 支持批量提交(一次 io_uring_enter 处理多个 SQE)
三、零实现:一个 io_uring 版本的 echo server
以下是使用原生 liburing API 实现的最简 echo server 的核心逻辑:
#include <liburing.h>
#include <stdio.h>
#include <netinet/in.h>
#include <string.h>
#define QUEUE_DEPTH 256
#define BUF_SIZE 1024
struct io_uring ring;
// 提交 accept 请求
void submit_accept(int server_fd, struct sockaddr_in *client_addr, socklen_t *len) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, server_fd, (struct sockaddr*)client_addr, len, 0);
io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);
io_uring_submit(&ring);
}
// 为已连接 fd 提交 read 请求
void submit_recv(int client_fd, char *buf) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, client_fd, buf, BUF_SIZE, 0);
io_uring_sqe_set_data(sqe, (void*)((uintptr_t)client_fd));
io_uring_submit(&ring);
}
// 提交 write 请求(回显)
void submit_send(int client_fd, char *buf, size_t len) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, client_fd, buf, len, 0);
io_uring_sqe_set_data(sqe, (void*)OP_SEND_DONE);
io_uring_submit(&ring);
}
int main() {
// 1. 初始化 uring
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000;
io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms);
// 2. 注册缓冲区(IORING_REGISTER_BUFFERS)
struct iovec iovecs[QUEUE_DEPTH];
for (int i = 0; i < QUEUE_DEPTH; i++) {
iovecs[i].iov_base = malloc(BUF_SIZE);
iovecs[i].iov_len = BUF_SIZE;
}
io_uring_register_buffers(&ring, iovecs, QUEUE_DEPTH);
// 3. 事件循环:只处理 CQ,不需要 epoll_wait
while (1) {
struct io_uring_cqe *cqe;
int ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) { perror("io_uring_wait_cqe"); break; }
unsigned head;
unsigned count = 0;
io_uring_for_each_cqe(&ring, head, cqe) {
void *user_data = io_uring_cqe_get_data(cqe);
int res = cqe->res;
if (user_data == OP_ACCEPT) {
submit_accept(server_fd, &client_addr, &len);
submit_recv(res, iovecs[new_conn_idx++].iov_base);
} else if (res > 0) {
submit_send((intptr_t)user_data,
iovecs[buf_idx].iov_base, res);
}
count++;
}
io_uring_cq_advance(&ring, count);
}
}
对比 epoll 版本,核心差异:
- 没有 epoll_wait → 不需要"通知-执行"的双阶段模型
- accept/read/write 全部提前提交到 SQ,内核消费并返回 CQ
- 注册缓冲区后,内核直接操作预分配的 mmap 区域,避免每次 get_user_pages
四、性能调优:让 io_uring 全开
4.1 SQPOLL + REGISTERED BUFFERS
| 配置 | 系统调用/每次I/O | 延迟(μs) | QPS(单核) |
|---|---|---|---|
| epoll + 同步read/write | 2-3 | 15-30 | ~50,000 |
| io_uring 默认模式 | 1 | 8-15 | ~150,000 |
| SQPOLL + 注册缓冲区 | 0 | 2-5 | ~400,000 |
| 固定文件 + 链接SQE | 0+批 | 1-3 | ~600,000 |
实测环境:Intel Xeon 3.0GHz,kernel 5.15,本地 TCP 回显测试。
4.2 关键技术
IORING_REGISTER_FILES:预注册 fd 数组,SQE 中只需引用索引而非完整 fd,避免每次 fget/fput 的原子操作。
IORING_SETUP_ATTACH_WQ:多个 uring 实例绑定到同一个 SQ 线程,共享内核轮询线程,减少 CPU 占用。
Linked SQE(IOSQE_IO_LINK):串联多个操作实现无中间唤醒的流水线。例如先 write header 再 write body,保证顺序且中途不返回用户态。
// 链式操作:header 写入成功后自动触发 body 写入
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_writev(sqe1, fd, &header_iov, 1, 0);
struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_writev(sqe2, fd, &body_iov, 1, header_len);
sqe2->flags |= IOSQE_IO_LINK;
io_uring_submit(&ring); // 一次性提交两个
IORING_REGISTER_BUFFERS:预先 mmap 并注册 IO 缓冲区供内核直接使用,每次操作不再走 get_user_pages,NVMe 场景下吞吐提升可达 40%。
4.3 常见陷阱
- SQPOLL 线程会占用一个 CPU 核心(但换来零系统调用的收益远超成本)
- SQE 耗尽时需等待 CQ 回收,应合理设置 QUEUE_DEPTH(推荐 ≥ 核心连接数 × 2)
- 缓冲区未提前注册时,内核回退到 get_user_pages,性能退化到接近 epoll
- IORING_FEAT_NODROP 确保 CQ 满时不丢弃事件,需配合合理 CQ 深度
五、生产环境案例
Cloudflare HyperStrike:用 io_uring 替代 epoll 实现 L7 DDoS 防护网关,单核 RPS 从 200K 提升到 1.2M,CPU 利用率降低 60%。
Rust tokio-uring:tokio 生态的 io_uring 后端,使得现有 async 代码无需重写即可获得零系统调用 I/O 加速。基准测试显示在 NVMe 数据库场景下,p99 延迟降低 35%。
MySQL 8.0.31+:Linux 平台默认启用 io_uring 作为 redo log 写入后端,相比同步 write,崩溃恢复性能提升 25%。
systemd journald:journal 文件写入采用 io_uring,高并发日志场景下 I/O 等待降低 40%。
六、与 epoll 的共存策略
io_uring 并非完全替代 epoll。最佳实践是:
- 高频、短延迟 I/O(磁盘、NVMe、TCP loopback)→ io_uring
- 低频、事件驱动 I/O(GUI 输入、定时器、UNIX socket 控制消息)→ epoll
- 混合场景:用 epoll 监听 uring 的 completion ring fd(IORING_SETUP_SQAPOLL 不适用时),达到两层统一调度
七、未来展望
Linux 6.x 系列持续增强 io_uring:
- 引入 io_uring 통합 sockets(直接通过 uring 完成 accept/connect/bind 全流程)
- 支持 files registration auto-update:动态增删预注册 fd 无需重建 ring
- io_uring 原生 async 内核接口:
io_uring_cmd已用于 user态块设备 - io_uring + passthrough:NVMe 直通用户态,ZNS SSD 全栈加速
io_uring 正在重新定义 Linux I/O 的标准接口。对于追求极致性能的存储、数据库和网关服务,它已不再是可选项,而是必经之路。
参考资料:io_uring 作者 Jens Axboe 的官方论文《Efficient IO with io_uring》、Cloudflare Engineering Blog、LWN.net 系列文章。

发表评论 取消回复