首页 / 文章 / Linux 中的 epoll 与 io_uring 对比分析
← 返回
AI技术

Linux 中的 epoll 与 io_uring 对比分析

✍️ zhirenhun 📅 2026/6/21 👁 43 阅读 ⏱ 15 分钟
Linux 中的 epoll 与 io_uring 对比分析

首先,我想告诉大家我是如何走到这一步的,以及为什么我开始研究 Linux 上处理异步 I/O 的不同选项……去年,我和我的学生们构建了一个名为 TinyGate 的反向代理服务器。它非常简单,基于工作进程(worker-based),并且基本上运行得很好。当然,我没有期望它会非常快,但这是一个教育项目,而且既然我们做出了一个真实、近乎生产就绪的工具,我感到非常自豪。但我的学生们并没有像我一样开心——他们希望构建出真正有用的东西,他们对我们的“产品”存在很强的架构限制,无法超越 nginx 和 haproxy 等巨头,感到非常失望。于是,他们直接“逼迫”我一起研究这些工具在底层是如何工作的,以及如何处理异步 I/O 来减少沉重的开销…… 长话短说,我们制作了 TinyGate 的第二个版本,基于 epoll。它在基准测试中仍然输给了 nginx/haproxy,但与第一个版本相比,性能有了显著提升。但 epoll 并非完美无缺(我将在下面解释),我们最终切换到了 io_uring,这又导致了我们项目的彻底从零开始重写…… 因此,这是一个非常有趣的话题,今天我将分享 Linux 为异步 I/O 提供的这两个队列系统概述。

epoll 的传承 (epoll heritage)

当我刚开始为 Linux 进行开发时,epoll 是一个新特性,基本上没有替代品。每个人都使用它来管理异步执行——没有其他选择。问题在于,epoll 严重依赖于 syscalls:它告诉你何时可以进行 I/O,但你仍然需要自己调用 read()/write()。这意味着每个 I/O 事件都需要两次 syscall(除了一次性的 epoll_ctl 注册)。这些 syscall 中的每一个都会在用户态和内核态之间引起一次上下文切换(context switch),一旦你开始处理大量连接,就会产生巨大的开销(HUGE overhead)。但我们有解决方案!在 epoll 进入 Linux 内核大约 17 年后(2002 年),io_uring 出现了(2019 年)!它不是告诉你何时 I/O 可能发生,而是告诉你 I/O 何时完成——无需轮询循环(polling loop),并且相关的 syscalls 数量大大减少。

内核从应用程序和内核之间共享的内存中消费提交(submissions),并将完成事件(completions)发布回同一块共享内存中——两者都存在于环形缓冲区(ring buffers)中,因此得名。难点在于:默认情况下,你仍然需要调用 io_uring_enter() 来告诉内核“去检查提交队列”。但一次调用可以提交一整批操作,并处理一整批完成事件,而不是像 epoll + read 那样为每个操作使用一对 syscall。如果你希望在稳定状态下接近零 syscalls,可以使用 io_uring_setup(),它会启动一个专用的内核线程来为你轮询提交队列——代价是这个线程会消耗 CPU(下面会详细介绍)。

一点比较 (A little comparison)

基本架构:如前所述,epoll 通知你 I/O 可能发生,而 io_uring 通知你 I/O 已完成。如果说 epoll 让每个 I/O 操作都跨越了内核边界,那么 io_uring 允许你支付一次小的“设置费”(创建 ring),加上每批次的费用(io_uring_enter() 调用),而不是为每个操作支付费用。因此,你不是为每个 I/O 支付一对 syscall,而是为每批 I/O 支付一次 syscall——或者,在使用 SQPOLL 时,几乎为零。正如你所看到的,当发生大量 I/O 时,这可以节省大量的 syscalls

在支持 io_uring 的相对较新的系统上(内核 v5.1+,2019 年发布),通常没有太多理由去使用 epoll。从“就绪模型”(readiness model)到“完成模型”(completion model)的转变是一个巨大的架构变化——它将很大一部分工作从你的应用程序中转移到了内核中。

让我们开始编码吧! (Let's code!)

当然,我不会不给你们一些代码来展示这两个系统是如何工作的。我们将使用 C 语言。(io_uring 示例使用了 liburing,即用户空间辅助库——可以通过 apt install liburing-dev 安装,或者如果你想要零依赖,可以直接使用原始的 io_uring syscalls。)

epoll

让我们做一个简单的例子来展示 epoll 是如何工作的。我们将创建实例,注册一个文件描述符(在本例中是标准输入 stdin),然后处理传入的事件。

#include 
#include 
#include 
#include 

#define MAX_EVENTS 8

int main() {
    // Creating the epoll instance
    int epoll_fd = epoll_create1(0);
    if (epoll_fd == -1) {
        perror("epoll_create1");
        return 1;
    }

    // Registering a file descriptor (stdin in our case)
    struct epoll_event ev, events[MAX_EVENTS];
    ev.events = EPOLLIN;
    ev.data.fd = STDIN_FILENO;

    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, STDIN_FILENO, &ev) == -1) {
        perror("epoll_ctl");
        return 1;
    }

    // Blocking until something is readable
    int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
    if (n == -1) {
        perror("epoll_wait");
        return 1;
    }

    // For each fd, issue a SEPARATE syscall to do the I/O
    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == STDIN_FILENO) {
            char buf[256];
            ssize_t count = read(STDIN_FILENO, buf, sizeof(buf));
            printf("read %zd bytes
", count);
        }
    }

    // Cleaning up
    close(epoll_fd);
    return 0;
}

正如你所看到的,这个例子总共使用了三个 syscallsepoll_create1(一次性注册),然后是 epoll_waitepoll_ctl(处理事件)——正如我上面提到的,这是每个实际 I/O 事件两次 syscalls。代码本身非常容易理解。

io_uring

现在让我们使用 io_uring 来做同样的事情,而不是 epoll。

#define _GNU_SOURCE
#include 
#include 
#include 
#include 

int main() {
    struct io_uring ring;
    char buf[256];

    // Setting up the ring
    if (io_uring_queue_init(8, &ring, 0) < 0) {
        perror("io_uring_queue_init");
        return 1;
    }

    // Prepare a READ operation on stdin
    struct io_uring_sqe sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, STDIN_FILENO, buf, sizeof(buf), 0);

    // Submitting the read
    io_uring_submit(&ring);

    // Waiting for completion
    struct io_uring_cqe cqe;
    if (io_uring_wait_cqe(&ring, &cqe) < 0) {
        perror("io_uring_wait_cqe");
        return 1;
    }
    if (cqe->res < 0) {
        fprintf(stderr, "read failed: %d
", cqe->res);
    } else {
        printf("read %d bytes
", cqe->res);
    }

    // Marking seen then cleaning up
    io_uring_cqe_seen(&ring, cqe);
    io_uring_queue_exit(&ring);
    return 0;
}

我们在这里看到了什么?

相似的实例创建步骤。

不需要 epoll_ctl 注册步骤。

在提交之前不需要进行就绪性检查(readiness check)。

在完成时不需要单独的 read() 调用。

是的,对于这个例子,io_uring 消耗的资源要少得多——尽管如上所述,除非你使用 SQPOLL,否则仍然有一个 io_uring_enter() 调用隐藏在 io_uring_submit()io_uring_wait_cqe() 内部。

当你测试这些示例时,请记住,为了简化起见,一些重要部分是缺失的。例如,如果 read() 永远没有产生任何数据,程序将无限期阻塞;而 io_uring 示例跳过了检查 io_uring_get_sqe()(如果提交队列满了,它可能会返回 NULL)。

关于 io_uring 的额外信息 (Something additional about io_uring)

零拷贝 (Zero-copy)。 对于真正的零拷贝 I/O,请提前使用 io_uring_register() 注册你的缓冲区——这避免了内核在每次操作中重新映射内存。特别是对于网络发送,请查看 io_uring_prep_sendmsg()(需要内核 6.0+),它甚至跳过了将缓冲区复制到内核的整个过程。

SQPOLL 消耗 CPU。 即使你的队列是空的,io_uring_setup() 也会让一个内核线程持续运行并轮询,从而消耗 CPU。它有一个空闲超时时间(idle timeout),之后会退回到休眠状态,但它并非免费的。

异步错误处理。 错误是异步返回的(并且必须被处理的),作为 's' 字段的一部分——而不是像正常的同步 syscall 那样作为直接的返回值。

总结 (Summary)

在现代 Linux 世界中,io_uring 是异步 I/O 的新标准,老实说,在我看来,在一个支持 io_uring 的系统上,仍然选择使用 epoll 的理由不多。对于一个在现代 Linux 服务器上从零开始的项目,比如我们的 TinyGate 重写,io_uring 绝对是首选方案。我是一个坚定的支持者,主张在合理的情况下尽快放弃对旧系统的支持——依我的观点,如果你仍然运行一个超过 7 年前发布的内核,那可不是一个好主意……


原文出处:https://sibexi.co/posts/epoll-vs-io_uring/

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

sibexico
← 上一篇
我的桌面机器人研究平台:从零搭建不到5000欧元的实操环境
下一篇 →
为 AI agents 提供的临时 Cloudflare 账户

📌 相关推荐

📄
Rhombus 1.0 正式发布
2026/6/24
📄
艾尔登法环的低技术AI
2026/6/24
提示注入的理论基础:角色混淆(Prompt Injection as Role Confusion)
2026/6/23
← 返回文章列表