土法炼钢兴趣小组的算法知识备份

用 io_uring 写一个比 nginx 快的静态文件服务器

目录

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 个

所有操作通过 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 index

registered 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, &params);

代价:一个 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:

  1. HTTP 完整性 – Keep-Alive、chunked transfer、gzip、TLS、Range request、If-Modified-Since。我们一个都没实现。
  2. 多核利用 – nginx 多 worker 进程天然利用多核。我们是单线程。
  3. 运维成熟度 – 配置热加载、graceful shutdown、access log、upstream proxy。
  4. 安全加固 – 请求大小限制、连接数限制、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 能帮你省多少。


延伸阅读:

参考资料:

  1. io_uring 官方文档 – Jens Axboe 的 io_uring 白皮书
  2. liburing – io_uring 的用户态库
  3. nginx io_uring 实验性支持 – nginx 1.25+ 的 io_uring event module

By .