一、为什么 epoll 不够用了
epoll 诞生于 2002 年(Linux 2.5.44),解决了 select/poll 的 O(n) 遍历问题,成为 Linux 高并发网络编程的事实标准。但在 C10K→C10M 演进、NVMe 存储普及、低延迟服务需求的今天,epoll 暴露了结构性局限:
| 维度 | epoll 模型 | 问题 |
|---|---|---|
| 系统调用开销 | 每次 I/O 需 1 次 syscall(epoll_wait 批量取事件后仍需 read/write) | 高 QPS 下 syscall 开销无法消除 |
| 数据拷贝 | 用户↔内核每次 2 次数据拷贝(read/write) | 零拷贝场景仍需额外 syscall |
| 状态管理 | 用户态自行维护 fd→state 映射 | 复杂场景下状态机与事件循环耦合 |
| 批量能力 | epoll_wait 单次最多返回 maxevents 个事件 | 高并发需多次调用或大 maxevents 内存 |
| 磁盘 I/O | 不支持异步文件 I/O(仅 O_DIRECT 有限方案) | 数据库等场景被迫用线程池模拟异步 |
| 编程模型 | 事件驱动回调为主 | 状态机嵌套,调试困难 |
io_uring(Linux 5.1, 2019)通过共享内存环形队列从根本上改变了 Linux 异步 I/O 的设计哲学。
二、io_uring 核心架构:环形队列设计
2.1 三个核心数据结构
io_uring 设置用户态与内核态共享的环形队列,由三个区域构成:
// 提交队列 — 用户态写、内核态读
struct io_uring_sq {
unsigned *khead; // 内核head指针(由内核更新)
unsigned *ktail; // 内核tail指针(用户态提交后更新)
unsigned *kring_entries; // 队列深度
unsigned *kflags; // 内核标志(如IORING_SQ_NEED_WAKEUP)
unsigned *kdropped; // 丢弃的SQE计数(溢出统计)
struct io_uring_sqe *sqes[SQE_COUNT]; // SQE数组(真正承载操作参数)
};
// 完成队列 — 内核态写、用户态读
struct io_uring_cq {
unsigned *khead; // 内核head
unsigned *ktail; // 内核tail
unsigned *kring_entries;
unsigned *koverflow; // CQE溢出计数
struct io_uring_cqe *cqes[CQE_COUNT]; // CQE数组
};
// 提交队列入口 — 描述一个I/O操作
struct io_uring_sqe {
__u8 opcode; // 操作码:IORING_OP_READV/WRITEV/SEND/RECV/...
__u8 flags; // IOSQE_FIXED_FILE/ASYNC/LINK...
__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; // read/write 标志
__u32 fsync_flags;
__u16 poll_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;
__u32 splice_flags;
};
__u64 user_data; // 用户标识符(CQE中原样返回,用于匹配完成事件)
union {
struct { __u16 buf_index; __u16 buf_group; }; // fixed-buffer支持
__u64 __pad2[3];
};
};
2.2 提交与完成流程
零系统调用提交模式(IORING_SETUP_SQPOLL 或 IORING_SQ_TASKRUN 标志位检测):
- 用户态
io_uring_get_sqe(ring)— 从 SQ 环形缓冲取一个空闲 SQE(纯用户态内存操作,无 syscall) io_uring_prep_readv(sqe, fd, iovec, nr_vecs, offset)— 填充操作参数io_uring_submit(ring)— 更新*sq.ktail(写屏障后自动更新,SQPOLL 模式下内核线程异步消费,无需 enter syscall)- 内核处理完成后将 CQE 写入 CQ 环形缓冲,更新
*cq.ktail - 用户态
io_uring_peek_cqe(ring, &cqe)— 从 CQ 尾部读取完成事件(纯内存操作) io_uring_cqe_seen(ring, cqe)— 更新*sq.khead回收槽位
在 BUSY-POLL 场景(IORING_SETUP_IOPOLL + SQPOLL 全忙模式)下,整个 submit→complete 路径完全无系统调用。
三、IORING_SETUP 参数与调优
| 参数 | 含义 | 推荐场景 |
|---|---|---|
| IORING_SETUP_IOPOLL | 轮询完成(绑定 HW queue depth),要求 fd 已设置 O_DIRECT | NVMe 低延迟 I/O |
| IORING_SETUP_SQPOLL | 内核线程(io-wq 或专门 SQ 线程)主动轮询 SQ,用户免 syscall | 高提交速率吞吐 |
| IORING_SETUP_SQ_AFF | 绑定 SQ 线程到指定 CPU 核 | NUMA 隔离、缓存热路径 |
| IORING_SETUP_CQSIZE | CQ 深度 > SQ depth(避免完成时阻塞) | 高并发快速提交 |
| IORING_SETUP_ATTACH_WQ | 绑定现有 workqueue 给新 ring(减少线程) | 多 ring 共享 worker |
| IORING_SETUP_SUBMIT_ALL | 提交失败时继续处理后续 SQE(部分提交) | 批量操作容错 |
| IORING_SETUP_COOP_TASKRUN | 延迟中断返回前处理 run-flag,减少抢占 | 混合负载降低中断延迟 |
| IORING_SETUP_SINGLE_ISSUER | 声明只单线程提交,减少锁开销 | 专用提交线程场景 |
| IORING_SETUP_DEFER_TASKRUN | 延迟任务运行到 io_uring 进程上下文 | 受信任务优先级高时 |
生产环境推荐组合:IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF | IORING_SETUP_CQSIZE | IORING_SETUP_SUBMIT_ALL
四、核心高级特性
4.1 Fixed Buffers(IORING_REGISTER_BUFFERS)
预注册 1+ iovec 缓冲区,避免每次 read 前 pin_user_pages + 映射。
// 注册 1024 个 8KB 固定缓冲区
struct iovec iovecs[1024];
for (int i = 0; i & 1024; i++) {
iovecs[i].iov_base = aligned_alloc(4096, 8192);
iovecs[i].iov_len = 8192;
}
io_uring_register_buffers(&ring, iovecs, 1024);
// 使用时选 buf_index,避免每次 pin/unpin
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, client_fd, NULL, 0, 0);
sqe->flags |= IOSQE_BUFFER_SELECT; // 自动选可用 buffer(BUFFER_GROUP)
sqe->buf_group = 0; // 指定 group(IORING_REGISTER_BUFFERS 注册的 group 0)
io_uring_submit(&ring);
4.2 Fixed Files(IORING_REGISTER_FILES)
预注册文件描述符数组,sqe->fd 设为 index(index>=0),避免每次 get_unused_fd + fd_install 开销。
// 注册一个 fd table
int fds[32] = { event_fd, listen_fd, log_fd, timerfd, ... };
io_uring_register_files(&ring, fds, 32);
// 使用 fixed file,减少 open/close syscall
sqe->flags |= IOSQE_FIXED_FILE;
sqe->fd = FIXED_FILE_INDEX; // 使用数组下标而非原始 fd
4.3 SQE Link(IOSQE_IO_LINK/IO_HARDLINK)
将多个链接为原子序列:上一操作成功后才调度下一操作,失败则中断整个链。
// 原子链:CONNECT → SEND handshake → WAIT response
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_connect(sqe, sockfd, addr, addrlen);
sqe->flags |= IOSQE_IO_LINK; // 链式
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, sockfd, hello_msg, hello_len, 0);
sqe->flags |= IOSQE_IO_LINK;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, sockfd, resp_buf, resp_len, 0);
// 链尾无需 IO_LINK
io_uring_submit(&ring); // 一次提交全部三个操作
4.4 Multishot Accept/Recv(Linux 5.19+)
一次 prep_multishot_accept 完成,内核每次新连接建立时自动产生 CQE,无需反复提交 accept。
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd, addr, &addrlen, SOCK_NONBLOCK);
sqe->flags |= IOSQE_FIXED_FILE; // 可选:从 fixed-table 分配 fd
io_uring_submit(&ring);
// 每次新连接 → 1 个 CQE(IORING_CQE_F_MORE=0 表示 multishot 终止,需重新提交)
4.5 Buffer Selection(IORING_RECVSEND_FIXED_BUF + BUFFER_SELECT)
CQE 返回后从 cqe->flags >> IORING_CQE_BUFFER_SHIFT 取 buffer_index。
对于 UDP 单 recv 场景等价于一次无拷贝的 read;TCP 流式场景需配合链式逻辑处理半包。
五、生产环境实战场景
5.1 场景一:TCP 高并发服务器(SQPOLL + Multishot)
#define NET_PORT 8080
struct conn {
int fd;
enum { ACCEPT, READ, WRITE_HANDSHAKE, CLOSE } state;
unsigned short buf_idx; // fixed buffer 索引
};
struct io_uring ring;
struct conn pool[MAX_CONN];
__thread int sqe_batch_submitted;
static void submit_accept_multishot(int listen_fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept_direct(sqe, listen_fd, NULL, NULL, 0);
sqe->flags |= IOSQE_FIXED_FILE;
io_uring_submit(&ring);
}
static void handle_cqe_read(struct io_uring_cqe *cqe) {
struct conn *c = (struct conn*)io_uring_cqe_get_data(cqe);
int32_t bytes = cqe->res;
if (bytes <= 0) { close_conn(c); return; }
// 业务处理...
c->state = WRITE_RESPONSE;
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, c->fd, response, response_len, 0);
sqe->flags |= IOSQE_FIXED_FILE;
io_uring_sqe_set_data(sqe, c);
batch_flush_if_needed(&ring, ++sqe_batch_submitted); // 每 32 个一起 flush
}
static void handle_cqe_accept(struct io_uring_cqe *cqe) {
int new_fd = cqe->res;
struct conn *c = alloc_conn(new_fd);
c->state = READ;
c->buf_idx = cqe->flags >> IORING_CQE_BUFFER_SHIFT; // 自动分配 buffer
// 提交首次读(1 SQE)
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, new_fd, NULL, 0, 0);
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = 0;
io_uring_sqe_set_data(sqe, c);
sqe_batch_submitted++;
if (!(cqe->flags & IORING_CQE_F_MORE)) {
// multishot 终止
submit_accept_multishot(listen_fd);
}
}
int main(void) {
struct io_uring_params p = {
.sq_entries = 4096,
.cq_entries = 16384,
.flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF |
IORING_SETUP_CQSIZE | IORING_SETUP_SUBMIT_ALL,
.sq_thread_cpu = SQ_THREAD_CPU_CORE,
.sq_thread_idle = 2000, // idle 2ms 后暂停 SQ 线程
.cq_entries = 16384,
};
io_uring_queue_init_params(SQ_DEPTH, &ring, &p);
// 注册 4096*8K fixed buffers
struct iovec bufs[4096];
for (int i=0;i<4096;i++) { bufs[i].iov_base=aligned_alloc(4096,8192); bufs[i].iov_len=8192; }
io_uring_register_buffers(&ring, bufs, 4096);
// 注册 fixed file table
int fixed_files[MAX_CONN + 4] = {[0 ... MAX_CONN+3] = -1};
fixed_files[0] = listen_fd;
io_uring_register_files(&ring, fixed_files, MAX_CONN+4);
submit_accept_multishot(listen_fd);
struct io_uring_cqe *cqe;
while (1) {
io_uring_wait_cqe(&ring, &cqe);
unsigned head; int count=0;
io_uring_for_each_cqe(&ring, head, cqe) {
count++;
struct conn *c = (struct conn*)io_uring_cqe_get_data(cqe);
switch (c ? c->state : ACCEPT) {
case ACCEPT: handle_cqe_accept(cqe); break;
case READ: handle_cqe_read(cqe); break;
case WRITE_RESPONSE: handle_cqe_write(cqe); break;
}
}
io_uring_cq_advance(&ring, count);
sqe_batch_submitted = 0;
}
}
5.2 场景二:磁盘 I/O 优化(IOPOLL+O_DIRECT)
// 数据库 WAL 写优化示例
#define WAL_BLOCK_SIZE 16384
struct io_uring_params p = {
.sq_entries = 1024,
.flags = IORING_SETUP_IOPOLL | IORING_SETUP_SQPOLL |
IORING_SETUP_SINGLE_ISSUER | IORING_SETUP_SUBMIT_ALL,
.sq_thread_cpu = WAL_WRITER_CORE,
.sq_thread_idle = 1000,
};
io_uring_queue_init_params(1024, &ring, &p);
// 固定 buffer(O_DIRECT 必须对齐)
void *wal_buf = aligned_alloc(4096, WAL_BLOCK_SIZE);
struct iovec iov = { wal_buf, WAL_BLOCK_SIZE };
io_uring_register_buffers(&ring, &iov, 1);
// 提交异步 fsync+write 原子链
static void wal_write(struct io_uring *r, int wal_fd,
const void *data, off_t offset) {
memcpy(wal_buf, data, WAL_BLOCK_SIZE);
struct io_uring_sqe *sqe = io_uring_get_sqe(r);
io_uring_prep_write_fixed(sqe, wal_fd, wal_buf, WAL_BLOCK_SIZE,
offset, 0, IOSQE_IO_LINK); // linked → fsync
sqe->flags |= IOSQE_IO_LINK;
sqe = io_uring_get_sqe(r);
io_uring_prep_fsync(sqe, wal_fd, IORING_FSYNC_DATASYNC);
io_uring_submit(r);
}
生产注意事项:
- IOPOLL 要求块设备已完成驱动配置(NVMe 空闲功耗状态需关闭,
nvme_core.default_ps_max_latency_us=0) - O_DIRECT 写入必须 len + offset 对齐
blk_queue_logical_block_size(通常为 512 或 4096) - 混合读写时建议分 ring:submitter ring(接收用户请求)+ worker ring(执行磁盘 I/O)
5.3 场景三:io_uring+epoll 混合模式
epoll 监听三类 fd:eventfd(uring 就绪)、listen socket、timerfd / signalfd。io_uring 负责磁盘与 socket I/O。
// io_uring 注册 eventfd 监听 CQ 完成
int event_fd = eventfd(0, EFD_NONBLOCK|EFD_CLOEXEC);
io_uring_register_eventfd(&ring, event_fd);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = event_fd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, event_fd, &ev);
while (1) {
int n = epoll_wait(epoll_fd, events, 32, 100);
for (int i=0; i<n; i++) {
if (events[i].data.fd == event_fd) {
eventfd_t val; eventfd_read(event_fd, &val);// 取io_uring完成
drain_cq(&ring);
} else if (events[i].data.fd == listen_fd) {
// 新连接:用 io_uring 接收
int new_fd = accept4(...);
submit_recv(&ring, new_fd);
} else if (events[i].data.fd == timer_fd) {
handle_timer_tick();
}
}
}
这种架构多见于需要同时管理带外事件(如定时信号、子进程状态、磁盘 I/O)的服务端:liblib 的最新 Web Server(如 Netty io_uring、Tokio-uring)即采用类似模式。
六、绑核与 CPU 隔离调优
高 QPS 场景下,SQ / CQ 线程绑核可降低 L2/L3 cache miss、NUMA remote-access 延迟:
# 1. 隔离 CPU 2-3 给 io_uring 服务
# /etc/default/grub: GRUB_CMDLINE_LINUX="isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3"
update-grub
# 2. SQ 线程绑核
struct io_uring_params p = {……};
p.sq_thread_cpu = 2;
io_uring_queue_init_params(DEPTH, &ring, &p);
// 3. 用户态 submit 线程同样绑核
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(3, &set);
pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
// 4. NIC 中断分离(专用核 0-1 处理,避免与 iouring 争核)
echo 00000001 > /proc/irq/IRQ_NUM/smp_affinity_list
echo 00000002 > /proc/irq/IRQ2_NUM/smp_affinity_list
未绑核场景下 io_uring SQ 线程在 NUMA 间频繁迁移会导致 30-45% 吞吐下降。
7+年积累的生产验证与参考
7.1 SPDK(Storage Performance Development Kit)
基于 io_uring 的 NVMe 用户态驱动。在 NVMe 单盘上当 IOPOLL 打开时可达 1,400,000+IOPS。
7.2 Seastar(ScyllaDB)
C++ 异步框架,使用 io_uring + shard-per-core 架构,单节点 1000 万 IOPS 测试中延迟低于 50μs。
7.3 Rust Tokio-uring
Tokio 的 io_uring 后端,用于 Yandex 内部数据库日志结构化存储(LFS)场景,吞吐比 epoll+线程池高 3.2 倍。
7.4 关键内核版本演进
| 内核版本 | 里程碑 |
|---|---|
| 5.1 | io_uring 初始版本,仅支持 read/write/fsync/poll |
| 5.10 | 固定文件与缓冲区注册 stably |
| 5.15 | SQPOLL 实验改进 |
| 5.19 | Multishot Accept/Recv、Send Zero-Copy |
| 6.1 | SCHED_FF、Overflows 容错改进 |
| 6.6 | IORING_OP_SENDMSG_ZC 改进 |
| 6.9 | Socket 预预备、IORING_OP_TASK_WORK 分流 |
八、基准测试与排错清单
8.1 系统参数调优
# 与 IO 相关的 slab/buffer
sysctl -w vm.dirty_ratio=40
sysctl -w vm.dirty_background_ratio=10
sysctl -w vm.dirty_expire_centisecs=3000
sysctl -w vm.dirty_writeback_centisecs=500
# 文件 ulimit
ulimit -n 1048576
fs.file-max = 2000000
# SQPOLL 线程优先级
chrt -p 50 $(pgrep io_wq)
8.2 快速排错清单
| 问题 | 原因 | 解决方法 |
|---|---|---|
| IORING ops slow / drop | SQ overflow(sq->kdropped > 0) | 增大 SQ 深度或 submit 更频繁 |
| CQE overflow | CQ ring 满,用户态未及时消费 | 增大 cq_entries 或更快推进 CQ head |
| Permission denied | WQ 工作线程资源限制 | 检查 rlimit memlock、操作是否 non-root |
| IO fails with EAGAIN | fixed buffer 未注册或 group 错误 | 核对 io_uring_register_buffers + sqe->buf_group |
| Throughput does not scale | SQ 线程未绑核或频繁唤醒 | IORING_SETUP_SQPOLL + 绑核 + sq_thread_idle 增加 |
| High jitter latency | 硬件 PM C-states 尚活跃 | intel_idle.max_cstate=0 processor.max_cstate=1 |
九、总结
io_uring 通过共享内存环形队列+零系统调用+固定资源+链式子操作,在 Linux 异步 I/O 场景下实现质的飞跃。在数据库(WAL、Block Cache)、Web 服务器(高并发网络 I/O)、存储引擎(NVMe 用户态)、CDN(边车代理)、消息队列(Broker 磁盘刷盘)等典型场景中,io_uring 平均能带来 1.5-3.5 倍吞吐提升与 40-60% 尾延迟降低。
掌握 io_uring 不仅是 API 调用,更是对 Linux I/O 栈(VFS、Block Layer、驱动队列)与硬件(NIC RSS、NVMe SQ/CQ)协同设计的理解。它把异步 I/O 从“模拟”推向了“原生”,是 Linux 6.x 服务端高性能开发的必备能力。
参考文档:
- io_uring man pages:
man 2 io_uring_enter/man 7 io_uring - Axboe 博客:Lord of the io_uring
- kernel Documentation:
/Documentation/io_uring/

发表评论 取消回复