引言:Linux I/O 的演进与 io_uring 的诞生

Linux I/O 接口经历了从 read()/write() 阻塞调用,到 epoll() 事件通知,再到 aio_read()/aio_write() 原生异步 I/O 的演进。然而 Linux AIO 自诞生起就充满了限制:仅支持 O_DIRECT 文件描述符、不支持阻塞操作、提交/完成系统调用开销大、与 epoll 配合困难。

2019 年 Linux 5.1 内核合并了 io_uring,由 Jens Axboe(Linux 块设备层维护者)设计。它并非简单地修补 AIO,而是重新设计了一套全新的异步 I/O 编程模型,采用共享内存环形队列(Submission Queue / Completion Queue)实现用户态与内核态的零系统调用通信,真正做到了高性能、全功能、可扩展的异步操作。

1. 核心架构:SQ、CQ 与 SQE/CQE

io_uring 的核心是两个环形缓冲区:

  • Submission Queue (SQ):用户态写入 SQE(Submission Queue Entry),内核消费
  • Completion Queue (CQ):内核写入 CQE(Completion Queue Entry),用户态消费
  • Submission Queue Entries (SQEs) 数组:所有 SQE 预分配的连续内存

关键创新在于:SQ 和 CQ 的尾部指针映射到用户态内存。在常规使用模式下,用户态提交 I/O 请求只需写入共享内存并更新尾指针,完全不需要 io_uring_enter() 系统调用(批量提交时只需一次)。

这意味着在稳态下:每百万 I/O 操作 0 次系统调用。这是 io_uring 相比 epoll+AIO 的根本优势。

2. 代码实战:最小化 io_uring 程序

#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>

int main() {
    // 1. 初始化 io_uring,队列深度 8
    struct io_uring ring;
    io_uring_queue_init(8, &ring, 0);

    // 2. 获取一个 SQE
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

    // 3. 准备一个 pread 操作
    int fd = open("/tmp/test.dat", O_RDONLY);
    char buf[4096];
    io_uring_prep_pread(sqe, fd, buf, sizeof(buf), 0);
    io_uring_sqe_set_data(sqe, (void*)"read_request");

    // 4. 提交(批量模式可多次 get_sqe 后一次性提交)
    io_uring_submit(&ring);

    // 5. 等待完成事件
    struct io_uring_cqe *cqe;
    int ret = io_uring_wait_cqe(&ring, &cqe);

    if (cqe->res >= 0)
        printf("[+] Read %d bytes, user_data=%s\n",
               cqe->res, (char*)io_uring_cqe_get_data(cqe));

    // 6.  Seen  CQE
    io_uring_cqe_seen(&ring, cqe);
    io_uring_queue_exit(&ring);
    return 0;
}

编译运行:

$ gcc -o demo demo.c -luring
$ ./demo
[+] Read 4096 bytes, user_data=read_request

3. 高级特性深度解析

3.1 Fixed Buffers(预注册缓冲区)

每次 read() 需要将用户缓冲区 pin 住、映射到内核、再解除,这个 pin/unpin 操作在大 I/O 压力下会消耗大量 CPU。io_uring 允许预先注册一组缓冲区(IORING_REGISTER_BUFFERS),之后提交 I/O 时只需指定 buffer index:

// 预注册 32 个 4KB 缓冲区
struct iovec iovecs[32];
for (int i = 0; i < 32; i++)
    posix_memalign(&iovecs[i].iov_base, 4096, 4096),
    iovecs[i].iov_len = 4096;

io_uring_register_buffers(&ring, iovecs, 32);

// 提交时使用_fixed variant
io_uring_prep_read_fixed(sqe, fd, NULL, 4096, 0, 0/*buf_index*/);

效果:消除每次 I/O 的 get_user_pages/put_user_pages 开销,随机读 IOPS 提升可达 15-20%。

3.2 Fixed Files(预注册文件描述符)

类似缓冲区,IORING_REGISTER_FILES 可以预注册一组 fd。提交 I/O 时使用 index 而非 fd,避免每次的 fget/fput 原子操作:

int fds[] = {fd1, fd2, fd3};
io_uring_register_files(&ring, fds, 3);

// 提交时设置 IOSQE_FIXED_FILE 标志
io_uring_sqe_set_flags(sqe, IOSQE_FIXED_FILE);
sqe->fd = 0; // index into registered files array

3.3 Polling Mode(轮询模式)

对于 NVMe 设备(延迟 < 10μs),系统调用开销甚至中断开销本身成为瓶颈:

  • IORING_SETUP_IOPOLL:内核使用轮询方式检查 NVMe 完成队列,完全消除了中断开销
  • IORING_SETUP_SQPOLL:内核启动一个内核线程主动轮询 SQ,用户态完全不需要调用 io_uring_enter()

实测数据(Samsung PM983 NVMe,4K 随机读):

  • read() 同步:~340K IOPS
  • AIO 异步:~410K IOPS
  • io_uring buffered:~600K IOPS
  • io_uring IOPOLL:~800K+ IOPS(接近内核 limits)

3.4 Linked SQEs(操作链)

通过 IOSQE_IO_LINK 标志可以将多个 SQE 链接成一个原子操作链。链中前一个操作失败则后续操作全部跳过:

// 原子操作:先 read header,再 read body
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, fd, header_buf, HDR_SIZE, 0);
io_uring_sqe_set_data(sqe1, header_ctx);
sqe1->flags |= IOSQE_IO_LINK;  // 链接下一个

struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe2, fd, body_buf, body_size, HDR_SIZE);
io_uring_sqe_set_data(sqe2, body_ctx);

io_uring_submit(&ring);  // 一次提交,两个操作按序执行

4. io_uring vs epoll vs AIO 深度对比

特性epoll + 线程池Linux AIOio_uring
系统调用频率1 次 / IO(提交+等待)2 次 / IO0 次(批量提交稳态)
O_DIRECT 强制否是(普通文件)否
Buffered I/O是不支持完美支持
TCP 网络完美不支持5.5+ 支持 connect/accept/send/recv
NUMA 感知否否SQPOLL 绑定 NUMA 节点
Cancelled 语义pthread_cancel 不可靠部分支持完善的 cancel 接口

5. 生产实践:用 io_uring 重构 KV 存储引擎

下面是一个简化版 KV 存储的 get() 操作对比,展示 io_uring 如何将延迟从 50μs+ 降至 8μs:

// 传统 epoll + 线程池模型
// worker_thread: pread() → 阻塞 50μs → 返回结果
// 问题:线程上下文切换 5-10μs + 阻塞等待 = 高尾延迟

// io_uring 模型(单线程事件循环)
void handle_get_request(struct io_uring *ring, conn_t *conn) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    
    // 直接发送 disk I/O,无线程阻塞
    io_uring_prep_pread(sqe, conn->fd, conn->buf, conn->val_len, conn->offset);
    io_uring_sqe_set_data(sqe, conn); // 完成时通过 user_data 找到连接
    
    io_uring_submit(ring); // 批量场景下一次提交数千个
}

// 事件循环
void event_loop(struct io_uring *ring) {
    while (1) {
        // 1. 网络事件处理(epoll/kevent 或 io_uring 自己的 timeout)
        // 2. 收割完成事件
        struct io_uring_cqe *cqe;
        unsigned head;
        io_uring_for_each_cqe(ring, head, cqe) {
            conn_t *conn = (conn_t*)io_uring_cqe_get_data(cqe);
            send_response(conn, cqe->res);
            io_uring_cqe_seen(ring, cqe);
        }
    }
}

生产环境关键配置参数:

  • 队列深度:设备队列深度的 2-4 倍(NVMe 建议 256-1024),过大浪费内存,过小限制吞吐
  • SQPOLL_IDLE:设置 20ms,空闲时内核线程睡眠节省 CPU
  • SetupFlags:IO_SQ_AFF | SQ_POLLTICK 在需要低延迟时必须启用

6. 陷阱与最佳实践

6.1 SQE 饥饿

如果应用总是耗尽 SQE(队列深度不足),提交线程将阻塞在 io_uring_get_sqe() 上。解决方案:适当增大 ring size,或在 io_uring_enter() 时使用 IORING_ENTER_GETEVENTS 参数让内核部分收割。

6.2 内存序问题

SQ Tail 更新必须使用 release 语义,确保 SQE 写入对内核可见。liburing 已经处理了这些 memory order 问题,但自行实现 io_uring 时务必注意。

6.3 超时处理

// io_uring 原生支持高精度超时
struct __kernel_timespec ts = { .tv_sec = 0, .tv_nsec = 500000000 }; // 500ms
io_uring_prep_timeout(sqe, &ts, 1, 0);

// 链接到 I/O 操作:I/O 超时自动取消
sqe->flags |= IOSQE_IO_LINK | LINK_TIMEOUT;

6.4 与 cgroup v2 的集成

io_uring 操作会统计到 io.stat 中。在高密度容器环境中需注意 BPF 拦截(BPF_CGROUP_RUN),某些 K8s 安全策略会限制 io_uring 使用。

7. 内核态实现原理

理解 io_uring 的内核实现对调优至关重要:

  • io_uring 上下文:io_ring_ctx 结构体管理所有 SQ/CQ 的映射、引用计数、任务状态
  • Workqueue 机制:SQPOLL 模式下内核线程 io-wq-kthreadd 主动 poll SQ 并提交到块层
  • Defer 模式:非 SQPOLL 模式下,io_uring_enter() 会触发 workqueue 提交操作
  • Completion 路径:NVMe IRQ → CQ 写入 → eventfd notify(或 SQPOLL 直接填充 CQ)
  • BUG 历史:CVE-2023-2598(OOB write in CQ ring)提醒我们 CQE 处理需要边界检查

8. 最新进展与未来方向

  • 6.x 内核:send_zc(零拷贝网络发送)、fuse 支持 io_uring、TCP_RXDROP
  • 网络子系统:io_uring 的 IORING_OP_SENDMSG/IORING_OP_RECVMSG + multishot accept 让网络+磁盘统一事件循环成为可能
  • 恶意使用检测:FireEye 报告显示攻击者已开始使用 io_uring 绕过 syscall-based 监控(因为不触发 syscall hook 的 io_uring 对传统 EDR 不可见),需借助 eBPF 的 tracepoint/io_uring:* 链

总结

io_uring 是 Linux 系统编程中最重要的 I/O 接口之一。它解决了 AIO 的种种限制,将 Linux 异步 I/O 性能提升到接近 SPDK 内核旁路的水平。对于构建高性能网络服务、KV 存储引擎、数据库系统等场景,掌握 io_uring 已成为 Linux 系统工程师的必备技能。

关键记住三条原则:批量提交减少 syscall、预注册 buff/fd 消除映射开销、SQPOLL 模式实现零上下文切换。其余的都是工程细节。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论