nginx 的静态文件服务性能已经是标杆了。sendfile() 零拷贝、epoll 事件驱动、多 worker 进程、高度优化的 HTTP 解析器。大多数场景下你不可能比它快。
但 io_uring 改变了一个基本假设:系统调用的开销不再是零。在高频 I/O 场景下,每个 read/write/sendfile 的用户态-内核态切换加起来,占总延迟的 20-40%。io_uring 的批量提交 + kernel polling 可以把这个开销压到接近零。
这篇文章做一件事:写一个极简的静态文件服务器,把 io_uring 的所有性能特性都用上,然后和 nginx 正面跑 wrk benchmark。
剧透:在小文件(<4KB)高并发场景下,我们赢了。在大文件场景下,nginx 的 sendfile 仍然是对的选择。
一、基线:一个 HTTP 请求要几个系统调用
先看 nginx 处理一个静态文件请求的系统调用链:
accept4() -> 接受连接
read() -> 读 HTTP 请求头
open() -> 打开文件
fstat() -> 获取文件大小 (构造 Content-Length)
sendfile() -> 零拷贝发送文件
close() -> 关闭文件
6 个系统调用。每个大约 200-400 ns(含上下文切换)。在 10 万 QPS 下,光系统调用就占了 120-240 us/s——接近一个 CPU 核的 15-30%。
io_uring 的目标:把这 6 个系统调用变成 0 个。
accept4-> io_uring multishot acceptread-> io_uring read,buffer 预注册open+fstat-> registered file descriptors + 预缓存sendfile-> io_uring write(从文件 buffer 直接写 socket)close-> io_uring close
所有操作通过 SQ 提交,CQ 收结果。如果开了 SQPOLL,连 io_uring_enter() 系统调用都不需要——内核线程自动消费 SQ。
二、核心优化:三把刀
Fixed Buffers
普通的 read(fd, buf, len) 每次都要告诉内核
buffer 在哪。内核需要通过页表翻译虚拟地址、pin
住物理页。如果你的 buffer 每次都一样,这个翻译是浪费的。
io_uring 的 fixed buffer:在初始化时用
io_uring_register_buffers() 把 buffer
注册给内核。内核一次性完成地址翻译和页面锁定。之后每次 I/O
只需要传 buffer index,不需要地址翻译。
// 注册 buffer pool
struct iovec iovs[BUFFER_COUNT];
for (int i = 0; i < BUFFER_COUNT; i++) {
iovs[i].iov_base = aligned_alloc(4096, BUFFER_SIZE);
iovs[i].iov_len = BUFFER_SIZE;
}
io_uring_register_buffers(&ring, iovs, BUFFER_COUNT);
// 使用 fixed buffer 的 read
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, iovs[buf_idx].iov_base,
BUFFER_SIZE, 0, buf_idx);实测收益:在 4KB 文件、32 并发下,fixed buffer 比普通 buffer 快 15-20%。buffer 越大收益越小(页表翻译的占比降低)。
Registered Files
和 fixed buffer 同理。每次 read(fd, ...)
内核要在 fd table 里查 fd 对应的 struct
file,增加引用计数,操作完再减。高频 I/O
下这个开销可观。
// 注册 fd table
int fds[MAX_FDS];
// ... 打开文件填入 fds ...
io_uring_register_files(&ring, fds, MAX_FDS);
// 使用 registered fd
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, registered_idx, buf, len, 0);
sqe->flags |= IOSQE_FIXED_FILE; // 告诉内核这是 registered indexregistered files 在高频 open/close 场景下收益明显。对于静态文件服务器,文件可以启动时全部打开并注册。
SQPOLL
终极优化。启动一个内核线程轮询 SQ,用户态提交 SQE
后不需要调用
io_uring_enter(),内核线程会自动看到。
struct io_uring_params params = {
.flags = IORING_SETUP_SQPOLL,
.sq_thread_idle = 2000, // 2 秒无活动后内核线程休眠
};
io_uring_queue_init_params(RING_SIZE, &ring, ¶ms);代价:一个 CPU
核专门给内核线程。在低负载时这是浪费。在高负载时,省掉的
io_uring_enter()
系统调用换一个核的占用,通常值得。
三、HTTP 解析:不要在这里浪费时间
我们的目标是测 io_uring,不是写 HTTP 库。HTTP 解析器只需要处理:
GET /path HTTP/1.1\r\n
Host: ...\r\n
\r\n
提取 path,其余全部忽略。不支持 POST、不支持 chunked、不支持 keep-alive(简化测试)。一个简单的状态机,不到 50 行:
// 极简 HTTP 解析: 只提取 GET 的 path
int parse_request(const char *buf, int len, char *path, int path_max) {
if (len < 14) return -1; // "GET / HTTP/1.1" 最短 14 字节
if (memcmp(buf, "GET ", 4) != 0) return -1;
const char *start = buf + 4;
const char *end = memchr(start, ' ', len - 4);
if (!end) return -1;
int path_len = end - start;
if (path_len >= path_max) return -1;
memcpy(path, start, path_len);
path[path_len] = '\0';
// 安全: 拒绝 path traversal
if (strstr(path, "..")) return -1;
return 0;
}响应也是固定模板:
int format_response(char *buf, int file_size, const char *content_type) {
return snprintf(buf, BUFFER_SIZE,
"HTTP/1.1 200 OK\r\n"
"Content-Length: %d\r\n"
"Content-Type: %s\r\n"
"Connection: close\r\n"
"\r\n", file_size, content_type);
}四、Benchmark:wrk 正面 PK
测试配置:
| 参数 | 值 |
|---|---|
| 机器 | AWS c5.2xlarge (8 vCPU, Intel Xeon 8275CL) |
| 内核 | 6.1 |
| 文件 | 1000 个静态文件,大小分布如下 |
| 工具 | wrk -t4 -c128 -d30s |
| nginx | 1.24, 4 worker, sendfile on, tcp_nopush on |
| io_uring server | 单线程, SQPOLL + fixed buffers + registered files |
小文件 (1KB)
| 指标 | nginx | io_uring server | 差距 |
|---|---|---|---|
| QPS | 185,000 | 240,000 | +30% |
| P50 延迟 | 0.52 ms | 0.41 ms | -21% |
| P99 延迟 | 2.1 ms | 1.3 ms | -38% |
| CPU 占用 | 4 核 ×95% | 1 核 ×98% + 1 核 SQPOLL | -50% |
1KB 文件场景下 io_uring 赢了。原因:文件小到可以完全放在 page cache 里,瓶颈是系统调用开销。io_uring 把 6 个系统调用变成 0 个,直接省掉了瓶颈。
中等文件 (64KB)
| 指标 | nginx | io_uring server | 差距 |
|---|---|---|---|
| QPS | 52,000 | 58,000 | +12% |
| P50 延迟 | 1.8 ms | 1.6 ms | -11% |
| P99 延迟 | 5.2 ms | 4.1 ms | -21% |
差距缩小。数据传输开销开始占主导,系统调用的比例降低。
大文件 (1MB)
| 指标 | nginx | io_uring server | 差距 |
|---|---|---|---|
| QPS | 8,200 | 7,500 | -8% |
| P50 延迟 | 12 ms | 14 ms | +17% |
大文件场景 nginx 赢了。 原因:nginx 用 sendfile() 实现了真正的零拷贝——数据从 page cache 直接发到 socket buffer,不经过用户态。我们的 io_uring server 是 read -> 用户态 buffer -> write,多了一次内存拷贝。
要赢回来需要用 io_uring 的 splice 操作(内核
5.17+),直接在内核里做 pipe
splice,跳过用户态。但这把实现复杂度又提高了一个量级。
结论
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 小文件 (<16KB) 高 QPS | io_uring | 系统调用开销占比高 |
| 中等文件 | 两者差别不大 | 选你熟悉的 |
| 大文件 (>256KB) | nginx sendfile | 零拷贝完胜 |
| 混合负载 | nginx | 通用优化更均衡 |
五、nginx 什么时候赢
这不是一篇”io_uring 吊打 nginx”的文章。nginx 在以下维度完胜我们的 toy server:
- HTTP 完整性 – Keep-Alive、chunked transfer、gzip、TLS、Range request、If-Modified-Since。我们一个都没实现。
- 多核利用 – nginx 多 worker 进程天然利用多核。我们是单线程。
- 运维成熟度 – 配置热加载、graceful shutdown、access log、upstream proxy。
- 安全加固 – 请求大小限制、连接数限制、rate limiting、WAF 集成。
io_uring 不是 nginx 的替代品。它是 nginx 下一代 event loop 的基础。 事实上,nginx 已经在开发 io_uring 后端(1.25.x 实验性支持)。当 nginx 原生用上 io_uring + SQPOLL + fixed buffers,上面的小文件优势也会消失。
真正的收益不在”替代 nginx”,而在”理解系统调用的成本结构”。当你知道每个 read/write 消耗 200-400 ns,你就能更准确地预测你的服务瓶颈在哪里,以及 io_uring 能帮你省多少。
延伸阅读:
- 实战:基于 io_uring 的 TCP Echo Server – io_uring 编程的入门
- io_uring 多线程编程模式 – 单线程不够时怎么办
- 用 Rust 重写你的 C 网络服务器 – 同样的 io_uring 模式,Rust 视角
- 零拷贝的肮脏真相 – sendfile vs splice vs io_uring,零拷贝的真实代价
参考资料:
- io_uring 官方文档 – Jens Axboe 的 io_uring 白皮书
- liburing – io_uring 的用户态库
- nginx io_uring 实验性支持 – nginx 1.25+ 的 io_uring event module