Linux中Epoll与Io_uring的对比
摘要
对Linux中epoll和io_uring的技术对比,解释它们用于异步I/O的架构和性能特点。
暂无内容
查看缓存全文
缓存时间: 2026/06/20 23:18
# Linux 中的 epoll vs io_uring
来源:https://sibexi.co/posts/epoll-vs-io_uring/
首先,我想告诉你我是如何走到这一步的,以及为什么我开始研究 Linux 上处理异步 I/O 的不同方案……去年,我和我的学生构建了一个反向代理服务器,名为 TinyGate。它非常简单,基于工作线程模型,而且基本运行良好。当然,我并没有期望它非常快,但这是一个教育项目,而且既然我们做出了一个真实、近乎生产级的工具,我为此感到非常自豪。但我的学生们并不像我那样开心——他们想要构建一些真正有用的东西,而且他们对我们的“产品”存在严重的架构限制、无法超越像 nginx 和 haproxy 这样的巨头感到非常失望。所以他们几乎强迫我一起研究这些工具在底层是如何工作的,以及如何处理异步 I/O 以减少巨大的开销……长话短说,我们制作了 TinyGate 的第二个版本,基于 epoll。在基准测试中它仍然输给了 nginx/haproxy,但与第一个版本相比性能有了巨大的提升。但 epoll 也不是完美的(下面我会解释),我们最终切换到了 io_uring,这导致我们的项目从头开始重写,又一次……所以这是一个非常有趣的话题,今天我将分享 Linux 为你提供的两个异步 I/O 队列系统的概述。
## epoll 的传承 (https://sibexi.co/posts/epoll-vs-io_uring/#epoll-heritage)
当我刚开始为 Linux 开发时,epoll 还是个新特性,基本上没有替代方案。每个人都用它来管理异步执行——没有其他选择。问题在于,epoll 严重依赖系统调用:它告诉你 I/O 何时可能,但你仍然需要自己调用 read()/write()——每个 I/O 事件需要两次系统调用,再加上一次性 epoll_ctl 注册。这些系统调用中的每一次都会导致用户态和内核态之间的上下文切换,一旦你处理大量连接,就会产生巨大的开销。但我们有解决方案!在 epoll 进入 Linux 内核(2002 年)大约 17 年后,io_uring 出现了(2019 年)!它不再告诉你 I/O 何时可能,而是告诉你 I/O 何时完成——没有轮询循环,而且相关的系统调用少得多。
内核从你的应用程序和内核之间共享的内存中消耗提交项,并将完成结果放回同一块共享内存中——两者都位于环形缓冲区中,因此得名。问题是:默认情况下你仍然需要调用 `io_uring_enter()` 来告诉内核“去检查提交队列”——但一个调用可以提交一整批操作并收割一整批完成结果,而不是像 epoll + read 那样每个操作对应一对系统调用。如果你希望在稳态下接近零系统调用,可以使用 `IORING_SETUP_SQPOLL`,它会启动一个专用的内核线程来为你轮询提交队列——代价是该线程会消耗 CPU(下面会详细说明)。
## 一点比较 (https://sibexi.co/posts/epoll-vs-io_uring/#a-little-comparison)
基本架构:正如我之前所说,epoll 在 I/O 可能时通知你,io_uring 在 I/O 完成时通知你。epoll 使每个 I/O 操作都跨越内核边界,而 io_uring 让你只支付一次“设置费用”(创建 ring)加上每批费用(`io_uring_enter()` 调用),而不是每个操作一次费用。因此,不是每个 I/O 产生一对系统调用,而是每批 I/O 产生一次系统调用——或者,使用 SQPOLL 时,几乎为零。正如你所看到的,当有大量 I/O 发生时,这可以节省大量系统调用。
在相对较新、支持 io_uring 的系统上(内核 v5.1+,2019 年发布),通常没有太多理由再使用 epoll。从就绪模型到完成模型的转变是一个巨大的架构变化——它将大部分工作从你的应用程序移到了内核中。
## 让我们写代码! (https://sibexi.co/posts/epoll-vs-io_uring/#let-s-code)
当然,我不会不给你展示一些代码就结束,这些代码展示了这两个系统是如何工作的。我们将使用 C 语言。(io_uring 的例子使用了 liburing,即用户空间辅助库——通过 `liburing-dev`/`liburing-devel` 安装,或者如果你想零依赖,可以直接使用原始的 `io_uring_setup`/`io_uring_enter` 系统调用。)
### epoll (https://sibexi.co/posts/epoll-vs-io_uring/#epoll)
让我们做一个简单的 epoll 工作示例。我们将创建实例,注册一个文件描述符(这里是 stdin),并处理传入的事件。
```c
// epoll 示例
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = STDIN_FILENO;
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev);
struct epoll_event events[1];
epoll_wait(epfd, events, 1, -1); // 等待可读
char buf[1024];
read(STDIN_FILENO, buf, sizeof(buf)); // 实际读取
printf("Read: %s\n", buf);
close(epfd);
return 0;
}
```
如你所见,这个示例总共使用了三个系统调用:`epoll_ctl`(一次性注册),然后是 `epoll_wait` 和 `read` 用于事件——所以每个实际 I/O 事件两次系统调用,就像我上面提到的。代码本身非常容易理解。
### io_uring (https://sibexi.co/posts/epoll-vs-io_uring/#io-uring)
现在让我们用 io_uring 代替 epoll 做同样的事情。
```c
// io_uring 示例
#include <liburing.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
struct io_uring ring;
io_uring_queue_init(32, &ring, 0); // 创建 ring
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, STDIN_FILENO, buf, sizeof(buf), 0); // 准备读取
io_uring_submit(&ring); // 提交到内核
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); // 等待完成
// 检查 cqe->res 获取结果
if (cqe->res < 0) {
printf("Read error\n");
} else {
printf("Read: %s\n", buf);
}
io_uring_cqe_seen(&ring, cqe); // 标记完成已处理
io_uring_queue_exit(&ring);
return 0;
char buf[1024]; // 注意:变量应该在前面声明,为简洁起见放在这里
}
```
我们在这里能看到什么?
- 类似的实例创建步骤。
- 不需要 epoll_ctl 注册步骤。
- 提交前不需要检查就绪状态。
- 完成时不需要单独的 read() 调用。
是的,io_uring 在这方面占用的资源少得多——不过,如上所述,在 `io_uring_submit()` 和 `io_uring_wait_cqe()` 内部仍然隐藏着一次 `io_uring_enter()` 调用,除非你使用 SQPOLL 运行。
当你测试这些示例时,要记住为了简化,一些重要的部分被省略了。例如,如果 `stdin` 从不产生任何数据,它会永远阻塞;io_uring 示例跳过了检查 `NULL` sqe(如果提交队列满了,`io_uring_get_sqe()` 可能返回 NULL)。
## 关于 io_uring 的其他补充 (https://sibexi.co/posts/epoll-vs-io_uring/#something-additional-about-io-uring)
- **零拷贝。** 为了实现真正的零拷贝 I/O,提前使用 `io_uring_register_buffers()` 注册你的缓冲区——这避免了内核在每次操作时重新映射内存。具体到网络发送,请查看 `IORING_OP_SEND_ZC`(需要内核 6.0+),它完全避免了将缓冲区复制到内核中。
- **SQPOLL 消耗 CPU。** 即使你的队列为空,`IORING_SETUP_SQPOLL` 也会保持一个内核线程旋转并轮询,这会消耗 CPU。有一个空闲超时(`sq_thread_idle`),超过后它会退回到休眠状态,但并非免费。
- **异步错误处理。** 错误会异步返回(并且必须被处理),作为 `cqe` 的 `res` 字段的一部分——而不是像正常的同步系统调用那样直接作为返回值。
## 总结 (https://sibexi.co/posts/epoll-vs-io_uring/#summary)
io_uring 是现代 Linux 世界中异步 I/O 的新标准,而且说实话,我看不出在支持它的系统上还有什么理由继续使用 epoll。对于一个在现代 Linux 服务器上从零开始的项目,比如我们的 TinyGate 重写,io_uring 绝对是首选。我坚决支持在合理的情况下尽快放弃对旧系统的支持——如果你还在运行一个超过 7 年前发布的内核,在我看来,那并不是一个好主意……
相似文章
通过HTTP提供文件的三种方式:同步、epoll和io_uring
一篇技术文章,比较了通过HTTP提供文件的三种方法:同步的每个请求一个线程、基于epoll的异步I/O和io_uring,并附有C语言代码示例。
进程死后还有 I/O 吗?当进程死亡时 io_uring 会发生什么
一项针对 Linux 6.6.79 的技术分析显示,io_uring 的 I/O 请求可以在其提交进程结束后继续存在,并在 waitpid() 返回后才完成,而原生 AIO 则会等待排空;作者建议使用独占 flock 锁作为屏障,以防止在快速失败恢复过程中出现竞态条件。
PostgreSQL 19 中的内核异步读取(io_uring)
PostgreSQL 19 通过 io_uring 引入了内核异步读取功能,实现了直接的异步缓冲 I/O,从而在不使用专用工作进程的情况下提高性能。
io_uring 的线程身份切换方案
本文介绍了 Jens Axboe 为 io_uring 提交的 RFC 补丁集,该方案提出在不切换上下文的情况下处理可能阻塞的操作,从而通过减少开销来提高性能。
io_uring 不使用预读
本文研究了在 Turso 数据库中 io_uring 实现预读的性能影响,表明批处理 I/O 请求可以通过合并减少设备请求来提高效率。