引言

Linux 5.1 引入了一个注定要改变高性能 I/O 编程范式的新系统调用 —— io_uring(曾名 aioring)。它由 Jens Axboe(Linux 块设备层维护者)主导设计,目标是解决 Linux 异步 I/O 长期存在的痛点:每次 I/O 都需要至少两次系统调用(submit + complete)、数据结构碎片化、缓冲区固定成本高昂。

io_uring 的出现不仅仅是一个新 API,它代表了一种全新的用户态与内核通信模型:通过两个共享环形队列(Submission Queue + Completion Queue),将系统调用开销逼近零。在特定场景下,io_uring 的 I/O 吞吐量甚至能超越 DPDK 等内核旁路方案,同时保持内核协议栈的完整生态。

本文将从设计哲学出发,深入剖析 io_uring 的内核实现架构、核心 API 模式、高级特性及生产环境中的实战经验,最后构建一个基于 io_uring 的高性能 TCP Echo Server 作为综合案例。

一、设计哲学:为什么需要 io_uring

1.1 Linux 原生异步 I/O 的困境

在 io_uring 之前,Linux 提供了posix AIO(即io_submit / io_getevents),但它存在大量设计缺陷:

  • 仅支持 O_DIRECT:必须使用直接 I/O,无法利用页缓存,且要求内存/偏移对齐
  • 不支持 sockets:无法用于网络 I/O,实用性大打折扣
  • 阻塞行为不可控:某些操作(如open、stat)仍会阻塞
  • 性能平庸:单一上下文的锁竞争限制了扩展性

1.2 epoll 的本质限制

epoll 是事件通知机制,不是异步 I/O 机制。使用 epoll 的典型模式是"通知可读 → 同步 read",read 本身仍是阻塞调用。当面对百万级连接时,这种"边缘触发 + 同步 I/O"的组合会导致:

  • 频繁的用户态/内核态切换
  • 无法批量处理就绪事件
  • NUMA 跨节点访问 socket 结构体造成缓存抖动

1.3 io_uring 的核心设计

io_uring 的设计围绕三个核心原则:

  1. 共享数据结构(Shared Ring Buffers):SQ 和 CQ 在用户态和内核之间共享内存映射,无需系统调用即可访问
  2. 批量提交与收割(Batch Submit + Batch Reap):一次io_uring_enter可提交数百个 SQE,收割数百个 CQE
  3. 操作统一化(Unified Interface):磁盘 I/O、网络 I/O、文件操作、定时器、accept/connect全部通过同一套接口

二、内核架构:双环形队列与通信模型

2.1 数据结构总览

io_uring 的核心数据结构由三部分组成:

┌─────────────────────────────────────────────────────────────────┐
│                     io_uring 实例                                │
├─────────────────────────────────────────────────────────────────┤
│  Submission Queue (SQ)                                           │
│  ┌──────────────────┬────────────────────────────────────────┐  │
│  │ SQE array        │ SQ ring buffer (head/tail 指针)        │  │
│  │ (实际请求数据)    │ (用户生产 / 内核消费)                   │  │
│  └──────────────────┴────────────────────────────────────────┘  │
├─────────────────────────────────────────────────────────────────┤
│  Completion Queue (CQ)                                           │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ CQE array (含 user_data, res, flags)                      │   │
│  └──────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

SQ(Submission Queue)由三方共享信息:

  • SQE Array:实际的 64 字节请求结构体数组,用户态写、内核态读
  • sq->head:内核消费指针(内核态可写)
  • sq->tail:用户态生产指针(用户态可写)

CQ(Completion Queue)是无锁的,因为仅内核生产、仅用户态消费:

  • CQE Array:16 字节的完成事件结构体
  • 内核写入流程:head = cq->head; cqe_array[head & mask] = event; head++; 内存屏障; 写 cq->head

2.2 mmap 映射机制

io_uring_setup()返回的 fd 被 mmap 多次,映射不同的内存区域:

sq->ring_ptr  = mmap(..., off=IORING_OFF_SQ_RING);  // 元数据
cq->ring_ptr  = mmap(..., off=IORING_OFF_CQ_RING);  // 元数据
sqes         = mmap(..., off=IORING_OFF_SQES);     // SQE 数组

关键:SQE 数组由内核在 setup 时分配,用户态通过 mmap 直接写入,避免了每次 submit 时复制数据到内核。

在支持 IORING_FEAT_SINGLE_MMAP(Linux 5.4+)的内核上,SQ ring 和 CQ ring 可以合并映射为一次 mmap 调用。

2.3 三种执行模式

模式触发方式系统调用次数适用场景
Interrupt-driven完整 io_uring_enter1 次/批通用场景
SQPOLL内核 sqthread 主动消费 SQ可降至 0极低延迟要求
IOPOLL内核线程轮询 IO 完成可降至 0NVMe 低延迟

SQPOLL 模式详解:内核创建一个专用线程(IORING_SETUP_SQPOLL)持续轮询 SQ。用户态只需写入 SQE 并更新 tail,内核线程自动消费。代价是线程持续占用一个 CPU 核心(100% busy polling)。

IOPOLL 模式:专门优化块设备 I/O 延迟,内核轮询线程绕过 block 层异步完成机制,直接轮询 completion queue。适合 NVMe 等设备。

三、核心 API 深度解析

3.1 初始化与队列操作

#include 

struct io_uring ring;
// 初始化:队列深度 1024,默认中断模式
int ret = io_uring_queue_init(1024, ˚, 0);

// SQPOLL 模式
struct io_uring_params params = { .sq_thread_idle = 2000 };
int ret = io_uring_queue_init_params(1024, ˚, ¶ms);

// 获取 SQE,填充请求,提交
struct io_uring_sqe *sqe = io_uring_get_sqe(˚);
io_uring_prep_readv(sqe, fd, &iov, 1, offset);
io_uring_sqe_set_data(sqe, my_context_ptr);
io_uring_submit(˚);

// 收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
int count = 0;
io_uring_for_each_cqe(˚, head, cqe) {
    process_cqe(cqe);
    count++;
}
io_uring_cq_advance(˚, count);

io_uring_queue_exit(˚);

关键点:

  • io_uring_get_sqe 返回 NULL 表示 SQ 满,需要先提交已填充的 SQE
  • io_uring_submit 尽可能批量提交(内部会合并多次 get_sqe 调用)
  • io_uring_cqe_seen 仅更新 CQ ring 的 head,不释放 SQE 对应资源——若使用注册缓冲区则需在业务层管理生命周期

3.2 网络 accept 与 read/write 操作链

// 异步 accept(Linux 5.5+)
io_uring_prep_accept(sqe, listen_fd, &addr, &addrlen, flags);

// 多射 accept(Linux 5.19+):一个 SQE 产生多个完成事件
io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);

// 异步 recv
io_uring_prep_recv(sqe, client_fd, buf, len, 0);

// 异步 send
io_uring_prep_send(sqe, client_fd, buf, len, 0);

// 使用缓冲区选择的内核预分配模式
// sqe->flags |= IOSQE_BUFFER_SELECT;
// sqe->buf_group = bgid;
// 完成后通过 (cqe->flags >> IORING_CQE_BUFFER_SHIFT) 获得 buf_id

3.3 链接操作(Linked SQEs)

io_uring 支持将多个 SQE 标记为链式执行(IOSQE_IO_LINK),前一操作成功后才执行后一操作:

struct io_uring_sqe *sqe1 = io_uring_get_sqe(˚);
io_uring_prep_read(sqe1, fd, buf, len, 0);
sqe1->flags |= IOSQE_IO_LINK;  // 链式标记

struct io_uring_sqe *sqe2 = io_uring_get_sqe(˚);
io_uring_prep_write(sqe2, fd2, buf, len, 0);
sqe2->flags |= IOSQE_IO_LINK;

struct io_uring_sqe *sqe3 = io_uring_get_sqe(˚);
io_uring_prep_close(sqe3, fd3);

io_uring_submit(˚);

应用场景:Zero-Copy 代理中"接收→转发→关闭"三步保证顺序;避免使用dup2等同步调用。

HARDLINK(IOSQE_IO_HARDLINK):比 IO_LINK 更严格——如果链接链中某个操作失败,后续链中操作全部丢弃。适用于"读 header → 写 header → 写 body"这类完全顺序的流水线。

3.4 固定缓冲区与文件

高频 I/O 中每次操作都需要 pin/unpin 用户页面。io_uring 允许预先注册缓冲区或文件描述符:

// 注册文件描述符数组(减少 fget/fput 调用)
int fds[] = { fd1, fd2, fd3 };
io_uring_register_files(˚, fds, 3);
// 使用时:sqe->flags |= IOSQE_FIXED_FILE; sqe->fd = index;

// 注册稀疏文件表(IORING_REGISTER_FILES_UPDATE)
int update_fds[] = { [2] = new_fd, [5] = new_fd2 };
io_uring_register_files_update(˚, 2, update_fds, 2);

// 注册的缓冲区(用于读,需预先 pin 页面)
struct iovec iov = { .iov_base = buf, .iov_len = BUF_SIZE };
io_uring_register_buffers(˚, &iov, 1);
// 使用:sqe->flags |= IOSQE_FIXED_BUFFER;

// 内核自动选择缓冲区(recv)
// 1) 注册缓冲组
struct io_uring_prov_bpb {
    .buf_cnt = 128;
    .buf_len = 4096;
    .bgid = 1;
};
// 实际使用 io_uring_register_buf_ring()(Linux 5.19+)

3.5 重要限制作息

  • SQE 大小固定 64 字节:所有变体操作共用同一段内存,需通过 opcode 区分
  • CQE 大小固定 16 字节:user_data(8) + res(4) + flags(4)
  • Opcode 类型通过 sqe->opcode 区分:IORING_OP_READV、IORING_OP_WRITEV、IORING_OP_SEND、IORING_OP_RECV 等
  • cold path vs fast path:io_uring 将 mostly async 操作和 blocking 操作统一到同一接口,但底层仍通过 workqueue 异步化阻塞调用

四、生产环境部署与调优

4.1 系统参数调优

# 增大 aio 最大请求数
sysctl -w fs.aio-max-nr=1048576

# 增大 rmem_max 用于大缓冲区 TCP
sysctl -w net.core.rmem_max=16777216

# NUMA 绑定:sqthread 绑定到网卡同 NUMA 节点
taskset -c 3 ./uring_app

# 中断亲和性
echo 3 > /proc/irq/IRQ_number/smp_affinity

# 增大异步 I/O 线程
echo 64 > /sys/block/nvme0n1/queue/nr_requests

4.2 队列深度选择

场景建议深度原因
网络 Echo Server256 ~ 1024连接数有限,CPU 瓶颈先于队列
NVMe SSD 顺序读32 ~ 128SSD 并行单元少,过深无收益
高并发代理(百万连接)4096 ~ 16384高并发突发需要更大缓冲

4.3 选择 SQPOLL 的时机

指标InterruptSQPOLL
CPU 开销(空载)01 核心 100%
最坏延迟~10μs~1μs
适用场景连接 < 10K>连接 > 100K,延迟敏感
热路径系统调用1 io_uring_enter可降为 0

4.4 链接操作的失败传播

理解链接标记的失败传播模式至关重要:

IOSQE_IO_LINK 链:
  [READ] --link--> [WRITE] --link--> [CLOSE]

  READ 返回 -EAGAIN → WRITE 被 CANCELED → CLOSE 被 CANCELED
  
  业务层必须检查 CLOSE 的 res:
  if (cqe->res == -ECANCELED) {
      // 链被中断,清理资源但不需要报错
  } else if (cqe->res < 0>

4.5 常见陷阱

  1. SQE 饥饿:io_uring_get_sqe 返回 NULL 时必须至少部分提交当前批量后再重新获取
  2. Chain + Multishot 的交互:Multishot accept 产生的 CQE 会不断链接到链中,可能导致链"永远无法关闭"。应单独提交 multishot 请求而非链接
  3. Buffer Select 后未归还:recv 使用 IOSQE_BUFFER_SELECT 收到的 buffer 必须在下次再注册(或通过 BUF_SELECT 指定 buf_id 的方式隐式归还),否则可用缓冲区会指数减少
  4. SQPOLL 线程优先级:默认 SCHED_OTHER 会导致用户态提交饥饿。应设置 SCHED_FIFO + 高优先级(通过 sq_thread_cpu 和 prctl)
  5. 内核版本兼容性:io_uring 特性与内核版本强相关。生产环境推荐 Linux 5.19+

五、综合实战:高性能 TCP Echo Server

以下示例使用 liburing 构建了完整的事件驱动服务器:

#include 
#include 
#include 
#include 
#include 
#include 

#define QUEUE_DEPTH   4096
#define BUF_SIZE      4096
#define PORT          9999

enum { OP_ACCEPT = 0, OP_READ, OP_WRITE };

struct client_info {
    int fd;
    char buf[BUF_SIZE];
};

static void submit_accept(struct io_uring *ring, int listen_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
    sqe->flags |= IOSQE_FIXED_FILE;
    io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);
    io_uring_submit(ring);
}

int main(void) {
    struct io_uring ring;
    io_uring_queue_init(QUEUE_DEPTH, ˚, 0);

    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port   = htons(PORT),
        .sin_addr   = { INADDR_ANY }
    };
    int optval = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd, 128);

    int fds[1] = { listen_fd };
    io_uring_register_files(˚, fds, 1);

    submit_accept(˚, listen_fd);

    while (1) {
        struct io_uring_cqe *cqe;
        unsigned head;
        int count = 0;
        io_uring_submit_and_wait(˚, 1);
        io_uring_for_each_cqe(˚, head, cqe) {
            int op_type = (int)(uintptr_t)io_uring_cqe_get_data(cqe);
            switch (op_type) {
            case OP_ACCEPT: {
                int client_fd = cqe->res;
                if (client_fd < 0 xss=removed>fd = client_fd;
                struct io_uring_sqe *rsqe = io_uring_get_sqe(˚);
                io_uring_prep_recv(rsqe, client_fd, ci->buf, BUF_SIZE, 0);
                io_uring_sqe_set_data(rsqe, (void*)OP_READ);
                break;
            }
            case OP_READ: {
                struct client_info *ci = (struct client_info *)io_uring_cqe_get_data(cqe);
                int bytes = cqe->res;
                if (bytes <= 0) { free(ci); break; }
                struct io_uring_sqe *wsqe = io_uring_get_sqe(˚);
                io_uring_prep_send(wsqe, ci->fd, ci->buf, bytes, 0);
                io_uring_sqe_set_data(wsqe, (void*)OP_WRITE);
                break;
            }
            case OP_WRITE: {
                struct client_info *ci = (struct client_info *)io_uring_cqe_get_data(cqe);
                if (cqe->res < 0 xss=removed>fd, ci->buf, BUF_SIZE, 0);
                io_uring_sqe_set_data(rsqe, (void*)OP_READ);
                break;
            }
            }
            count++;
        }
        io_uring_cq_advance(˚, count);
    }
    io_uring_queue_exit(˚);
    return 0;
}

5.1 架构要点

  • 多射 accept:IORING_ACCEPT_MULTISHOT(Linux 5.19+)使内核在有新连接到达时自动产生多个 CQE,直到显式取消,减少 SQE 提交频率
  • 状态机驱动:通过user_data编码操作类型(ACCEPT/READ/WRITE),CQE 处理后决定下一步动作(读→写→读 循环)
  • 注册文件:通过 register_files + IOSQE_FIXED_FILE 避免每次操作内核 fget/fput 引用计数操作
  • 连接生命周期:基于 FREE_ON_COMPLETION 认知——当 recv 返回 0 或负值时,归还 client_info 内存;使用 shutdown() + close() 优雅关闭

5.2 进阶改进方向

  • 多连接共享缓冲区池:注册 4096 个 4KB 缓冲组,recv 时内核自动从池中取空闲 buffer,省去 fd→buffer 映射查找
  • IORING_RECVSEND_PEEK:在决定处理前查看 TCP 数据而不消费,用于协议解析
  • IORING_ACCEPT_MULTISHOT_DRAIN:停止 multishot accept 时优雅处理队列中未完成的请求
  • XDP 加速:将大包接收路径卸载到 XDP 程序,数据包直接写入 io_uring 提供的 buffer

六、性能基准与生态现状

6.1 吞吐量对比(单核,NVMe 4K 随机读)

模型IOPS(单核)系统调用
同步 read/write~120K2/IO
epoll + O_DIRECT~350K3+/IO
io_uring(默认)~1.2M0.5/IO(批量均摊)
io_uring + SQPOLL + fixed~2.1M0.1/IO
io_uring + registered buffers~1.8M极低

6.2 网络场景(TCP Echo,单核)

模型Requests/sec
epoll 方案~80K req/s
io_uring (interrupt)~180K req/s
io_uring (SQPOLL)~320K req/s
io_uring + multishot + buffers~410K req/s

6.3 主流框架集成

  • Tokio-uring (Rust):基于 io_uring 的异步运行时,绕过 epoll 路径
  • glommio:Rust per-core 异步运行时,每个 core 独立 ring
  • Netty io_uring transport:2022 年引入,零拷贝 + 无需 epoll
  • uring-sys + uringds:Rust 底层绑定 + 高级 socket API
  • PostgreSQL:实验性 io_uring 后端替代 pg_pwritev
  • QEMU:virtio-blk/scsi 后端默认使用 io_uring

七、未来演进方向

  • IORING_OP_SENDMSG_ZC:真正的零拷贝 sendmsg(跳过 socket 层额外复制)
  • Multi-shot 扩展:multishot timeout、multishot send
  • FUSE io_uring:FUSE 文件系统通过 io_uring 加速,减少用户态/内核态切换
  • io_uring + eBPF:在 SQE 处理链中注入 eBPF 程序,实现可编程 I/O 调度
  • Kernel same-page merging:注册缓冲区 + KSM,减少虚拟机重复 I/O 内存

总结

io_uring 不只是一个高性能 I/O 库,它是 Linux 内核在"用户态-内核态边界长久以来高开销"问题上的范式革新。其核心贡献:

  • 双环形共享队列将 sys_bottleneck 降至历史最低
  • 统一接口覆盖磁盘、网络、定时器、socket
  • SQPOLL / IOPOLL / Fixed 三级调控机制覆盖不同延迟要求
  • 生态繁荣:Rust(tokio-uring/glommio)、Netty、QEMU 全面接入

对于构建高并发网络服务或存储引擎的工程师而言,io_uring 已从"新特性"变为"必备技能"。掌握其设计哲学与生产级调优方法,将直接影响服务的吞吐上限与延迟长尾表现。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部