一、为什么 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 标志位检测):

  1. 用户态 io_uring_get_sqe(ring) — 从 SQ 环形缓冲取一个空闲 SQE(纯用户态内存操作,无 syscall)
  2. io_uring_prep_readv(sqe, fd, iovec, nr_vecs, offset) — 填充操作参数
  3. io_uring_submit(ring) — 更新 *sq.ktail(写屏障后自动更新,SQPOLL 模式下内核线程异步消费,无需 enter syscall)
  4. 内核处理完成后将 CQE 写入 CQ 环形缓冲,更新 *cq.ktail
  5. 用户态 io_uring_peek_cqe(ring, &cqe) — 从 CQ 尾部读取完成事件(纯内存操作)
  6. 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_DIRECTNVMe 低延迟 I/O
IORING_SETUP_SQPOLL内核线程(io-wq 或专门 SQ 线程)主动轮询 SQ,用户免 syscall高提交速率吞吐
IORING_SETUP_SQ_AFF绑定 SQ 线程到指定 CPU 核NUMA 隔离、缓存热路径
IORING_SETUP_CQSIZECQ 深度 > 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.1io_uring 初始版本,仅支持 read/write/fsync/poll
5.10固定文件与缓冲区注册 stably
5.15SQPOLL 实验改进
5.19Multishot Accept/Recv、Send Zero-Copy
6.1SCHED_FF、Overflows 容错改进
6.6IORING_OP_SENDMSG_ZC 改进
6.9Socket 预预备、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 / dropSQ overflow(sq->kdropped > 0)增大 SQ 深度或 submit 更频繁
CQE overflowCQ ring 满,用户态未及时消费增大 cq_entries 或更快推进 CQ head
Permission deniedWQ 工作线程资源限制检查 rlimit memlock、操作是否 non-root
IO fails with EAGAINfixed buffer 未注册或 group 错误核对 io_uring_register_buffers + sqe->buf_group
Throughput does not scaleSQ 线程未绑核或频繁唤醒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/
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部