io_uring 与 Linux 异步 IO 革命深度实战

从内核提交队列到零系统调用的全链路透视

一、为什么需要 io_uring

在 Linux 传统异步 IO 框架下,开发者一直面临一个根本性难题:io_submit() 的性能太差,甚至不如同步 read/write。这短暂背后是内核 AIO 架构的三个缺陷:仅支持 O_DIRECT 模式、接口开销巨大(每次 io_submit 需要 copy_from_user 和复杂验证)、以及 io_getevents() 无法与事件循环无缝整合。

io_uring 由 Jens Axboe(Facebook 内核开发者,也是 block 子系统维护者)重新设计,用两个共享的环形队列重构了 Linux 异步 IO 的整个编程模型。自 Linux 5.1 引入以来,io_uring 已经在高性能存储、网络服务器和数据库系统中得到广泛应用。

核心性能优势:

  • IOPS 提升:NVMe 场景下比 libaio 高出 50%~100%
  • 延迟优化:p999 延迟从百微秒降至 10~20 微秒
  • 零系统调用:SQPOLL 模式下完全消除用户态-内核态切换开销
  • 统一接口:文件 IO、网络 IO、文件属性操作统一通过 io_uring 提交

二、io_uring 核心架构

2.1 双环队列设计

io_uring 的核心数据结构由两个环形缓冲区组成,通过 mmap 直接在用户态和内核态之间共享:

  • Submission Queue (SQ):提交队列,用户态写 SQE(读 tail),内核读 SQE(读 head),单生产者单消费者,无需锁
  • Completion Queue (CQ):完成队列,内核写 CQE(写 tail),用户态读 CQE(读 head),同样无锁设计

关键在于:SQ 和 CQ 的 head/tail 指针以及 entries 数组全部通过 mmap 映射到用户态内存,整个过程零拷贝,没有任何内存复制操作。

2.2 io_uring_setup 系统调用

#include <linux/io_uring.h>

int io_uring_setup(unsigned entries, struct io_uring_params *p);
// entries: 队列深度(2的幂次)
// p: 参数配置(flags/sq_thread_cpu/cpu_idle/cq_entries 等)

// 用户态初始化(liburing 封装)
#include <liburing.h>
struct io_uring ring;
int ret = io_uring_queue_init(1024, &ring, 0);

io_uring_params 结构体关键字段:

  • sq_entries:SQ 实际深度
  • cq_entries:CQ 实际深度(默认为 SQ 的 2 倍)
  • flags:标志位(IORING_SETUP_SQPOLL/IOPOLL/ATTACH_WSQ 等)
  • sq_thread_cpu:SQPOLL 线程绑定的 CPU
  • sq_thread_idle:SQPOLL 空闲超时(毫秒)
  • features:内核支持的特性位图(IORING_FEAT_SINGLE_MMAP/NODROP 等)

三、io_uring 三种工作模式

3.1 中断驱动模式(默认)

最基本的模式,用户态通过 io_uring_enter() 系统调用通知内核有新 SQE 待处理,内核处理完成后通过 CQ 通知:

// 准备并提交 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, BUF_SIZE, 0);
sqe->user_data = (u64)buf; // 上下文标识
io_uring_submit(&ring); // 内部调用 io_uring_enter

// 等待完成事件
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
void *completed_buf = (void *)cqe->user_data;
int ret = cqe->res; // 返回值
io_uring_cqe_seen(&ring, cqe);

适用场景:中等 IO 负载、对延迟要求不极端的场景。

3.2 内核线程轮询模式(IORING_SETUP_SQPOLL)

Linux 5.11 引入的革命性模式。内核创建一个专用线程持续轮询 SQ 中的新 SQE,用户态永远不需要调用 io_uring_enter():

struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_cpu = 2;       // 绑定到 CPU2
params.sq_thread_idle = 2000;   // 空闲2秒后休眠内核线程

struct io_uring ring;
io_uring_queue_init_params(2048, &ring, &params);

// 提交 SQE —— 只需写入 tail 指针,无系统调用!
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, offset);
io_uring_sqe_set_data(sqe, my_data);

// 只需更新 SQ tail,无 syscall
io_uring_submit(&ring); // 不调用 io_uring_enter,纯内存写

核心优势:

  • 单次 IO 用户态开销从 ~120ns(syscall)降至 ~20ns(内存写)
  • NVMe 随机读取可达 200万+ IOPS(相比 libaio 的 ~80万)
  • 完美适配 io_uring + 绑核 + NVMe 的多队列提交

3.3 IO 完成轮询模式(IORING_SETUP_IOPOLL)

针对支持轮询的块设备(NVMe),绕过 IRQ 机制直接轮询 CQ,消除中断处理开销:

params.flags = IORING_SETUP_IOPOLL;
io_uring_queue_init_params(1024, &ring, &params);

// 与 SQPOLL 组合可实现完全零中断+零系统调用
params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_IOPOLL;

典型性能对比(三星 NVMe 970 EVO Plus,4K 随机读):

  • io_uring + SQPOLL + IOPOLL:1.95M IOPS,延迟 3-8μs
  • io_uring + SQPOLL:1.5M IOPS,延迟 8-20μs
  • io_uring(默认):1.1M IOPS,延迟 15-40μs
  • libaio:750K IOPS,延迟 50-120μs
  • 同步 pread/pwrite:120K IOPS,延迟 200+μs

四、liburing API 编程实战

4.1 SQE 准备函数族大全

liburing 提供了覆盖所有 POSIX IO 操作的预处理器函数:

// ===== 文件 IO =====
io_uring_prep_read(sqe, fd, buf, nbytes, offset);
io_uring_prep_write(sqe, fd, buf, nbytes, offset);
io_uring_prep_readv(sqe, fd, iovec, nr_vecs, offset);
io_uring_prep_writev(sqe, fd, iovec, nr_vecs, offset);
io_uring_prep_splice(sqe, fd_in, off_in, fd_out, off_out, len, flags);

// ===== 网络 IO(Linux 5.19+ 完整支持)=====
io_uring_prep_recv(sqe, sockfd, buf, len, flags);
io_uring_prep_send(sqe, sockfd, buf, len, flags);
io_uring_prep_recvmsg(sqe, sockfd, msg, flags);
io_uring_prep_sendmsg(sqe, sockfd, msg, flags);
io_uring_prep_accept(sqe, sockfd, addr, addrlen, flags);
io_uring_prep_connect(sqe, sockfd, addr, addrlen);

// ===== 文件元数据 =====
io_uring_prep_fsync(sqe, fd, flags);
io_uring_prep_fallocate(sqe, fd, mode, offset, len);
io_uring_prep_ftruncate(sqe, fd, len);
io_uring_prep_openat(sqe, dfd, path, flags, mode);
io_uring_prep_close(sqe, fd);
io_uring_prep_statx(sqe, dfd, path, flags, mask, statx_buf);
io_uring_prep_unlinkat(sqe, dfd, path, flags);
io_uring_prep_renameat(sqe, olddfd, oldpath, newdfd, newpath, flags);
io_uring_prep_mkdirat(sqe, dfd, path, mode);

// ===== 同步操作 =====
io_uring_prep_nop(sqe); // 空操作用于测试/唤醒
io_uring_prep_timeout(sqe, ts, count, flags);
io_uring_prep_poll_add(sqe, fd, poll_mask);
io_uring_prep_cancel(sqe, user_data, flags);

4.2 批量提交模式

io_uring 的真正威力在于批量提交。通过准备多个 SQE 后一次 io_uring_submit(),大幅分摊系统调用开销:

#define BATCH_SIZE 32

void batch_read(int fd, void **buffers, off_t *offsets, int count) {
    struct io_uring *ring = get_ring();
    
    for (int i = 0; i < count; i += BATCH_SIZE) {
        int batch = (count - i < BATCH_SIZE) ? (count - i) : BATCH_SIZE;
        
        // 批量准备 BATCH_SIZE 个读请求
        for (int j = 0; j < batch; j++) {
            struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
            io_uring_prep_read(sqe, fd, buffers[i + j], BUF_SIZE, offsets[i + j]);
            sqe->user_data = encode_context(i + j, OP_READ);
        }
        
        // 一次系统调用提交全部
        io_uring_submit(ring);
    }
}
// 32个 IO 操作只需 1 次 syscall,而非 32 次!

4.3 链式请求(IOSQE_IO_LINK)

链式请求确保操作严格按执行顺序串联:

// SQL 数据库 WAL 场景:先写日志头 + 再写数据页 + 最后 fsync
struct io_uring_sqe *sqe;

// [1] 写入 WAL 日志记录
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, wal_fd, wal_record, record_size, wal_offset);
sqe->flags |= IOSQE_IO_LINK; // 链式标记

// [2] 写入数据页
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, data_fd, page_data, PAGE_SIZE, page_offset);
sqe->flags |= IOSQE_IO_LINK;

// [3] 确保数据落盘
sqe = io_uring_get_sqe(&ring);
io_uring_prep_fsync(sqe, data_fd, IORING_FSYNC_DATASYNC);

io_uring_submit(&ring);
// 三个操作保证顺序:写日志 → 写数据 → fsync,且 fsync 仅在写入成功后执行

4.4 高级链接标志

// IOSQE_IO_HARDLINK:前一个必须成功才执行下一个(默认行为)
// IOSQE_IO_DRIVE:前一个失败也继续执行下一个
// IOSQE_ASYNC:强制在异步上下文中执行(即使操作本身可能同步完成)
// IOSQE_BUFFER_SELECT:自动选择接收缓冲区(网络场景核心)
// IOSQE_FIXED_FILE:使用注册的文件表而非直接 fd

sqe->flags |= IOSQE_IO_LINK | IOSQE_IO_DRAIN; // 链式 + 排空
sqe->flags |= IOSQE_BUFFER_SELECT; // 缓冲区自动选择
sqe->buf_group = 1; // 缓冲组 ID

五、Fixed Files 与 Registered Buffers 高级优化

5.1 注册缓冲区(Registered Buffers / Fixed Buffers)

在高频 IO 场景中,每次 IO 都需要内核 pin 住用户态内存(get_user_pages),造成显著开销。通过预先注册缓冲区,消除每次的 pin/unpin:

#define NR_BUFS 256
#define BUF_SIZE 4096

// [1] 注册缓冲池
struct iovec iovecs[NR_BUFS];
for (int i = 0; i < NR_BUFS; i++) {
    void *buf;
    posix_memalign(&buf, 4096, BUF_SIZE); // 页对齐
    iovecs[i].iov_base = buf;
    iovecs[i].iov_len = BUF_SIZE;
}
int ret = io_uring_register_buffers(&ring, iovecs, NR_BUFS);

// [2] 使用索引引用缓冲区
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, NULL, BUF_SIZE, offset, 23);
//                                                         ^-- buf_index=23
sqe->flags |= IOSQE_FIXED_BUFFER;

// 内核直接使用预注册的缓冲区,无需 get_user_pages

性能收益分析:

  • 消除每次 IO 的 page fault 和 pin/unpin 开销
  • NVMe 4K 读取:+15-30% IOPS,延迟降低 20-40%
  • 适合固定大小 IO 的数据库、KV 存储引擎

5.2 注册文件表(Fixed Files)

预先注册 N 个文件描述符到 io_uring,用整型索引替代 int fd,避免内核 fdtable 查找:

#define NR_FILES 512

// [1] 注册文件描述符表
int file_table[NR_FILES];
for (int i = 0; i < NR_FILES; i++) {
    file_table[i] = open(file_paths[i], O_RDONLY | O_DIRECT);
}
io_uring_register_files(&ring, file_table, NR_FILES);

// [2] 使用索引引用文件
sqe = io_uring_get_sqe(&ring);
sqe->flags |= IOSQE_FIXED_FILE; // 关键标志
io_uring_prep_read(sqe, 42, buf, size, offset); // fd=42 是索引,非真实 fd
// 内核自动从注册的 file_table[42] 获取真实文件

5.3 缓冲区选择组(Buffer Groups)

Linux 5.17+ 引入,允许内核从预注册的缓冲池中自动选择合适的缓冲区,特别适用于网络接收:

// 注册两个缓冲组
io_uring_register_buffers(&ring, small_bufs, 128);  // group 0: 1KB
io_uring_register_buffers(&ring, large_bufs, 64);   // group 1: 8KB

// 提交接收请求,由内核选择缓冲区
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, client_fd, NULL, 0, 0); // buf=NULL + BUFFER_AUTO
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = 0; // 从缓冲组 0 自动分配

// 完成后 CQE 返回实际使用的缓冲区索引
int buf_index = io_uring_cqe_get_buf(cqe);
int flags = cqe->flags >> IORING_CQE_BUFFER_SHIFT;

// 处理完成后放回缓冲池
io_uring_buf_ring_add(...);
io_uring_buf_ring_advance(...);

这是零拷贝网络 IO 的核心机制:数据包直接写入预注册的 DMA 可达内存。

六、io_uring 与 epoll 的网络整合实战

6.1 架构设计模式

典型的高性能网络服务器将 epoll(事件通知)与 io_uring(异步执行)结合:

// 网络 IO 架构:epoll 监听 → io_uring 处理
int epoll_fd = epoll_create1(0);
int listen_fd = create_listener(port);

// epoll 注册 listen_fd
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

while (1) {
    int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
    
    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == listen_fd) {
            // 新连接:通过 io_uring accept
            submit_accept();
        } else {
            // 客户端可读:通过 io_uring recv
            submit_recv(events[i].data.fd);
        }
    }
    
    // 收割 io_uring 完成事件(非阻塞)
    reap_completions(IO_URING_REAP_NONBLOCK);
}

6.2 完整的异步 HTTP 服务器骨架

#include <liburing.h>
#include <sys/epoll.h>

#define QUEUE_DEPTH 4096
#define BUF_SIZE 8192
#define MAX_CLIENTS 10000

enum op_type { OP_ACCEPT, OP_READ, OP_WRITE, OP_CLOSE };

struct client_ctx {
    int fd;
    char read_buf[BUF_SIZE];
    char write_buf[BUF_SIZE];
    int bytes_read;
    int bytes_to_write;
};

struct http_server {
    struct io_uring ring;
    int listen_fd;
    int epoll_fd;
    struct client_ctx clients[MAX_CLIENTS];
};

void submit_accept(struct http_server *srv) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&srv->ring);
    struct sockaddr_in addr;
    socklen_t len = sizeof(addr);
    io_uring_prep_accept(sqe, srv->listen_fd, 
                         (struct sockaddr*)&addr, &len, SOCK_NONBLOCK);
    sqe->user_data = make_data(OP_ACCEPT, 0);
    io_uring_submit(&srv->ring);
}

void submit_read(struct http_server *srv, int client_fd) {
    if (client_fd >= MAX_CLIENTS) return;
    struct client_ctx *ctx = &srv->clients[client_fd];
    
    struct io_uring_sqe *sqe = io_uring_get_sqe(&srv->ring);
    io_uring_prep_recv(sqe, client_fd, ctx->read_buf, BUF_SIZE, 0);
    sqe->user_data = make_data(OP_READ, client_fd);
    io_uring_submit(&srv->ring);
}

void submit_write(struct http_server *srv, int client_fd) {
    struct client_ctx *ctx = &srv->clients[client_fd];
    
    struct io_uring_sqe *sqe = io_uring_get_sqe(&srv->ring);
    io_uring_prep_send(sqe, client_fd, ctx->write_buf, 
                       ctx->bytes_to_write, 0);
    sqe->user_data = make_data(OP_WRITE, client_fd);
    io_uring_submit(&srv->ring);
}

void event_loop(struct http_server *srv) {
    submit_accept(srv);
    
    struct epoll_event events[128];
    while (1) {
        // 等待事件(io_uring completions + epoll)
        int n = epoll_wait(srv->epoll_fd, events, 128, 0);
        
        for (int i = 0; i < n; i++) {
            if (events[i].data.ptr == &srv->listen_fd) {
                submit_accept(srv);
            }
        }
        
        // 收割 io_uring CQE
        struct io_uring_cqe *cqe;
        unsigned head;
        int count = 0;
        
        io_uring_for_each_cqe(&srv->ring, head, cqe) {
            uint64_t data = cqe->user_data;
            enum op_type op = data >> 32;
            int cqe_fd = data & 0xFFFFFFFF;
            
            switch (op) {
            case OP_ACCEPT:
                if (cqe->res >= 0)
                    submit_read(srv, cqe->res);
                submit_accept(srv);
                break;
            case OP_READ:
                if (cqe->res > 0) {
                    srv->clients[cqe_fd].bytes_read = cqe->res;
                    handle_http_request(srv, cqe_fd);
                    submit_write(srv, cqe_fd);
                } else {
                    close(cqe_fd);
                }
                break;
            case OP_WRITE:
                submit_read(srv, cqe_fd);
                break;
            }
            if (++count > 64) break;
        }
        io_uring_cq_advance(&srv->ring, count);
    }
}

七、内核参数调优指南

7.1 io_uring 参数调优

# 增加最大队列条目数
sysctl -w kernel.io_uring_max_entries=65536

# 增加用户可用 pending 操作数
sysctl -w vm.max_map_count=524288

# SQPOLL 线程优先级(负值为实时调度)
chrt -f -p 50 $(pgrep io_uring-sq)

# 进程的 io_uring 数量限制
ulimit -l 65536

7.2 块设备与 NVMe 调优

# NVMe 多队列绑定
echo 0 > /sys/block/nvme0n1/queue/rq_affinity  # 不绑定 CPU
echo 256 > /sys/block/nvme0n1/queue/nr_requests  # 深队列
echo none > /sys/block/nvme0n1/queue/scheduler  # 无调度器

# 中断亲和性(与 SQ 线程错开)
echo "f" > /proc/irq/42/smp_affinity_list  # IRQ 42 → CPU 0-3
echo 4 > /proc/irq/43/smp_affinity_list     # IRQ 43 → CPU 4(SQ 线程用)

7.3 内存与页缓存优化

# 减少Swap(io_uring pinned pages 会被 OOM)
sysctl -w vm.swappiness=1

# HugePages 减少 TLB miss
echo 1024 > /proc/sys/vm/nr_hugepages

# 调整 dirty page 比例
sysctl -w vm.dirty_ratio=10
sysctl -w vm.dirty_background_ratio=5

八、性能监控与可观测性

8.1 读取 io_uring 统计信息

// 通过 /proc/<pid>/io_uring 查看运行时状态
$ cat /proc/1234/io_uring
// sq_cpu: 2 sq_total: 1024 sq_pending: 47
// cq_total: 2048 cq_pending: 12

// 关键指标计算:
// SQ 积压率 = sq_pending / sq_total = 47/1024 = 4.6%
// CQ 收割率 = cq_pending / cq_total = 12/2048 = 0.6%

8.2 使用 BPF 监控 io_uring 性能

// BPF 探测 io_uring 提交延迟
SEC("kprobe/io_uring_submit_sqe")
int trace_submit(struct pt_regs *ctx) {
    u64 ts = bpf_ktime_get_ns();
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    u64 *start = bpf_map_lookup_elem(&start_map, &pid);
    if (!start) {
        bpf_map_update_elem(&start_map, &pid, &ts, BPF_ANY);
    }
    return 0;
}

// 用户态 bpftrace 一行命令
bpftrace -e 'k:io_uring_submit_sqe { @start[tid] = nsecs; }
            k:io_uring_complete { @lat_us = hist((nsecs - @start[tid]) / 1000); }'

九、io_uring vs 其他框架对比分析

特性io_uring (SQPOLL)libaiolibuv/epolluring-sys (Rust)
系统调用次数/IO01 (io_submit)2 (epoll_wait + read)0
延迟(NVMe 4K)3-8μs40-100μs50-200μs3-8μs
IOPS(NVMe)1.8M+750K300K1.8M+
支持网络IO✅(5.19+)❌✅✅
缓冲区注册✅ Fixed Buffers❌✅✅
链式请求✅ IOSQE_IO_LINK❌❌✅
内核版本要求5.1+(最佳 5.19+)2.6+2.6+5.1+
语言生态C/rust/Go/Java(Rhun)CC++/JS(libuv)Rust

十、下一代 io_uring 演进方向

10.1 多缓冲区(Multi-Buffer)

Linux 6.9+ 支持单个操作使用多个独立缓冲区,网络场景下的 scatter-gather 无需额外系统调用。

10.2 硬件卸载与 SmartNIC

io_uring 与 XDP 协同,直接在网卡硬件中实现过滤和转发,用户态程序完全无关。

10.3 Rust 异步运行时

tokio-uring crate 已提供基于 io_uring 的 Rust 异步运行时,将 io_uring 的所有能力暴露为 Rust Future:

use tokio_uring::fs::File;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let file = File::open("data.bin").await?;
    let buf = vec![0u8; 4096];
    
    let (res, buf) = file.read_at(buf, 0).await;
    let n = res?;
    println!("读取 {} 字节(零系统调用)", n);
    Ok(())
}

10.4 io_uring 与 io_uring_cmd(字符设备)

io_uring_cmd 支持用户态通过 io_uring 直接发送 NVMe _ADMIN_COMMAND,绕过内核 NVMe 驱动栈。

十一、总结

io_uring 是 Linux 内核近二十年来最重要的 IO 架构革新,它将异步 IO 从「勉强可用的贵族」变成了「零系统调用的基础设施」。核心设计理念可以概括为三点:

  1. 共享环形队列:通过 mmap 实现用户态-内核态零拷贝通信
  2. 轮询模式:SQPOLL 下用户态只需更新内存指针,完全消除系统调用
  3. 预注册优化:Fixed Files + Registered Buffers 将每次 IO 的固定开销降到最低

在高性能存储、分布式数据库、CDN 边缘计算和网络服务器的场景中,io_uring 已成为事实上的标准。掌握 io_uring,就是掌握了 Linux 高性能 IO 编程的下一个十年。

主要参考资料:io_uring 官方文档、Jens Axboe 的 io_uring-talk 系列演讲、liburing 源码、Linux 内核 io_uring 子系统。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部