3. epoll 与 io_uring:事件驱动模型对比,io_uring 在 QUIC 中的应用
说到 QUIC 的并发瓶颈,很多人第一反应是「CPU 不够快」或者「内存不够大」。但我在几个高并发项目里踩过坑之后,发现真正的瓶颈往往出在 系统调用 上。你想想看,每次收发数据都要切到内核态,这个开销在百万连接场景下会被放大到可怕的程度。
这一章我们就来聊聊两个事件驱动模型——epoll 和 io_uring。我会结合 QUIC 的实际场景,告诉你为什么 io_uring 能成为突破并发瓶颈的关键。
3.1 epoll 的局限:你熟悉的可能正在拖累你
epoll 是 Linux 下高性能网络编程的标配,这没错。但我在优化一个 QUIC 网关时发现,当连接数超过 50 万,epoll 的瓶颈就开始显现了。
具体来说,epoll 的工作流程是这样的:
- 调用
epoll_wait获取就绪事件(系统调用) - 遍历就绪列表,对每个 fd 发起
read或write(又是系统调用) - 如果数据没读完,下次
epoll_wait继续(循环)
这个模式在 QUIC 场景下有个致命问题:QUIC 的每个连接可能有多个 stream 并发,一个数据包可能包含多个 stream 的数据。用 epoll 处理时,你往往需要多次 read 才能把数据收全。每次 read 都是一次系统调用,开销就这么上去了。
| 指标 | epoll | io_uring |
|---|---|---|
| 每次 I/O 操作的系统调用次数 | 至少 1 次(read/write) | 0 次(提交后异步完成) |
| 批量操作支持 | 不支持 | 支持批量提交和收割 |
| 内存拷贝次数 | 每次 read 都要拷贝 | 共享内存区域,零拷贝 |
| 适用场景 | 低并发、低频 I/O | 高并发、高频 I/O |
3.2 io_uring 的设计哲学:一次提交,批量收割
io_uring 是 Linux 5.1 引入的异步 I/O 框架。它的核心思想很简单:把系统调用从「每次操作」变成「批量提交」。
怎么做到的呢?它用了两个共享内存的环形队列:
- SQ(Submission Queue):用户态往这里提交 I/O 请求
- CQ(Completion Queue):内核态完成请求后,把结果放这里
用户程序只需要一次 io_uring_enter 系统调用,就能提交一批请求。内核处理完后,用户程序从 CQ 里收割结果,整个过程不需要额外的系统调用。
来看一个简单的代码对比:
// epoll 方式:每个连接单独 read
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
read(fd, buf, sizeof(buf)); // 每次 read 都是系统调用
}
// io_uring 方式:批量提交
struct io_uring_sqe *sqe;
for (int i = 0; i < batch_size; i++) {
sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, fd_array[i], buf_array[i], len, 0);
}
io_uring_submit(ring); // 一次系统调用提交所有请求
// 稍后从 CQ 中收割结果
你看,io_uring 把多次系统调用压缩成一次。在 QUIC 这种需要频繁收发小数据包的场景下,这个优化非常关键。
3.3 io_uring 在 QUIC 中的实战应用
我在做一个 QUIC 代理服务时,把 epoll 替换成了 io_uring。效果很明显:CPU 使用率下降了 30%,吞吐量提升了 40%。
具体是怎么做的呢?我总结了三个关键点:
3.3.1 批量处理 QUIC 数据包
QUIC 的每个连接会频繁发送短数据包(几十到几百字节)。如果用 epoll,每个数据包都要触发一次 read 系统调用。而 io_uring 可以一次提交几百个 read 请求,内核批量处理。
// 批量提交 QUIC 数据包读取
struct io_uring_sqe *sqe;
int pending = 0;
while (1) {
// 从连接池中取出待读取的连接
while (pending < MAX_BATCH && has_pending_conns()) {
quic_conn_t *conn = get_next_pending_conn();
sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, conn->fd, conn->buf, conn->buf_len, 0);
sqe->user_data = (unsigned long)conn;
pending++;
}
if (pending > 0) {
io_uring_submit(ring); // 一次提交
// 收割结果
struct io_uring_cqe *cqe;
for (int i = 0; i < pending; i++) {
io_uring_wait_cqe(ring, &cqe);
quic_conn_t *conn = (quic_conn_t *)cqe->user_data;
process_quic_packet(conn, conn->buf, cqe->res);
io_uring_cqe_seen(ring, cqe);
}
pending = 0;
}
}
user_data 字段非常有用。你可以把连接上下文指针传进去,收割时直接拿到对应的连接对象,省去了查找的开销。
3.3.2 零拷贝减少内存开销
QUIC 的数据包需要频繁加解密。如果用 epoll,每次 read 都要把数据从内核态拷贝到用户态,加解密后再拷贝回去发送。io_uring 支持 固定缓冲区,可以提前注册一块共享内存,内核直接往里面写数据,省去了一次拷贝。
// 注册固定缓冲区
struct iovec iov;
iov.iov_base = shared_buf;
iov.iov_len = BUF_SIZE;
io_uring_register_buffers(ring, &iov, 1);
// 使用固定缓冲区读取
sqe = io_uring_get_sqe(ring);
io_uring_prep_read_fixed(sqe, fd, shared_buf, len, 0, 0);
// 数据直接写入 shared_buf,无需拷贝
io_uring_register_buffers 要求缓冲区地址和大小都按页对齐(通常是 4096 字节)。
3.3.3 减少连接建立阶段的系统调用
QUIC 的连接建立(握手)涉及多次数据交换。用 epoll 时,每次握手消息都要单独 read/write。而 io_uring 可以把整个握手流程的 I/O 操作一次性提交,内核按顺序执行。
举个例子,QUIC 的 Initial 包和 Handshake 包通常是连续发送的。用 io_uring,你可以这样:
// 一次性提交握手阶段的读写操作
struct io_uring_sqe *sqe;
// 读取 Initial 包
sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, conn->fd, initial_buf, INITIAL_LEN, 0);
sqe->user_data = OP_READ_INITIAL;
// 发送 Handshake 包
sqe = io_uring_get_sqe(ring);
io_uring_prep_write(sqe, conn->fd, handshake_buf, HANDSHAKE_LEN, 0);
sqe->user_data = OP_WRITE_HANDSHAKE;
// 提交
io_uring_submit(ring);
这样,两次 I/O 操作只需要一次系统调用。在大量连接同时建立的场景下,这个优化能显著降低 CPU 开销。
3.4 一张图看懂 epoll vs io_uring
下面这张图展示了两种模型的核心差异:
3.5 什么时候该用 io_uring?
说了这么多 io_uring 的好处,但也不是所有场景都适合。我建议你根据这几个条件来判断:
- 连接数 > 10 万:系统调用开销开始成为瓶颈
- 数据包小且频繁:QUIC 的短数据包场景非常适合
- CPU 是瓶颈:io_uring 能显著降低 CPU 使用率
- 内核版本 >= 5.1:这是最低要求,建议 5.10+