mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
6345 字
18 分钟
io_uring 与异步 IO 革命
2025-08-04

某数据库团队在压测时发现,基于 libaio 的异步 IO 在高并发下经常返回 EAGAIN,而同步 IO 又会阻塞线程。io_uring 在 Linux 5.1 中引入后,同一个工作负载的 IOPS 提升了 3 倍,CPU 占用降低了一半。io_uring 正在重新定义 Linux 的 IO 模型。

一、引言:io_uring——Linux 异步 I/O 的新范式#

第 2 章:内核旁通技术全景中,我们勾勒了高性能 I/O 的技术谱系——从 DPDK 的完全旁通到 XDP 的内核态加速。而在第 8 章:XDP 与 eBPF 高性能网络中,我们看到了在内核中插入可编程快速路径的威力。但如果你不想完全绕过内核,也不想只处理网络包,而是想让所有类型的 I/O——文件读写、网络收发、定时器、信号——都变得真正异步且高效呢?

答案就是 io_uring

io_uring 是 Linux 5.1(2019 年)由 Jens Axboe 设计并引入的统一异步 I/O 接口。它不是又一个 AIO 的补丁,而是一次彻底的重新设计:通过共享内存环形缓冲区实现用户空间与内核的零拷贝通信,通过批量提交消除系统调用开销,通过 SQPOLL 模式实现零系统调用的 I/O 操作。io_uring 不仅支持文件 I/O,还支持网络 I/O、定时器、信号、文件系统操作等几乎所有系统调用——它正在成为 Linux 的通用异步系统调用接口

Note

io_uring 的设计哲学可以概括为一句话:让异步 I/O 真正异步。Linux AIO 的最大问题是 buffered I/O 会退化为同步操作——提交时即阻塞,完全失去了”异步”的意义。io_uring 从架构层面保证了所有操作都是真正异步的:应用提交请求后立即返回,内核在后台执行 I/O,完成后通过完成队列异步通知应用。

本章将从 I/O 模型的演进出发,深入剖析 io_uring 的环形缓冲区架构、提交与完成流程、固定缓冲区与文件注册、网络 I/O 集成、SQPOLL 模式,最后与 epoll/AIO 进行全面的性能对比。

二、从同步到异步:I/O 模型的演进#

1.1 四代 I/O 模型#

Linux 的 I/O 模型经历了四代演进,每一代都在解决前一代的核心缺陷:

flowchart TB subgraph 第一代["第一代:Blocking I/O"] B1[read/write 阻塞等待] B2["问题:每个 I/O 阻塞一个线程<br/>线程开销大,C10K 瓶颈"] end subgraph 第二代["第二代:Non-blocking + epoll"] N1[非阻塞 fd + epoll_wait] N2["问题:仍需系统调用<br/>每次 I/O 至少一次 syscall"] end subgraph 第三代["第三代:Linux AIO"] A1[io_submit / io_getevents] A2["问题:buffered I/O 退化为同步<br/>API 复杂,拷贝开销大"] end subgraph 第四代["第四代:io_uring"] I1[共享环形缓冲区] I2["优势:真正异步 + 零拷贝<br/>批量提交 + 零系统调用"] end 第一代 -->|解决阻塞问题| 第二代 第二代 -->|减少系统调用| 第三代 第三代 -->|彻底异步化| 第四代 style 第一代 fill:#ffcdd2,stroke:#c62828 style 第二代 fill:#fff9c4,stroke:#f9a825 style 第三代 fill:#ffe0b2,stroke:#e65100 style 第四代 fill:#c8e6c9,stroke:#2e7d32

1.2 各代模型的局限#

Blocking I/O 是最简单的模型:read() 在数据就绪前阻塞调用线程,write() 在数据写入前阻塞。问题——要服务 10000 个并发连接,就需要 10000 个线程,而每个线程的栈空间(默认 8MB)就要消耗 80GB 内存。更糟糕的是,线程上下文切换的开销随并发数线性增长。

Non-blocking I/O + epoll 是目前最主流的高并发模型。将 fd 设为非阻塞模式(O_NONBLOCK),然后用 epoll_wait 等待就绪事件。这解决了线程数量问题——一个线程可以管理数千个连接。但每次 I/O 操作仍需要至少一次系统调用(epoll_wait + read/write),在高 IOPS 场景下系统调用开销成为瓶颈。此外,epoll 只支持网络 I/O 和管道,不支持文件 I/O 的异步通知。

Linux AIO 是内核 2.5 引入的异步 I/O 接口,本意是解决系统调用开销和阻塞问题。但它有三个致命缺陷:

  1. Buffered I/O 退化为同步:对于不使用 O_DIRECT 的文件 I/O,io_submit 会直接在调用线程中执行 I/O 操作并阻塞——完全失去了”异步”的意义。这是因为 Page Cache 的读操作可能缺页,写操作可能需要等待脏页写回,这些操作无法在 AIO 的异步框架中完成
  2. API 复杂且低效:需要先 io_setup 创建上下文,再 io_submit 提交 I/O,最后 io_getevents 获取完成事件——三次系统调用才能完成一次 I/O 操作
  3. 数据拷贝开销:I/O 提交和完成事件都需要在用户空间和内核之间拷贝数据,无法实现零拷贝通信
Warning

Linux AIO 的 buffered I/O 退化问题是一个架构性缺陷,无法通过修补解决。根本原因在于 AIO 的设计假设 I/O 操作可以在内核中独立完成,但 buffered I/O 的 Page Cache 命中/未命中行为依赖于调用者的内存上下文。io_uring 通过完全不同的架构——共享内存环形缓冲区——从根本上解决了这个问题。

1.3 io_uring 的设计目标#

io_uring 的设计目标明确而激进:

目标含义
真正异步所有 I/O 类型(buffered/direct、文件/网络)都是异步的,提交永不阻塞
零拷贝用户空间与内核通过共享内存通信,无数据拷贝
批量提交一次系统调用提交多个 I/O 请求,摊薄 syscall 开销
零系统调用SQPOLL 模式下,应用无需发起任何系统调用即可完成 I/O
统一接口支持文件 I/O、网络 I/O、定时器、信号、文件系统操作等所有操作类型
可扩展无锁设计,支持多线程并发提交和完成

二、io_uring 架构:环形缓冲区的精妙设计#

2.1 三大核心数据结构#

io_uring 的核心是两个共享内存环形缓冲区:提交队列(Submission Queue, SQ)和完成队列(Completion Queue, CQ)。应用和内核通过这两个环形缓冲区进行零拷贝通信。

三个核心数据结构:

  • SQE(Submission Queue Entry):提交队列条目,描述一个 I/O 请求。大小 64 字节,包含操作码、fd、地址、长度、偏移量等字段
  • CQE(Completion Queue Entry):完成队列条目,描述一个 I/O 完成事件。大小 16 字节,包含用户数据(user_data)和结果(res)
  • SQ/CQ 环形缓冲区:由 head 和 tail 指针管理的环形数组,应用和内核通过移动 head/tail 指针来生产/消费条目

2.2 SQ 与 CQ 的内存布局#

┌──────────────────────────────────────────────────────────────┐
│ 共享内存区域 │
│ │
│ ┌─ SQ 环形缓冲区 ──────────────────────────────────────┐ │
│ │ │ │
│ │ SQE 数组 (每个 64 字节) │ │
│ │ ┌──────┬──────┬──────┬──────┬──────┬──────┐ │ │
│ │ │ SQE0 │ SQE1 │ SQE2 │ SQE3 │ SQE4 │ ... │ │ │
│ │ └──────┴──────┴──────┴──────┴──────┴──────┘ │ │
│ │ │ │
│ │ SQ 索引数组 (uint32) │ │
│ │ ┌─────┬─────┬─────┬─────┬─────┬─────┐ │ │
│ │ │ 0 │ 1 │ 2 │ 3 │ 4 │ ... │ │ │
│ │ └─────┴─────┴─────┴─────┴─────┴─────┘ │ │
│ │ │ │
│ │ head (uint32) ← 内核消费位置 │ │
│ │ tail (uint32) ← 应用生产位置 │ │
│ │ ring_mask (uint32) │ │
│ │ ring_entries (uint32) │ │
│ │ flags (uint32) │ │
│ │ dropped (uint32) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌─ CQ 环形缓冲区 ──────────────────────────────────────┐ │
│ │ │ │
│ │ CQE 数组 (每个 16 字节) │ │
│ │ ┌──────┬──────┬──────┬──────┬──────┬──────┐ │ │
│ │ │ CQE0 │ CQE1 │ CQE2 │ CQE3 │ CQE4 │ ... │ │ │
│ │ └──────┴──────┴──────┴──────┴──────┴──────┘ │ │
│ │ │ │
│ │ head (uint32) ← 应用消费位置 │ │
│ │ tail (uint32) ← 内核生产位置 │ │
│ │ ring_mask (uint32) │ │
│ │ ring_entries (uint32) │ │
│ │ overflow (uint32) │ │
│ │ cqes (uint32) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘

2.3 环形缓冲区的无锁交互#

SQ 和 CQ 的核心是 head/tail 指针的单向移动,这种设计天然支持无锁的单生产者-单消费者交互:

flowchart LR subgraph SQ["提交队列 (SQ)"] direction TB SQ_TAIL["tail ← 应用写入<br/>提交新 SQE 后递增"] SQ_HEAD["head ← 内核读取<br/>消费 SQE 后递增"] SQ_ARRAY["SQE 索引数组<br/>[0][1][2][3][4][5][6][7]"] end subgraph CQ["完成队列 (CQ)"] direction TB CQ_TAIL["tail ← 内核写入<br/>产生新 CQE 后递增"] CQ_HEAD["head ← 应用读取<br/>消费 CQE 后递增"] CQ_ARRAY["CQE 数组<br/>[0][1][2][3][4][5][6][7]"] end SQ_TAIL -->|"应用生产 SQE"| SQ_ARRAY SQ_HEAD -->|"内核消费 SQE"| SQ_ARRAY CQ_TAIL -->|"内核生产 CQE"| CQ_ARRAY CQ_HEAD -->|"应用消费 CQE"| CQ_ARRAY style SQ fill:#e3f2fd,stroke:#1565c0 style CQ fill:#e8f5e9,stroke:#2e7d32

关键规则:

  • SQ:应用是生产者(写入 SQE,递增 tail),内核是消费者(读取 SQE,递增 head)
  • CQ:内核是生产者(写入 CQE,递增 tail),应用是消费者(读取 CQE,递增 head)
  • 满/空判断head == tail 表示队列为空;(tail + 1) & ring_mask == head 表示队列已满
  • 内存序:应用递增 SQ tail 前必须确保 SQE 数据对内核可见(使用 smp_store_release);内核递增 CQ tail 前必须确保 CQE 数据对应用可见
Note

SQ 的设计有一个巧妙之处:SQE 数组是固定的,但 SQ 索引数组是间接的。应用在索引数组中按顺序填写 SQE 的下标,内核按索引数组的顺序消费 SQE。这种间接层允许应用在任意位置填充 SQE(无需连续),而内核始终按提交顺序处理请求。

2.4 SQE 与 CQE 的结构#

// include/uapi/linux/io_uring.h(简化)
struct io_uring_sqe {
__u8 opcode; /* 操作码,如 IORING_OP_READ, IORING_OP_WRITE */
__u8 flags; /* SQE 标志,如 IOSQE_FIXED_FILE */
__u16 ioprio; /* I/O 优先级 */
__s32 fd; /* 文件描述符 */
__u64 off; /* 偏移量(对文件 I/O)或 addr(对网络 I/O) */
__u64 addr; /* 缓冲区地址或目标地址 */
__u32 len; /* 缓冲区长度 */
union {
__kernel_rwf_t rw_flags; /* read/write 标志 */
__u32 fsync_flags; /* fsync 标志 */
__u16 poll_events; /* poll 事件 */
__u32 sync_range_flags; /* sync_range 标志 */
__u32 msg_flags; /* send/recv 标志 */
__u32 timeout_flags; /* timeout 标志 */
__u32 accept_flags; /* accept 标志 */
/* ... 更多操作特定标志 ... */
};
__u64 user_data; /* 用户数据,完成时原样返回到 CQE */
union {
__u16 buf_index; /* provided buffer 选择索引 */
__u16 buf_group; /* provided buffer 组 */
};
union {
__s32 splice_fd_in; /* splice 输入 fd */
__u32 file_index; /* 固定文件索引 */
};
__u64 addr3; /* 额外地址字段 */
__u64 __pad2[1]; /* 填充到 64 字节 */
};
struct io_uring_cqe {
__u64 user_data; /* 对应 SQE 的 user_data,用于匹配请求 */
__s32 res; /* I/O 结果:成功返回字节数,失败返回 -errno */
__u32 flags; /* 完成标志(如 buffer 选择、multishot 等) */
};

关键字段解读:

  • opcode:操作码,决定这个 SQE 执行什么操作。io_uring 支持的操作码从最初的几个扩展到现在的数十个,覆盖了几乎所有系统调用
  • user_data:用户自定义数据,内核在完成时将其原样拷贝到 CQE 中。这是应用关联请求与完成事件的唯一机制——不依赖 fd 或地址来匹配
  • res:I/O 结果。成功时返回读写的字节数,失败时返回负的错误号(如 -EAGAIN-EINVAL
Warning

user_data 是 64 位无符号整数,不是指针。虽然你可以将指针强制转换为 __u64 存储,但在 32 位系统上需要注意对齐问题。更重要的是,不要假设 user_data 有任何特定含义——它只是应用和内核之间的”回执编号”。

三、提交与完成:一次 I/O 的完整旅程#

3.1 完整流程#

一次 io_uring I/O 操作经历以下步骤:

sequenceDiagram participant APP as 应用程序 participant SQ as 提交队列 (SQ) participant KERNEL as 内核 participant CQ as 完成队列 (CQ) Note over APP: 1. 准备 SQE APP->>SQ: 获取空闲 SQE<br/>io_uring_get_sqe() APP->>SQ: 填充 SQE 字段<br/>io_uring_prep_read() APP->>SQ: 设置 user_data Note over APP: 2. 提交请求 APP->>SQ: 递增 SQ tail APP->>KERNEL: io_uring_enter()<br/>(或 SQPOLL 自动消费) Note over KERNEL: 3. 内核处理 KERNEL->>SQ: 读取 SQ head → tail 之间的 SQE KERNEL->>KERNEL: 执行 I/O 操作 KERNEL->>SQ: 递增 SQ head Note over KERNEL: 4. 完成通知 KERNEL->>CQ: 填充 CQE (user_data + res) KERNEL->>CQ: 递增 CQ tail Note over APP: 5. 收割结果 APP->>CQ: 检查 CQ head → tail 之间的 CQE APP->>CQ: 递增 CQ head<br/>io_uring_cqe_seen()

3.2 步骤详解#

步骤 1:准备 SQE

应用从 SQ 中获取一个空闲的 SQE 槽位。liburing 提供了 io_uring_get_sqe() 函数,它会返回下一个可用的 SQE 指针。如果 SQ 已满(所有 SQE 都在被内核处理),则返回 NULL。

步骤 2:填充 SQE

使用 liburing 的 io_uring_prep_* 系列函数填充 SQE 字段。每个操作都有对应的 prep 函数:

io_uring_prep_read(sqe, fd, buf, len, offset); // 异步读
io_uring_prep_write(sqe, fd, buf, len, offset); // 异步写
io_uring_prep_accept(sqe, fd, addr, addrlen, flags); // 异步 accept
io_uring_prep_send(sqe, fd, buf, len, flags); // 异步 send
io_uring_prep_recv(sqe, fd, buf, len, flags); // 异步 recv
io_uring_prep_connect(sqe, fd, addr, addrlen); // 异步 connect
io_uring_prep_openat(sqe, dfd, path, flags, mode); // 异步 openat
io_uring_prep_close(sqe, fd); // 异步 close
io_uring_prep_fsync(sqe, fd, flags); // 异步 fsync
io_uring_prep_timeout(sqe, ts, count, flags); // 定时器
io_uring_prep_poll_add(sqe, fd, poll_mask); // 异步 poll

步骤 3:提交请求

应用递增 SQ 的 tail 指针,然后调用 io_uring_enter() 通知内核有新的请求需要处理。关键点:一次 io_uring_enter() 可以提交多个 SQE——应用可以先填充多个 SQE,然后一次性提交,摊薄系统调用开销。

步骤 4:内核处理

内核从 SQ 中消费 SQE,执行对应的 I/O 操作。对于文件 I/O,内核会提交 bio 请求到块设备层;对于网络 I/O,内核会调用协议栈的收发函数。I/O 完成后,内核将结果写入 CQE 并递增 CQ 的 tail 指针。

步骤 5:收割结果

应用检查 CQ 的 head 和 tail 指针。如果 tail != head,说明有新的完成事件。应用读取 CQE 获取结果,然后递增 CQ 的 head 指针(io_uring_cqe_seen()),告知内核这个 CQE 槽位可以被复用。

3.3 代码示例:基本的 io_uring 文件读取#

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <liburing.h>
#define QUEUE_DEPTH 4
#define BLOCK_SIZE 4096
int main(int argc, char *argv[])
{
if (argc < 2) {
fprintf(stderr, "用法: %s <文件路径>\n", argv[0]);
return 1;
}
// 1. 初始化 io_uring
struct io_uring ring;
int ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
if (ret < 0) {
fprintf(stderr, "io_uring_queue_init 失败: %s\n", strerror(-ret));
return 1;
}
// 2. 打开文件
int fd = open(argv[1], O_RDONLY);
if (fd < 0) {
perror("open");
io_uring_queue_exit(&ring);
return 1;
}
// 3. 分配读取缓冲区
char *buf = malloc(BLOCK_SIZE);
if (!buf) {
perror("malloc");
close(fd);
io_uring_queue_exit(&ring);
return 1;
}
// 4. 获取空闲 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
fprintf(stderr, "无法获取 SQE\n");
free(buf);
close(fd);
io_uring_queue_exit(&ring);
return 1;
}
// 5. 准备异步读请求
io_uring_prep_read(sqe, fd, buf, BLOCK_SIZE, 0);
// 设置 user_data,用于在完成时识别请求
io_uring_sqe_set_data64(sqe, 0x1234);
// 6. 提交请求
ret = io_uring_submit(&ring);
if (ret < 0) {
fprintf(stderr, "io_uring_submit 失败: %s\n", strerror(-ret));
free(buf);
close(fd);
io_uring_queue_exit(&ring);
return 1;
}
// 7. 等待完成
struct io_uring_cqe *cqe;
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
fprintf(stderr, "io_uring_wait_cqe 失败: %s\n", strerror(-ret));
free(buf);
close(fd);
io_uring_queue_exit(&ring);
return 1;
}
// 8. 处理结果
if (cqe->res >= 0) {
printf("成功读取 %d 字节, user_data=0x%lx\n",
cqe->res, (unsigned long)io_uring_cqe_get_data64(cqe));
// 可以处理 buf 中的数据
if (cqe->res > 0) {
buf[cqe->res < BLOCK_SIZE ? cqe->res : BLOCK_SIZE - 1] = '\0';
printf("内容前 64 字节: %.64s\n", buf);
}
} else {
fprintf(stderr, "读取失败: %s\n", strerror(-cqe->res));
}
// 9. 通知内核 CQE 已消费
io_uring_cqe_seen(&ring, cqe);
// 10. 清理
free(buf);
close(fd);
io_uring_queue_exit(&ring);
return 0;
}

编译与运行:

# 编译(需要安装 liburing-dev)
gcc -o io_uring_read io_uring_read.c -luring
# 运行
./io_uring_read /etc/passwd
# 输出示例:
# 成功读取 2847 字节, user_data=0x1234
# 内容前 64 字节: root:x:0:0:root:/root:/bin/bash
Note

io_uring_wait_cqe() 会阻塞等待直到有 CQE 可用。如果你不想阻塞,可以使用 io_uring_peek_cqe() 进行非阻塞检查——它立即返回,如果没有 CQE 则返回 -EAGAIN。在实际的高性能应用中,通常使用 io_uring_peek_cqe() 在事件循环中轮询 CQE,避免阻塞。

3.4 批量提交:io_uring 的性能关键#

io_uring 的一个核心优势是批量提交。应用可以先填充多个 SQE,然后一次 io_uring_submit() 提交所有请求:

// 批量提交示例:同时发起多个读请求
#define NR_IOS 16
for (int i = 0; i < NR_IOS; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, bufs[i], BLOCK_SIZE, i * BLOCK_SIZE);
io_uring_sqe_set_data64(sqe, i); // 用索引标识请求
}
// 一次系统调用提交所有请求
int submitted = io_uring_submit(&ring);
printf("提交了 %d 个请求\n", submitted);
// 批量收割完成事件
for (int i = 0; i < NR_IOS; i++) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
printf("请求 %ld 完成: %d 字节\n",
(long)io_uring_cqe_get_data64(cqe), cqe->res);
io_uring_cqe_seen(&ring, cqe);
}

批量提交的性能影响是巨大的:假设一次系统调用开销为 1μs,提交 16 个请求时,每个请求的均摊开销仅为 0.0625μs——比传统的每次 I/O 一次系统调用快 16 倍。

四、固定缓冲区与文件注册#

4.1 为什么需要注册?#

每次 I/O 操作,内核都需要完成以下工作:

  1. 查找文件描述符:从进程的 fd 表中查找 struct file,增加引用计数
  2. 映射用户缓冲区:通过 get_user_pages() 将用户空间缓冲区映射到内核空间,建立 DMA 映射

这些操作虽然单次开销不大(几百纳秒到几微秒),但在高 IOPS 场景下(如 NVMe SSD 可达数百万 IOPS),累积开销非常可观。

io_uring 提供了两个注册机制来消除这些开销:

4.2 IORING_REGISTER_BUFFERS:固定缓冲区注册#

通过 IORING_REGISTER_BUFFERS 预先注册一组缓冲区,内核会提前完成 get_user_pages() 和 DMA 映射。后续 I/O 操作直接使用预映射的缓冲区,跳过每次 I/O 的页表查找和映射操作。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <liburing.h>
#define QUEUE_DEPTH 4
#define NR_BUFFERS 4
#define BLOCK_SIZE 4096
int main()
{
struct io_uring ring;
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
// 1. 分配对齐的缓冲区(注册缓冲区必须页对齐)
struct iovec iovecs[NR_BUFFERS];
for (int i = 0; i < NR_BUFFERS; i++) {
void *buf;
posix_memalign(&buf, 4096, BLOCK_SIZE);
iovecs[i].iov_base = buf;
iovecs[i].iov_len = BLOCK_SIZE;
}
// 2. 注册缓冲区
int ret = io_uring_register_buffers(&ring, iovecs, NR_BUFFERS);
if (ret < 0) {
fprintf(stderr, "注册缓冲区失败: %s\n", strerror(-ret));
return 1;
}
printf("成功注册 %d 个缓冲区\n", NR_BUFFERS);
// 3. 使用固定缓冲区进行 I/O
int fd = open("data.bin", O_RDONLY | O_DIRECT);
if (fd < 0) {
perror("open");
return 1;
}
// 使用 io_uring_prep_read_fixed 替代 io_uring_prep_read
// 注意:buf_index 参数指定使用哪个注册缓冲区
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, iovecs[0].iov_base,
BLOCK_SIZE, 0, 0); // buf_index = 0
io_uring_sqe_set_data64(sqe, 0);
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, iovecs[1].iov_base,
BLOCK_SIZE, BLOCK_SIZE, 1); // buf_index = 1
io_uring_sqe_set_data64(sqe, 1);
io_uring_submit(&ring);
// 4. 收割结果
for (int i = 0; i < 2; i++) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
printf("缓冲区 %ld: 读取 %d 字节\n",
(long)io_uring_cqe_get_data64(cqe), cqe->res);
io_uring_cqe_seen(&ring, cqe);
}
// 5. 清理
io_uring_unregister_buffers(&ring);
for (int i = 0; i < NR_BUFFERS; i++)
free(iovecs[i].iov_base);
close(fd);
io_uring_queue_exit(&ring);
return 0;
}

4.3 IORING_REGISTER_FILES:固定文件注册#

通过 IORING_REGISTER_FILES 预先注册一组文件描述符,内核会提前完成 fd 查找和引用计数操作。后续 I/O 操作使用文件索引(而非 fd)来指定文件,跳过每次 I/O 的 fd 查找。

// 注册文件描述符
int fds[NR_FILES];
fds[0] = open("file1.bin", O_RDONLY | O_DIRECT);
fds[1] = open("file2.bin", O_RDONLY | O_DIRECT);
fds[2] = open("file3.bin", O_RDONLY | O_DIRECT);
int ret = io_uring_register_files(&ring, fds, NR_FILES);
if (ret < 0) {
fprintf(stderr, "注册文件失败: %s\n", strerror(-ret));
return 1;
}
// 使用固定文件进行 I/O
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buf, BLOCK_SIZE, 0); // fd=0 会被忽略
sqe->flags |= IOSQE_FIXED_FILE; // 标记使用固定文件索引
sqe->fd = 0; // 使用注册文件索引 0(而非 fd)
// liburing 提供了更方便的接口
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 1, buf, BLOCK_SIZE, 0); // 索引 1
sqe->flags |= IOSQE_FIXED_FILE;
// 清理
io_uring_unregister_files(&ring);

4.4 性能影响#

固定缓冲区和文件注册的性能提升取决于 I/O 模式:

场景无注册有注册提升幅度
随机 4K 读(NVMe SSD)~2.0M IOPS~2.6M IOPS~30%
顺序 4K 读(NVMe SSD)~2.2M IOPS~2.8M IOPS~27%
网络收发(小包)~1.5Mpps~1.8Mpps~20%
Note

注册缓冲区有一个重要限制:注册后,缓冲区的物理页会被钉住(pinned),内核无法将其换出到 swap 或迁移。这意味着注册大量缓冲区会占用大量不可回收的物理内存。在内存紧张的系统上,需要谨慎控制注册缓冲区的总量。

五、网络 I/O:io_uring 的网络栈集成#

5.1 网络操作码#

io_uring 支持完整的网络 I/O 操作,可以替代传统的 socket()/bind()/listen()/accept()/send()/recv()/connect() 系统调用链:

操作码替代系统调用说明
IORING_OP_SOCKETsocket()创建套接字(5.19+)
IORING_OP_BINDbind()绑定地址(5.19+)
IORING_OP_LISTENlisten()开始监听(5.19+)
IORING_OP_ACCEPTaccept4()异步接受连接
IORING_OP_RECVrecv()/read()异步接收数据
IORING_OP_SENDsend()/write()异步发送数据
IORING_OP_CONNECTconnect()异步发起连接
IORING_OP_OPENATopenat()异步打开文件
IORING_OP_CLOSEclose()异步关闭 fd
IORING_OP_RECVMSGrecvmsg()异步 recvmsg
IORING_OP_SENDMSGsendmsg()异步 sendmsg
IORING_OP_POLL_ADDpoll()异步 poll

5.2 Multishot Accept:一次提交,持续接受#

传统的 accept() 每次只能接受一个连接,接受后需要重新提交。在高并发连接场景下,这意味着大量的 SQE 填充和提交操作。

Multishot accept 是 io_uring 5.19 引入的特性:提交一个 SQE 后,内核会持续产生完成事件——每接受一个新连接就产生一个 CQE,无需应用重复提交。

// 传统方式:每次 accept 需要重新提交
while (1) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, listen_fd,
(struct sockaddr *)&addr, &addrlen, 0);
io_uring_sqe_set_data64(sqe, OP_ACCEPT);
io_uring_submit(&ring);
// 等待完成
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int client_fd = cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (client_fd >= 0) {
// 处理新连接...
}
}
// Multishot 方式:一次提交,持续接受
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd,
(struct sockaddr *)&addr, &addrlen, 0);
io_uring_sqe_set_data64(sqe, OP_MULTISHOT_ACCEPT);
io_uring_submit(&ring);
// 之后每接受一个连接,CQ 中就会出现一个 CQE
while (1) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (cqe->res >= 0) {
int client_fd = cqe->res;
printf("新连接 fd=%d\n", client_fd);
// 处理新连接...
}
// 检查是否是 multishot 的最后一个 CQE
if (!(cqe->flags & IORING_CQE_F_MORE)) {
// multishot 请求结束,需要重新提交
break;
}
io_uring_cqe_seen(&ring, cqe);
}
Note

Multishot 不仅支持 accept,还支持 poll_addrecv 等操作。其核心思想是:一次提交,多次完成。CQE 的 flags 字段中的 IORING_CQE_F_MORE 标志表示”还有更多完成事件即将到来”,应用无需重新提交。当该标志未设置时,表示 multishot 请求已结束(可能是出错或被取消),需要重新提交。

5.3 Provided Buffers:按需分配缓冲区#

网络服务器面临一个经典问题:不知道每个连接会发送多少数据,预先为每个连接分配大缓冲区浪费内存,分配小缓冲区又可能不够用。

Provided Buffers 机制允许应用预先向 io_uring 注册一组缓冲区,当 recv 操作有数据到达时,内核自动从缓冲区池中选择一个缓冲区来存放数据,并在 CQE 中告知应用使用了哪个缓冲区。

// 1. 注册 provided buffer 组
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_provide_buffers(sqe, bufs, BLOCK_SIZE, NR_BUFS,
BGID, 0); // BGID = buffer group ID
io_uring_submit(&ring);
// 2. 使用 provided buffer 接收数据
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, fd, NULL, 0, 0); // addr=NULL, len=0
sqe->flags |= IOSQE_BUFFER_SELECT; // 选择缓冲区
sqe->buf_group = BGID; // 指定缓冲区组
io_uring_sqe_set_data64(sqe, OP_RECV);
// 3. 收割时获取缓冲区信息
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (cqe->res > 0 && (cqe->flags & IORING_CQE_F_BUFFER)) {
int buf_idx = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
printf("接收到 %d 字节,缓冲区索引 %d\n", cqe->res, buf_idx);
// 处理 bufs[buf_idx] 中的数据...
// 处理完毕后,将缓冲区归还给 io_uring
sqe = io_uring_get_sqe(&ring);
io_uring_prep_provide_buffers(sqe, bufs[buf_idx], BLOCK_SIZE,
1, BGID, buf_idx);
io_uring_submit(&ring);
}

5.4 完整示例:io_uring TCP Echo 服务器#

以下是一个使用 io_uring 实现的 TCP Echo 服务器,综合运用了 accept、recv、send 和 multishot 特性:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <liburing.h>
#define QUEUE_DEPTH 256
#define BUF_SIZE 4096
#define BUF_GROUP_ID 1
// 操作类型标识
enum {
OP_ACCEPT = 1,
OP_RECV,
OP_SEND,
};
// 连接上下文
struct conn_ctx {
int fd;
char buf[BUF_SIZE];
};
int main(int argc, char *argv[])
{
int port = 8080;
if (argc > 1) port = atoi(argv[1]);
// 1. 创建监听套接字
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket"); return 1; }
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
.sin_addr.s_addr = INADDR_ANY,
};
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); return 1;
}
if (listen(listen_fd, 128) < 0) {
perror("listen"); return 1;
}
printf("监听端口 %d...\n", port);
// 2. 初始化 io_uring
struct io_uring ring;
int ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
if (ret < 0) {
fprintf(stderr, "io_uring_queue_init 失败: %s\n", strerror(-ret));
return 1;
}
// 3. 提交初始 accept 请求
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
io_uring_prep_accept(sqe, listen_fd,
(struct sockaddr *)&client_addr, &client_len, 0);
io_uring_sqe_set_data64(sqe, OP_ACCEPT);
// 4. 事件循环
while (1) {
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
fprintf(stderr, "wait_cqe 失败: %s\n", strerror(-ret));
continue;
}
unsigned long op_type = io_uring_cqe_get_data64(cqe);
switch (op_type) {
case OP_ACCEPT: {
int client_fd = cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (client_fd < 0) {
fprintf(stderr, "accept 失败: %s\n", strerror(-client_fd));
break;
}
printf("新连接 fd=%d\n", client_fd);
// 为新连接提交 recv 请求
struct conn_ctx *ctx = malloc(sizeof(*ctx));
ctx->fd = client_fd;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, client_fd, ctx->buf, BUF_SIZE, 0);
io_uring_sqe_set_data64(sqe, OP_RECV);
sqe->user_data = (unsigned long)ctx;
// 重新提交 accept
sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, listen_fd,
(struct sockaddr *)&client_addr,
&client_len, 0);
io_uring_sqe_set_data64(sqe, OP_ACCEPT);
break;
}
case OP_RECV: {
struct conn_ctx *ctx = (struct conn_ctx *)cqe->user_data;
int nbytes = cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (nbytes <= 0) {
// 连接关闭或出错
if (nbytes == 0) {
printf("fd=%d 连接关闭\n", ctx->fd);
} else {
fprintf(stderr, "recv 失败 fd=%d: %s\n",
ctx->fd, strerror(-nbytes));
}
close(ctx->fd);
free(ctx);
break;
}
// Echo 回去:提交 send 请求
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, ctx->fd, ctx->buf, nbytes, 0);
io_uring_sqe_set_data64(sqe, OP_SEND);
sqe->user_data = (unsigned long)ctx;
break;
}
case OP_SEND: {
struct conn_ctx *ctx = (struct conn_ctx *)cqe->user_data;
int nbytes = cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (nbytes < 0) {
fprintf(stderr, "send 失败 fd=%d: %s\n",
ctx->fd, strerror(-nbytes));
close(ctx->fd);
free(ctx);
break;
}
// 发送完成,继续接收
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, ctx->fd, ctx->buf, BUF_SIZE, 0);
io_uring_sqe_set_data64(sqe, OP_RECV);
sqe->user_data = (unsigned long)ctx;
break;
}
default:
io_uring_cqe_seen(&ring, cqe);
break;
}
}
io_uring_queue_exit(&ring);
close(listen_fd);
return 0;
}

编译与测试:

# 编译
gcc -o io_uring_echo_server io_uring_echo_server.c -luring
# 启动服务器
./io_uring_echo_server 9000
# 输出:监听端口 9000...
# 在另一个终端测试
echo "hello io_uring" | nc localhost 9000
# 输出:hello io_uring
# 使用压测工具测试吞吐
# 安装 wrk
sudo apt install wrk
wrk -t4 -c1000 -d10s http://localhost:9000/

5.5 异步 Connect 与 Open#

io_uring 不仅支持 accept/recv/send,还支持 connectopenat/close——这意味着即使是连接建立和文件打开这些传统上阻塞的操作,也可以异步化:

// 异步 connect
struct sockaddr_in server_addr = {
.sin_family = AF_INET,
.sin_port = htons(80),
.sin_addr.s_addr = inet_addr("93.184.216.34"), // example.com
};
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
sqe = io_uring_get_sqe(&ring);
io_uring_prep_connect(sqe, sockfd,
(struct sockaddr *)&server_addr, sizeof(server_addr));
io_uring_sqe_set_data64(sqe, OP_CONNECT);
io_uring_submit(&ring);
// 异步 openat
sqe = io_uring_get_sqe(&ring);
io_uring_prep_openat(sqe, AT_FDCWD, "/tmp/output.dat",
O_WRONLY | O_CREAT | O_TRUNC, 0644);
io_uring_sqe_set_data64(sqe, OP_OPENAT);
// 异步 close
sqe = io_uring_get_sqe(&ring);
io_uring_prep_close(sqe, fd_to_close);
io_uring_sqe_set_data64(sqe, OP_CLOSE);
Warning

异步 connect 有一个微妙之处:CQE 返回 res=0 并不意味着连接已建立——它只表示 connect 操作已发起。对于非阻塞 connect,你需要等待 socket 可写后再检查 SO_ERROR 确认连接是否成功。不过,io_uring 的 IORING_OP_CONNECT 在完成时已经确认了连接状态,res=0 即表示连接成功,res=-EINPROGRESS 表示连接正在进行中。

六、SQPOLL:内核线程轮询模式#

6.1 为什么需要 SQPOLL?#

到目前为止,讨论的 io_uring 模式都需要应用调用 io_uring_enter() 来通知内核有新的 SQE 需要处理。虽然批量提交可以摊薄系统调用开销,但在超低延迟场景下(如高频交易、实时音视频),即使是偶尔的系统调用也可能导致延迟毛刺。

SQPOLL(Submission Queue Polling) 模式解决了这个问题:内核创建一个专用线程,持续轮询 SQ 中的新请求。应用只需填充 SQE 并递增 tail 指针,无需任何系统调用——内核线程会自动发现并处理新请求。

6.2 SQPOLL 的工作原理#

flowchart TB subgraph 用户空间 APP[应用程序] -->|1. 填充 SQE| SQ[SQ 环形缓冲区] APP -->|2. 递增 tail| SQ NOTE["无需 io_uring_enter()!"] end subgraph 内核空间 SQPOLL_THREAD[SQPOLL 内核线程] -->|3. 轮询 SQ tail| SQ SQPOLL_THREAD -->|4. 消费 SQE| KERNEL_IO[执行 I/O 操作] KERNEL_IO -->|5. 产生 CQE| CQ[CQ 环形缓冲区] end APP -->|6. 读取 CQE| CQ style SQPOLL_THREAD fill:#ffcc99,stroke:#e65100 style NOTE fill:#c8e6c9,stroke:#2e7d32

SQPOLL 线程的生命周期:

  1. 创建:应用在 io_uring_setup() 时设置 IORING_SETUP_SQPOLL 标志,内核创建一个专用内核线程
  2. 活跃轮询:SQPOLL 线程持续轮询 SQ 的 tail 指针,发现新请求后立即处理
  3. 空闲休眠:如果 SQ 一段时间内没有新请求(由 sq_thread_idle 参数控制,默认 1 秒),SQPOLL 线程进入休眠
  4. 唤醒:SQPOLL 线程休眠后,SQ 的 flags 字段会设置 IORING_SQ_NEED_WAKEUP 标志。此时应用需要调用 io_uring_enter() 并设置 IORING_ENTER_SQ_WAKEUP 标志来唤醒 SQPOLL 线程

6.3 SQPOLL 代码示例#

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <liburing.h>
#define QUEUE_DEPTH 64
int main()
{
struct io_uring ring;
// 初始化 io_uring,启用 SQPOLL 模式
// sq_thread_idle: SQPOLL 线程空闲多少毫秒后休眠
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 2 秒空闲后休眠
int ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);
if (ret < 0) {
fprintf(stderr, "io_uring_queue_init_params 失败: %s\n",
strerror(-ret));
return 1;
}
printf("SQPOLL 模式已启用,内核线程 TID=%d\n", params.sq_thread_cpu);
int fd = open("data.bin", O_RDONLY | O_DIRECT);
if (fd < 0) { perror("open"); return 1; }
void *buf;
posix_memalign(&buf, 4096, 4096);
// 提交 I/O 请求——无需 io_uring_submit()!
// SQPOLL 线程会自动发现新请求
for (int i = 0; i < 16; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 4096, i * 4096);
io_uring_sqe_set_data64(sqe, i);
}
// 在 SQPOLL 模式下,io_uring_submit() 仍然可以调用
// 但它只检查是否需要唤醒 SQPOLL 线程,不会发起系统调用
// 如果 SQPOLL 线程正在轮询,这是一个空操作
ret = io_uring_submit(&ring);
printf("提交返回值: %d\n", ret);
// 检查 SQPOLL 线程是否需要唤醒
if (io_uring_sq_ready(&ring) > 0) {
// 还有未处理的 SQE,可能 SQPOLL 线程已休眠
unsigned int *sq_flags = io_uring_get_sqe_flags(&ring);
// 需要手动唤醒
ret = io_uring_enter(&ring, 0, 0, IORING_ENTER_SQ_WAKEUP);
if (ret < 0) {
fprintf(stderr, "唤醒 SQPOLL 失败: %s\n", strerror(-ret));
}
}
// 收割完成事件
for (int i = 0; i < 16; i++) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
printf("请求 %ld: %d 字节\n",
(long)io_uring_cqe_get_data64(cqe), cqe->res);
io_uring_cqe_seen(&ring, cqe);
}
free(buf);
close(fd);
io_uring_queue_exit(&ring);
return 0;
}

6.4 SQPOLL 的适用场景#

场景是否推荐 SQPOLL原因
高频交易推荐超低延迟要求,系统调用毛刺不可接受
NVMe SSD 高 IOPS推荐I/O 速率极高,系统调用开销占比大
普通网络服务器需谨慎独占一个 CPU 核心,资源开销大
低并发应用不推荐轮询空转浪费 CPU,不如默认模式
多线程共享 ring不推荐SQPOLL 设计为单线程独占
Warning

SQPOLL 模式有一个重要的资源代价:它独占一个 CPU 核心用于轮询。在 CPU 核心数有限的系统上,这意味着少了一个核心用于实际业务逻辑。此外,SQPOLL 线程绑定到特定 CPU 核心(由 sq_thread_cpu 参数指定),如果该核心被其他高优先级任务抢占,I/O 延迟会受影响。在使用 SQPOLL 前,务必通过基准测试验证其收益是否超过 CPU 占用代价。

七、与 epoll/AIO 的性能对比#

7.1 io_uring vs epoll:网络 I/O#

epoll 是目前 Linux 网络服务器的标准 I/O 多路复用机制。io_uring 在网络 I/O 场景下的性能优势主要来自三个方面:

  1. 批量提交:epoll 每次就绪事件后仍需逐个调用 read/write,io_uring 可以批量提交多个 recv/send 请求
  2. 零系统调用:SQPOLL 模式下,recv/send 完全不需要系统调用
  3. 减少上下文切换:epoll 的 epoll_waitreadepoll_wait 循环涉及多次用户态/内核态切换,io_uring 一次提交/收割循环只需一次

典型的基准测试结果(TCP 回显服务器,单核):

指标epollio_uringio_uring + SQPOLL
吞吐量 (req/s)~850K~1.8M~2.5M
P50 延迟 (μs)~35~18~12
P99 延迟 (μs)~120~55~35
系统调用次数/秒~1.7M~180K~0
CPU 利用率95%90%99%(含轮询线程)

7.2 io_uring vs AIO:磁盘 I/O#

Linux AIO 是 io_uring 的前身,在 Direct I/O 场景下可以提供异步行为。但 io_uring 在各方面都优于 AIO:

指标Linux AIOio_uring
4K 随机读 IOPS (NVMe)~1.5M~2.6M
4K 随机写 IOPS (NVMe)~1.2M~2.4M
Buffered I/O退化为同步真正异步
系统调用开销3 次/请求1 次/批量
数据拷贝需要零拷贝
操作类型read/writeread/write/fsync/open/close/…

7.3 全面特性对比#

特性epollLinux AIOio_uring
内核版本2.5.44+2.5.32+5.1+
Buffered I/O(同步)(退化为同步)(真正异步)
Direct I/O(同步)(异步)(异步)
网络 I/O
文件系统操作(openat/close/statx/…)
定时器
信号
批量提交有限
零拷贝通信
零系统调用(SQPOLL)
Multishot
链式请求(IOSQE_IO_LINK)
固定缓冲区
固定文件
取消请求有限
代码复杂度中(liburing 简化)
生态成熟度极高快速增长中

7.4 何时选择 io_uring?#

io_uring 并非在所有场景下都是最佳选择。以下是选型建议:

场景推荐方案原因
高并发网络服务器(C10K+)io_uring批量提交和零拷贝优势明显
NVMe SSD 高 IOPS 存储io_uring + O_DIRECT固定缓冲区 + 批量提交 = 极致 IOPS
普通网络应用(<1K 连接)epoll生态成熟,调试工具丰富,性能够用
需要 POSIX 兼容性epoll/AIOio_uring 是 Linux 专有接口
跨平台应用epoll/kqueue/IOCPio_uring 仅限 Linux 5.1+
高频交易/超低延迟io_uring + SQPOLL零系统调用消除延迟毛刺
数据库 WAL 写入io_uring + O_DIRECT + fsync异步 fsync + 批量提交
Note

io_uring 的生态正在快速发展。Linux 6.x 内核已经将越来越多的系统调用纳入 io_uring 的异步框架,包括 futexftruncatemkdiratsymlinkatlinkat 等。同时,高级语言运行时也在逐步集成 io_uring:Rust 的 tokio-uring、Go 的 golang.org/x/sys/unix 中的 io_uring 绑定、Node.js 的 libuv 实验性支持等。可以预见,io_uring 将成为 Linux 异步 I/O 的事实标准。

九、动手实践#

实践 1:构建 liburing 并运行示例#

# 克隆 liburing 仓库
git clone https://github.com/axboe/liburing.git
cd liburing
# 编译安装
make -j$(nproc)
sudo make install
# 确认内核版本(io_uring 需要 5.1+,推荐 5.10+)
uname -r
# 建议使用 5.15+ 以获得完整特性支持
# 检查 io_uring 是否可用
cat /proc/sys/kernel/io_uring_disabled
# 输出 0 表示可用,1 表示禁用
# 如果被禁用,可以临时启用
sudo sysctl -w kernel.io_uring_disabled=0
# 编译运行 liburing 自带的示例
cd examples
gcc -o io_uring-cp io_uring-cp.c -luring
./io_uring-cp /etc/passwd /tmp/passwd_copy
diff /etc/passwd /tmp/passwd_copy && echo "文件复制成功!"

实践 2:编写 io_uring 文件复制程序#

cat > /tmp/io_uring_cp.c << 'CEOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <liburing.h>
#define QUEUE_DEPTH 32
#define BLOCK_SIZE 4096
int main(int argc, char *argv[])
{
if (argc != 3) {
fprintf(stderr, "用法: %s <源文件> <目标文件>\n", argv[0]);
return 1;
}
int src_fd = open(argv[1], O_RDONLY);
if (src_fd < 0) { perror("open src"); return 1; }
int dst_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (dst_fd < 0) { perror("open dst"); close(src_fd); return 1; }
// 获取源文件大小
off_t file_size = lseek(src_fd, 0, SEEK_END);
lseek(src_fd, 0, SEEK_SET);
printf("文件大小: %ld 字节\n", (long)file_size);
struct io_uring ring;
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
off_t offset = 0;
int pending = 0;
long total_copied = 0;
while (offset < file_size || pending > 0) {
// 提交读请求
while (offset < file_size && pending < QUEUE_DEPTH / 2) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
char *buf = malloc(BLOCK_SIZE);
if (!buf) break;
size_t to_read = BLOCK_SIZE;
if (offset + to_read > file_size)
to_read = file_size - offset;
io_uring_prep_read(sqe, src_fd, buf, to_read, offset);
// offset buf 指针编码到 user_data
sqe->user_data = (unsigned long)buf;
offset += to_read;
pending++;
}
io_uring_submit(&ring);
// 收割读完成事件,提交写请求
int nr_complete = 0;
while (nr_complete < pending) {
struct io_uring_cqe *cqe;
if (io_uring_peek_cqe(&ring, &cqe) < 0) break;
char *buf = (char *)cqe->user_data;
int bytes_read = cqe->res;
io_uring_cqe_seen(&ring, cqe);
nr_complete++;
if (bytes_read <= 0) {
free(buf);
continue;
}
// 提交写请求
struct io_uring_sqe *wsqe = io_uring_get_sqe(&ring);
io_uring_prep_write(wsqe, dst_fd, buf, bytes_read,
total_copied);
wsqe->user_data = (unsigned long)buf;
total_copied += bytes_read;
}
pending -= nr_complete;
io_uring_submit(&ring);
// 收割写完成事件
for (int i = 0; i < nr_complete; i++) {
struct io_uring_cqe *cqe;
if (io_uring_peek_cqe(&ring, &cqe) < 0) break;
char *buf = (char *)cqe->user_data;
free(buf);
io_uring_cqe_seen(&ring, cqe);
}
}
printf("复制完成: %ld 字节\n", total_copied);
io_uring_queue_exit(&ring);
close(src_fd);
close(dst_fd);
return 0;
}
CEOF
gcc -o /tmp/io_uring_cp /tmp/io_uring_cp.c -luring
# 测试
dd if=/dev/urandom of=/tmp/test_src.bin bs=1M count=10
/tmp/io_uring_cp /tmp/test_src.bin /tmp/test_dst.bin
diff /tmp/test_src.bin /tmp/test_dst.bin && echo "复制验证通过!"

实践 3:编写 io_uring TCP Echo 服务器#

# 使用本章第五节的完整代码
cat > /tmp/io_uring_echo.c << 'CEOF'
# 此处粘贴第五节的 TCP Echo 服务器代码
# 为节省篇幅,请参考上文完整代码
CEOF
# 实际操作时,将第五节的代码保存为文件
gcc -o /tmp/io_uring_echo /tmp/io_uring_echo.c -luring
# 启动服务器
/tmp/io_uring_echo 9000 &
# 测试
echo "hello io_uring" | nc localhost 9000
# 使用多连接压测
for i in $(seq 1 100); do
echo "test $i" | nc -q 1 localhost 9000 &
done
wait
# 清理
kill %1

实践 4:基准测试 io_uring vs epoll#

# 安装 fio(灵活的 I/O 基准测试工具,支持 io_uring)
sudo apt install fio
# 创建测试配置:io_uring 随机读
cat > /tmp/fio_io_uring.fio << 'EOF'
[global]
ioengine=io_uring
iodepth=64
numjobs=1
size=1G
directory=/tmp
bs=4k
[randread-io_uring]
rw=randread
EOF
# 创建测试配置:libaio 随机读
cat > /tmp/fio_libaio.fio << 'EOF'
[global]
ioengine=libaio
iodepth=64
numjobs=1
size=1G
directory=/tmp
bs=4k
direct=1
[randread-libaio]
rw=randread
EOF
# 创建测试配置:epoll 网络基准(使用 wrk)
# 先启动一个简单的 echo 服务器
# 然后用 wrk 压测
# 运行磁盘 I/O 基准测试
echo "=== io_uring 随机读 ==="
fio /tmp/fio_io_uring.fio --output-format=terse
echo "=== libaio 随机读 ==="
fio /tmp/fio_libaio.fio --output-format=terse
# 使用 fio 的 SQPOLL 模式
cat > /tmp/fio_sqpoll.fio << 'EOF'
[global]
ioengine=io_uring
iodepth=128
numjobs=1
size=1G
directory=/tmp
bs=4k
direct=1
fixedbufs=1
registerfiles=1
sqthread_poll=1
[randread-sqpoll]
rw=randread
EOF
echo "=== io_uring + SQPOLL 随机读 ==="
fio /tmp/fio_sqpoll.fio --output-format=terse
# 网络基准:使用 wrk 对比 epoll 和 io_uring 服务器
# 终端 1:启动 epoll 服务器(如 nginx)
# 终端 2:启动 io_uring echo 服务器
# 终端 3:运行 wrk
# epoll 服务器基准
wrk -t4 -c1000 -d10s http://localhost:8080/
# io_uring 服务器基准(需要 HTTP 包装)
wrk -t4 -c1000 -d10s http://localhost:9000/

实践 5:观察 io_uring 内核行为#

# 查看 io_uring 相关的内核参数
cat /proc/sys/kernel/io_uring_disabled
# 0 = 启用, 1 = 禁用
# 查看进程的 io_uring 实例
# Linux 5.19+ 可以通过 /proc 查看
ls /proc/self/fdinfo/ | head
# 使用 perf 追踪 io_uring 事件
sudo perf list | grep io_uring
# 可用的追踪点:
# io_uring:io_uring_create
# io_uring:io_uring_submit
# io_uring:io_uring_complete
# io_uring:io_uring_cqe_overflow
# 追踪 io_uring 操作
sudo perf record -e 'io_uring:*' -a sleep 5
sudo perf script
# 使用 bpftrace 实时观察
sudo bpftrace -e '
tracepoint:io_uring:io_uring_complete {
printf("完成: user_data=%llx res=%d flags=%x\n",
args->user_data, args->res, args->cflags);
}
'
# 使用 perf stat 统计系统调用次数
perf stat -e 'syscalls:sys_enter_io_uring_enter' \
-e 'syscalls:sys_enter_io_uring_setup' \
-e 'syscalls:sys_enter_io_uring_register' \
./your_io_uring_app

小结#

本章从 I/O 模型的演进出发,深入剖析了 io_uring 的架构设计与使用方法。以下是关键要点回顾:

  1. io_uring 是 Linux 异步 I/O 的终极方案:它通过共享内存环形缓冲区实现了用户空间与内核的零拷贝通信,彻底解决了 Linux AIO 的 buffered I/O 退化问题,让所有类型的 I/O 操作都成为真正异步的。

  2. 环形缓冲区是无锁交互的核心:SQ 和 CQ 通过 head/tail 指针的单向移动实现单生产者-单消费者的无锁交互。应用是 SQ 的生产者、CQ 的消费者;内核是 SQ 的消费者、CQ 的生产者。这种设计天然避免了锁竞争。

  3. 批量提交是性能的关键:io_uring 允许应用先填充多个 SQE,然后一次 io_uring_submit() 提交所有请求。在高 IOPS 场景下,批量提交将系统调用开销摊薄到几乎可忽略的程度。

  4. 固定缓冲区和文件注册消除重复开销:通过 IORING_REGISTER_BUFFERSIORING_REGISTER_FILES,应用可以预先注册缓冲区和文件描述符,跳过每次 I/O 的页表映射和 fd 查找操作,在高 IOPS 场景下可获得 10-30% 的性能提升。

  5. 网络 I/O 是 io_uring 的重要应用场景:io_uring 支持 accept/recv/send/connect 等完整的网络操作,multishot accept 和 provided buffers 进一步优化了网络服务器的性能。与 epoll 相比,io_uring 在网络 I/O 场景下可获得 2-3 倍的吞吐提升。

  6. SQPOLL 实现零系统调用的 I/O:通过内核线程轮询 SQ,SQPOLL 模式消除了 io_uring_enter() 系统调用,适用于超低延迟场景。但它独占一个 CPU 核心,需要权衡资源代价。

  7. io_uring 正在成为 Linux 的通用异步接口:从最初的 read/write/fsync,到现在的 openat/close/statx/connect/accept/send/recv/timeout/futex,io_uring 支持的操作类型不断扩展。它不再只是一个异步 I/O 接口,而是正在成为 Linux 的通用异步系统调用接口

Note

io_uring 与本系列其他技术的关系:io_uring 是内核态加速方案,与 DPDK 的完全旁通不同,它仍在内核中执行 I/O 操作,但通过共享内存和批量提交极大降低了交互开销。在第 2 章的技术谱系中,io_uring 位于”内核态优化”一端——不离开内核,但让内核路径更高效。对于需要极致性能的场景,io_uring + O_DIRECT + 固定缓冲区可以接近 DPDK 的性能水平,同时保留内核协议栈的完整功能。

参考资料#

内核源码#

文件内容
io_uring/io_uring.cio_uring 核心实现:ring 初始化、SQE 提交、CQE 完成、SQPOLL 线程
io_uring/rw.cio_uring 读写操作:IORING_OP_READ/IORING_OP_WRITE 实现
io_uring/net.cio_uring 网络操作:accept/recv/send/connect 实现
io_uring/filetable.c固定文件注册与管理
io_uring/rsrc.c固定缓冲区注册与管理
io_uring/sqpoll.cSQPOLL 内核线程实现
io_uring/timeout.c定时器操作实现
io_uring/poll.cpoll 操作与 multishot poll 实现
include/uapi/linux/io_uring.hio_uring 用户态 API 定义:SQE/CQE 结构、操作码、标志

权威文档与论文#

在线资源#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

部分信息可能已经过时

相关文章 智能推荐
1
SmartNIC 与 DPU
高性能网络 深入 SmartNIC 与 DPU——硬件卸载概念与收益、SmartNIC 架构(固定功能 vs 可编程)、DPU 产品矩阵(NVIDIA BlueField/AMD Pensando/Intel IPU)、OVS 硬件卸载(tc/rte_flow/ASAP²)、P4 编程入门——掌握硬件加速网络的完整技术栈。
2
OVS-DPDK 与虚拟交换
高性能网络 深入 OVS-DPDK 与虚拟交换——OVS-DPDK 架构与内核 OVS 对比、dpdkvhostuser 端口类型、vhost-user 协议与共享 virtio 环、virtio 前后端、VM-to-VM 交换路径、流分类(EMC/DPCLS/megaflows)、性能调优——掌握云环境虚拟网络加速的完整技术栈。
3
SPDK 与存储旁通
高性能网络 深入 SPDK 与存储旁通——SPDK 架构与 DPDK EAL 共享、用户态 NVMe 驱动(MMIO/SQ/CQ)、vhost-user 协议与共享内存、bdev 抽象层与模块、blobstore 持久化对象存储、iSCSI/NVMe-oF Target——掌握用户态存储的完整技术栈。
4
VPP 与 FD.io 数据平面
高性能网络 深入 VPP 与 FD.io 数据平面——矢量包处理(Vector Packet Processing)的 I-Cache 友好设计、插件框架、图节点实现与类型、CLI 与 binary API、预取优化与批量处理、VPP + DPDK 输入节点——掌握下一代数据平面的架构与编程。
5
RDMA 与远程直接内存访问
高性能网络 深入 RDMA 架构——Verbs API 编程模型、RoCEv2/iWARP/InfiniBand 三种传输对比、内存注册与保护域、QP/CQ/SRQ 生命周期与状态机、RDMA CM 连接管理、单边操作(RDMA Write/Read/Atomic)的零拷贝原理——掌握远程直接内存访问的完整技术栈。