io_uring:Linux 下一代异步 I/O 基础设施

引言:从 Bluesky 的 epoll 瓶颈说起

2024 年 1 月,Bluesky 工程师发表了一篇文章,讲述了他们在将 Go 服务扩展到 192 核裸金属服务器时遭遇的一个深层运行时瓶颈。

他们的 AppView V2 服务是一个 ConnectRPC 服务器,平均每个请求要向 ScyllaDB 发起约 15.2 次查询。在配备 2×96 核 AMD Genoa-X CPU、512GB RAM 的服务器上,他们遇到了两个核心瓶颈:

  1. GC 压力:通过调高 GOGC 参数(从 100 到 500),用内存换 CPU 时间
  2. epoll 瓶颈:Go 的 Netpoll 在单次 EPoll 调用中最多只缓冲 128 个 socket,而实际场景中有数千个 socket 就绪,导致 syscall.EpollWait 占据了近 65% 的 CPU 时间

他们的解决方案是:在每台主机上启动 8 个 Go 运行时实例,将网络负载分摊开来。性能提升显著:

  • ScyllaDB 查询吞吐量:130 万次/秒 → 280 万次/秒
  • 前端请求吞吐:9 万次/秒 → 18.5 万次/秒
  • p50/p99 延迟下降超过 50%
  • CPU 利用率:80% → 40%

这个故事揭示了一个反直觉的工程规律:在极高并发 I/O 场景下,运行时本身(而非业务逻辑)会成为瓶颈

而 io_uring,正是为了解决这类问题而生的。


epoll 的局限性

epoll 是 Linux 2.6+ 引入的 I/O 多路复用机制,替代了 select/poll。它的工作原理是:

  1. 内核维护一个就绪事件列表
  2. 用户空间通过 epoll_wait() 轮询获取就绪事件
  3. 支持 LT(水平触发)、ET(边缘触发)等模式

epoll 的痛点

痛点 说明
上下文切换开销 每次 epoll_wait() 都要从用户态切换到内核态
数据拷贝 就绪事件需要从内核空间拷贝到用户空间
锁竞争 高并发下 epoll 实例的锁成为瓶颈
中断风暴 每个事件到达都可能触发中断
单次缓冲限制 如 Go Netpoll 单次只缓冲 128 个 socket

Bluesky 遇到的正是 epoll 的系统性瓶颈——当连接数突破某个阈值,抽象层的开销就藏不住了。


io_uring:设计哲学

io_uring 是 Linux 5.1+ 引入的异步 I/O 接口,由 Jens Axboe(FIO 作者)设计。它的核心设计哲学是:

让 I/O 提交和完成都无需系统调用,通过共享内存实现零拷贝通信。

核心架构

io_uring 基于共享环形缓冲区

  • **SQ (Submission Queue)**:提交队列,用户空间将 I/O 请求放入这里
  • **CQ (Completion Queue)**:完成队列,内核将完成的通知放入这里
  • **SQE (Submission Queue Entry)**:提交队列条目,描述一个 I/O 请求
  • **CQE (Completion Queue Entry)**:完成队列条目,描述 I/O 完成结果
1
2
3
4
5
6
7
8
用户空间                              内核空间
┌─────────────────┐ ┌─────────────────┐
│ SQ Ring │◄────共享内存────►│ SQ Ring │
│ (提交队列) │ │ (提交队列) │
├─────────────────┤ ├─────────────────┤
│ CQ Ring │◄────共享内存────►│ CQ Ring │
│ (完成队列) │ │ (完成队列) │
└─────────────────┘ └─────────────────┘

零拷贝机制详解

io_uring 的”零拷贝”不是魔法,而是巧妙的虚拟内存映射设计

两个阶段

阶段一:io_uring_setup() — 内核分配物理页

内核分配物理页 P1, P2, P3,并在内核页表中建立映射。此时用户进程页表中没有任何映射,用户态无法访问这些物理页。

阶段二:mmap() 三次 — 插入用户态映射

内核在用户进程的页表里插入新条目——把用户态的某段虚拟地址(比如 0x7f000000)也指向同一批物理页。

io_uring mmap 机制

关键

  • 用户态虚拟地址 0x7f000000 → 物理页 P1
  • 内核态虚拟地址 0xffff8000同一个物理页 P1
  • 权限位不同:用户态是 RW|User,内核态是 RW|Kernel-only

mmap() 做了什么

  • io_uring_setup() — 内核分配物理页 P1/P2/P3,建立内核虚拟地址→物理页的映射
  • mmap() 三次 — 在用户进程页表里插入新条目:用户虚拟地址 → 同一批物理页 P1/P2/P3
  • 写入后 — 用户写 0x7f000000,内核读 0xffff8000...,落在同一物理字节,零拷贝

数据流:零拷贝如何实现

用户程序写 SQ Ring:

1
sqring->tail++;  // 虚拟地址 0x7f000000,CPU 翻译到物理页 P1

内核程序读 SQ Ring:

1
tail = sqring->tail;  // 虚拟地址 0xffff8000,CPU 翻译到同一个物理页 P1

数据从未被复制,只是同一块物理内存有两个”门牌号”。

页表切换优化

切换场景 CR3 是否切换 原因
用户进程 A → 内核线程 ❌ 不切换 内核线程借用进程 A 的页表,只访问内核映射区
内核线程 → 内核线程 ❌ 不切换 所有内核页表的内核映射区完全相同
内核线程 → 用户进程 B ✅ 必须切换 进程 B 的用户态映射不同,需要换页表

如果调度序列是:进程 A → kworker → kworker2 → 进程 A,整个过程 CR3 一直是进程 A 的页表,完全不需要切换!


性能对比

操作 epoll io_uring
提交请求 epoll_ctl() syscall 写 SQ Ring (无 syscall)
等待事件 epoll_wait() syscall 读 CQ Ring (无 syscall)
数据拷贝 就绪事件从内核拷贝到用户 零拷贝 (共享内存)
上下文切换 每次 wait 都要切换 初始化后几乎不切换
并发能力 万级并发 OK 十万级并发轻松

实战:使用 liburing

安装

1
2
3
4
5
6
7
8
9
# Ubuntu/Debian
apt install liburing-dev

# 源码编译
git clone https://github.com/axboe/liburing
cd liburing
./configure
make
sudo make install

Hello World 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <liburing.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
char buf[256];
int fd;
int ret;

// 初始化 io_uring
ret = io_uring_queue_init(32, &ring, 0);
if (ret < 0) {
perror("io_uring_queue_init");
return 1;
}

// 打开文件
fd = open("test.txt", O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}

// 获取提交队列条目
sqe = io_uring_get_sqe(&ring);

// 准备读请求
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);

// 提交请求
ret = io_uring_submit(&ring);
if (ret < 0) {
perror("io_uring_submit");
return 1;
}

// 等待完成
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
perror("io_uring_wait_cqe");
return 1;
}

// 检查结果
if (cqe->res < 0) {
fprintf(stderr, "read failed: %d\n", cqe->res);
} else {
printf("Read %d bytes: %.*s\n", cqe->res, cqe->res, buf);
}

// 标记完成
io_uring_cqe_seen(&ring, cqe);

close(fd);
io_uring_queue_exit(&ring);
return 0;
}

编译:

1
gcc -o hello_uring hello_uring.c -luring

生产环境考量

内核版本要求

特性 最低内核版本
基础 io_uring 5.1
链接操作 (IOSQE_IO_LINK) 5.5
缓冲区选择 5.6
轮询模式 (IORING_SETUP_SQPOLL) 5.11
注册文件描述符 5.6

建议:生产环境使用 5.10+ LTS 内核。

云厂商支持情况

  • AWS:Amazon Linux 2 默认 4.14,需升级;Amazon Linux 2023 默认 5.10+
  • GCP:Cos 默认较新,Ubuntu 镜像需确认
  • Azure:Ubuntu 20.04+ 支持良好
  • 阿里云/腾讯云:需确认具体实例类型

调试工具

1
2
3
4
5
6
7
8
9
10
11
# 查看内核支持
cat /boot/config-$(uname -r) | grep IO_URING

# 检查 io_uring 状态
cat /proc/sys/fs/io_uring-*

# 追踪系统调用
strace -e io_uring_* ./your_program

# 性能分析
bpftrace -e 'tracepoint:syscalls:sys_enter_io_uring_* { @[comm] = count(); }'

已知坑点

  1. 内存限制ulimit -l 可能限制锁内存大小,io_uring 需要锁内存
  2. 文件系统:NFS 等网络文件系统支持有限
  3. 权限问题:某些操作可能需要 CAP_IPC_LOCK 能力

生态现状

采用 io_uring 的项目

项目 状态 说明
Nginx 实验性 部分模块支持
Redis 部分支持 持久化模块
Node.js 实验中 uv 库
Python 实验性 asyncio 后端选项
vLLM ✅ 生产 FlexKV 使用 io_uring 处理 SSD I/O

语言支持矩阵

语言 成熟度
C liburing ✅ 官方
Rust tokio-uring, io-uring ✅ 成熟
Go golang.org/x/exp/io/uring ⚠️ 实验性
Python python-liburing ⚠️ 非官方

学习资源

官方资料

教程

示例代码


总结:什么时候该用 io_uring

适用场景

  • ✅ 高并发网络服务(>10K 连接)
  • ✅ 数据库、存储引擎
  • ✅ 低延迟要求的应用
  • ✅ 大量随机 I/O 场景

不适用场景

  • ❌ 连接数少(<100)
  • ❌ 内核版本受限(<5.1)
  • ❌ 需要广泛兼容性的场景

未来展望

io_uring 正在成为 Linux I/O 的默认选择。随着内核普及和语言绑定的成熟,它有望在以下方面带来变革:

  1. 网络框架重构:现有 epoll 框架(如 Netty、Tokio)可能重写后端
  2. 数据库优化:存储引擎直接利用 io_uring 降低延迟
  3. 云原生基础设施:Service Mesh、API Gateway 等中间件受益

正如 Bluesky 的案例所示,在极端场景下,运行时抽象会变成瓶颈。io_uring 提供了一种更底层的、更高效的 I/O 模型,让我们能够突破这些瓶颈。

对于追求极致性能的系统工程师来说,io_uring 不是”要不要学”的问题,而是”什么时候学”的问题。


参考资料

  1. Bluesky Engineering. “Scaling AppView to 192 Cores.” https://jazco.dev/2024/01/10/golang-and-epoll/
  2. Jens Axboe. “io_uring: A New Linux Async I/O Subsystem.” https://kernel.dk/io_uring.pdf
  3. Shuveb Hussain. “Lord of the io_uring.” https://unixism.net/loti/
  4. Linux Kernel Documentation. “io_uring.” https://github.com/torvalds/linux/tree/master/Documentation/io_uring