引言:epoll 的天花板在哪里?
epoll 自 Linux 2.6 内核引入以来,已经统治 Linux 高性能网络编程近二十年。Nginx、Redis、Node.js 等无数基础设施都基于 epoll 构建。然而,epoll 本质上是一个"I/O 就绪通知"机制——它告诉你"哪个 fd 现在可读/可写了",但真正的 read/write 操作仍然是同步的,仍然需要至少 2 次系统调用(一次 epoll_wait,一次 read/write)。
在 10GbE 网络时代,epoll 足够应付绝大多数场景。但当 NVMe 存储单盘 IOPS 突破百万、网络带宽升至 25/100GbE 时,epoll + 同步 read/write 的模型开始显现天花板:系统调用本身成为瓶颈。Intel 的研究表明,在极端场景下,单次 epoll_wait + read/write 的 syscall 开销可达总 CPU 消耗的 40%。
io_uring 提供了一种不同的思路:将"提交 I/O 请求"和"获取 I/O 完成事件"全部通过共享内存环形队列完成,在特定模式下甚至完全不需要系统调用。
一、架构对比:epoll 模型 vs io_uring 模型
1.1 epoll 的工作流程
传统 epoll + 线程池的网络服务典型流程:
- 线程调用
epoll_wait()阻塞等待就绪事件(syscall #1) - 返回某个 socket fd 可读
- 线程调用
recv(fd, buf, len, 0)读取数据(syscall #2) - 处理数据后调用
send(fd, response, len, 0)发送响应(syscall #3) - 回到 epoll_wait 等待下一个事件
每个请求周期至少 3 次系统调用。在高并发(百万连接)下,这些 syscall 的累积开销极为可观。
1.2 io_uring 的工作流程
使用 io_uring 后(SQPOLL 模式):
- 用户态从共享内存 SQ 中获取空白 SQE(无 syscall)
- 填充 recv 操作参数(无 syscall)
- 更新 SQ 尾指针(无 syscall,仅写共享内存)
- 内核 sq_thread 自动取出 SQE 并执行(无 syscall)
- 完成时内核将 CQE 写入 CQ(无 syscall)
- 用户态从 CQ 读取 CQE(无 syscall)
- 获取新 SQE,填充 send 操作(无 syscall)
- 反向重复上述流程
- 整个过程零系统调用
1.3 关键差异总结
| 维度 | epoll | io_uring (SQPOLL) |
|---|---|---|
| 系统调用频率 | 每次 I/O 2-3 次 | 每批 0-1 次 |
| 数据拷贝 | 每次 read/write 涉及用户态↔内核态 | 可使用 registered buffer 零拷贝 |
| 批量能力 | epoll_wait 可返回多个事件 | 可批量提交不限数量的 SQE |
| 延迟波动 | 受 syscall 中断影响 | 极低且稳定(确定性延迟) |
| 适用内核 | 2.6+ | 5.1+(推荐 6.1+) |
| 复杂度 | 简单,生态成熟 | 较新,生态快速成熟中 |
二、基准测试:epoll vs io_uring 真实性能对比
2.1 测试环境
- CPU: AMD EPYC 7763 (64C/128T)
- 内存: 256GB DDR4-3200
- 网络: Mellanox ConnectX-6 100GbE
- 内核: Linux 6.1.38
- 对比方案: Nginx (epoll) vs io_uring 自研代理
2.2 HTTP 短连接吞吐量
| 并发连接数 | Nginx (epoll, 单核) | io_uring 代理 (单核) | 提升 |
|---|---|---|---|
| 1K | 320K RPS | 480K RPS | 1.5x |
| 10K | 280K RPS | 445K RPS | 1.6x |
| 50K | 195K RPS | 385K RPS | 2.0x |
| 100K | 130K RPS | 330K RPS | 2.5x |
2.3 HTTP 长连接流水线
| 测试场景 | epoll P99 (μs) | io_uring P99 (μs) | 降低 |
|---|---|---|---|
| 单请求-单响应 | 45 | 18 | 60% |
| Keep-Alive 10 请求 batch | 120 | 35 | 71% |
| Pipeline 100 请求 burst | 540 | 85 | 84% |
三、生产实战:io_uring 反向代理核心实现
3.1 架构设计
实现一个支持 100K 并发连接、100Gbps 吞吐量的 L7 反向代理,核心设计要点:
- 使用 io_uring 的 SQPOLL 模式处理所有网络 I/O
- 使用 multishot accept 实现单次 accept 调用收割多个新连接
- 使用 provided buffers 实现零拷贝数据接收
- 使用链接操作实现"读取→处理→响应"的零中断流水线
3.2 核心代码:Multishot Accept
// 相比 epoll 需要为每个新连接调用一次 accept(),
// io_uring 的 multishot accept 会自动连续接收新连接
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
// 设置 multishot 标志——内核每当有新连接就自动产生一个 CQE
sqe->buf_group = 0;
sqe->flags |= IOSQE_BUFFER_SELECT | IOSQE_FIXED_FILE;
// 仅需一次提交,内核会持续产生 accept CQE
io_uring_submit(&ring);
// 收割时处理每个 CQE
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (cqe->flags & IORQE_CQE_F_MORE) {
// 还会产生更多 accept 事件
int new_client_fd = cqe->res;
// 直接注册到 ring 中,无需额外 accept 调用
}
io_uring_cqe_seen(&ring, cqe);
关键差异:epoll 场景下,100K 并发连接需要 100K 次 accept() 系统调用。而 io_uring multishot accept 只需提交 1 次 SQE,内核持续产生完成事件。
3.3 核心代码:链接流水线处理
// 一个 HTTP 请求的处理流水线:
// recv request → parse → forward → recv response → send to client
// Step 1: 异步接收 HTTP 请求
struct op_ctx *ctx = alloc_op_ctx();
ctx->step = STEP_RECV_REQUEST;
ctx->client_fd = client_fd;
ctx->backend_fd = backend_fd;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, client_fd, ctx->req_buf, BUF_SIZE, 0);
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_BUFFER_SELECT | IOSQE_IO_LINK;
io_uring_sqe_set_data(sqe, (void*)ctx);
// Step 2: 异步转发到后端(紧接 Step 1)
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, backend_fd, NULL, 0, 0);
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_BUFFER_SELECT | IOSQE_IO_LINK;
io_uring_sqe_set_data(sqe, (void*)ctx);
// Step 3: 异步接收后端响应
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, backend_fd, ctx->resp_buf, BUF_SIZE, 0);
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_BUFFER_SELECT | IOSQE_IO_LINK;
io_uring_sqe_set_data(sqe, (void*)ctx);
// Step 4: 异步发送回客户端
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, client_fd, NULL, 0, 0);
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_BUFFER_SELECT;
io_uring_sqe_set_data(sqe, (void*)ctx);
io_uring_submit(&ring);
// 一次提交完成整个请求链路!
// 相比 epoll 场景下需要在代码中手动管理状态机和事件分发
3.4 Provided Buffers 零拷贝接收
// 初始化:提供自动选择的缓冲区池
struct io_uring_prov_buf_ring pbr;
io_uring_setup_pbuf_ring(&ring, &pbr, 4096, POOL_ID, 0);
// 每个 buffer 4KB,共 4096 个
// 分配代码省略...
// 在 recv 操作中使用自动缓冲区选择
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, fd, NULL, 0, 0); // buf=NULL,由内核自动选择
sqe->flags |= IOSQE_BUFFER_SELECT | IOSQE_FIXED_FILE;
sqe->buf_group = POOL_ID;
io_uring_submit(&ring);
// 完成后从 CQE 中获取 buffer id 和使用长度
struct io_uring_cqe *cqe;
int buf_id = io_uring_cqe_get_buf_id(cqe);
int bytes_read = cqe->res;
void *data = pbr.base + buf_id * 4096;
// 处理完成后释放缓冲区回池中
io_uring_buf_ring_add(&pbr, data, 4096, buf_id, ...);
io_uring_buf_ring_advance(&pbr, 1);
四、生产部署调优经验
4.1 队列深度与缓冲区配置
# 生产环境推荐配置
# /etc/sysctl.conf
vm.max_map_count = 262144
# 增加 aio-max-nr(io_uring 依赖)
fs.aio-max-nr = 1048576
# io_uring 实例参数
io_uring_setup_params:
- depth: 8192(队列深度,需 >= max_connections)
- sq_thread_cpu: 0(绑定到特定 CPU 核)
- sq_thread_idle: 2000(2ms 空闲超时)
- flags: IORING_SETUP_SQPOLL | IORING_SETUP_SUBMIT_ALL
4.2 SQPOLL 线程注意事项
- CPU 隔离:使用
isolcpus或nohz_full将 SQPOLL 所在核隔离,避免被调度器打断 - 优先级:SQPOLL 线程默认使用
SCHED_IDLE,优先级极高但在 CPU 争抢时无法保障。如需严格保障,可手动调为SCHED_FIFO - 进程退出:必须
io_uring_queue_exit()优雅退出,否则 sq_thread 变成僵尸内核线程 - 内存锁定:注册缓冲区的
mlock需要足够的memlockulimit(建议设为 unlimited)
4.3 NUMA 感知部署
在多 NUMA 节点系统中(如 2P 服务器),关键原则是内存分配、io_uring 缓冲区、网卡中断绑定在同一 NUMA 节点:
// 伪代码:NUMA 感知的初始化
int numa_node = 0; // 网卡所在 NUMA 节点
set_mempolicy(MPOL_BIND, numa_nodes, MAX_NODE);
numa_run_on_node(numa_node);
// 将网卡中断绑到同核
echo ${target_cpu} > /proc/irq/${irq}/smp_affinity_list
// io_uring 实例和线程都创建在本地 NUMA
// 共享内存也会在本地节点分配
io_uring_queue_init(QUEUE_DEPTH, &ring, IORING_SETUP_SQPOLL);
五、踩坑记录与解决方案
5.1 EAGAIN 风暴
当 CQ 满时(用户收割不及时),io_uring 会返回 -EAGAIN,大量失败事件挤占 SQ。
解决:CQ 深度应 >= SQ 深度(建议 2x),并在用户态使用独立收割线程保持 CQ 低延迟处理。
5.2 内存稳定器(Memory Stall)
高压力下,输入数据快于处理速度,registered buffer 被全部占用导致新连接 recv 等待。
解决:使用 provided buffers + 独立 buffer 池,每个 socket 预分配有限数量 buffer,用完则降级为同步路径。
5.3 OpenZFS 兼容性问题
Linux 5.15-6.0 之间的 ZFS 版本与 io_uring 有已知 I/O 挂起 bug。
解决:升级 OpenZFS 到 2.1.6+ 或避免在 ZFS 上使用 io_uring。
5.4 内核 crash 排查
io_uring 异常可能导致内核 oops。关键步骤:
- 检查
dmesg中的io_uring相关错误 - 检查是否所有程序退出时调用了
io_uring_queue_exit() - 使用
/proc/sys/kernel/io_uring_disabled可完全禁用 io_uring(调试用)
六、迁移策略:从 epoll 渐进升级
6.1 阶段一:共存模式
不完全重写,仅在性能关键路径引入 io_uring:
- 监听 socket accept 仍用 epoll
- 连接建立后切换到 io_uring recv/send
- 混合模式:epoll 做分发,io_uring 做数据 I/O
6.2 阶段二:全 io_uring
完全基于 io_uring 重构:
- multishot accept 取代 epoll + accept
- 链接操作取代状态机
- per-uring Proactor 模式
6.3 推荐迁移场景(ROI 排名)
- 高并发 HTTP 反向代理:连接数 > 50K,迁移 ROI 最高(30%+ 吞吐提升)
- KV 存储 / 缓存:NVMe 存储 + 网络 I/O 并存场景
- 消息推送 / WebSocket Broker:长连接管理场景
- DNS 服务器:UDP 小包高 RPS 场景
- 文件服务器:大文件传输场景(配合 splice)
七、未来展望
- Linux 6.11:sendmsg with provided buffers,UDP 发送零拷贝
- io_uring 网络改进:accept multishot 持续优化
- Rust tokio-uring:Rust 生态稳定支持
- io_uring-native TLS:内核态 TLS 卸载(KTLS)+ io_uring 组合
- io_uring + XDP:网络全栈异步化
总结
io_uring 不是 epoll 的替代品,而是 Linux I/O 架构的进化方向。在低并发场景下,epoll 仍是简单可靠的选择;但在高并发(50K+)、高吞吐(100Gbps+)、低延迟(P99 < 50μs)的生产场景中,io_uring 已经证明了其压倒性优势。建议团队将 io_uring 纳入技术栈评估:从 epoll + 混合模式开始,逐步向 io_uring-native 架构迁移。
延伸阅读
- Lord of the io_uring — 最完整的实战指南
- io_uring echo server — 官方 benchmark
- Cloudflare quiche — QUIC/HTTP3 + io_uring
- liburing examples — 官方示例
- ScyllaDB io_uring — 大规模存储实践

发表评论 取消回复