深入理解 Linux 异步 I/O:从 epoll 到 io_uring
这是一篇教学性质的文章,旨在带你从"听说异步"到真正理解操作系统层面的异步 I/O 是什么、为什么快、以及如何用代码实现它。我们会先拆解日常"异步服务器"的魔法,看看
io_context.run()和async_read_until到底做了什么,再一步步走进真正的内核异步世界。
一、异步到底是什么?
1.1 POSIX 定义的 5 种 I/O 模型
POSIX 标准将 I/O 操作分为 5 种模型,按"阻塞程度"和"谁负责拷贝"来区分:
| 模型 | 发起 I/O 后 | 等待数据 | 数据拷贝(内核→用户) | 举例 |
|---|---|---|---|---|
| 同步阻塞 | 线程挂起 | 线程挂起 | 线程自己做 | recv(fd) 阻塞模式 |
| 同步非阻塞 | 立即返回 | 线程反复轮询 | 线程自己做 | recv(fd) + O_NONBLOCK + 循环 |
| I/O 多路复用 | 线程阻塞在 select/poll/epoll 上 |
一个线程等待多个 fd | 线程自己做 | epoll_wait + recv |
| 信号驱动 | 立即返回 | 内核发信号通知 | 线程自己做 | SIGIO + recv |
| 异步 I/O | 立即返回 | 内核等待 | 内核完成拷贝,通知用户 | POSIX AIO、io_uring |
关键区别在于最后两个步骤:谁来等数据?谁来做拷贝?
- 在 1~4 中,拷贝工作永远是用户线程调用
recv/read来完成的。即使epoll告诉你"数据到了",你仍然需要亲自去取。 - 在模型 5 中,你只需要告诉内核"我要读多少数据到哪个缓冲区",然后就可以干别的去了。内核在后台等待数据、完成拷贝,完事之后通知你------"数据已经在你的缓冲区了,直接用"。
所以,严格意义上的异步 I/O 必须满足:内核全程负责等待和拷贝,用户不参与数据搬运。
1.2 日常说的"异步服务器"到底指什么?------ 一个 Asio 示例
在日常开发中,我们经常看到类似这样的 C++ 网络库(如 Asio)编写出的"异步"服务器:
cpp
// asio_echo_server.cpp ------ 看起来非常"异步"的回声服务器
#define ASIO_STANDALONE
#include <asio.hpp>
#include <iostream>
#include <memory>
#include <string>
using asio::ip::tcp;
class Session : public std::enable_shared_from_this<Session> {
public:
Session(tcp::socket socket) : socket_(std::move(socket)) {}
void start() { do_read(); }
private:
void do_read() {
auto self = shared_from_this();
asio::async_read_until(socket_, buffer_, '\n',
[this, self](std::error_code ec, std::size_t length) {
if (!ec) {
std::istream is(&buffer_);
std::string line;
std::getline(is, line);
std::string reply = "echo: " + line + "\n";
do_write(reply);
}
});
}
void do_write(const std::string& message) {
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(message),
[this, self](std::error_code ec, std::size_t) {
if (!ec) do_read();
});
}
tcp::socket socket_;
asio::streambuf buffer_;
};
class Server {
public:
Server(asio::io_context& io_context, short port)
: acceptor_(io_context, tcp::endpoint(tcp::v4(), port)) {
do_accept();
}
private:
void do_accept() {
acceptor_.async_accept(
[this](std::error_code ec, tcp::socket socket) {
if (!ec) {
std::make_shared<Session>(std::move(socket))->start();
}
do_accept();
});
}
tcp::acceptor acceptor_;
};
int main() {
asio::io_context io_context;
Server server(io_context, 12345);
io_context.run();
}
这段代码给人的感觉非常"异步" :调用 async_read_until、async_accept 后立刻返回,数据到来时回调被自动调用,我们完全没有参与任何拷贝甚至数据的处理。但要真正理解这种"魔法",我们必须深入 io_context.run() 和 async_read_until 的内部。
二、Asio 的魔法:io_context 与 async_read_until 内部探秘
2.1 io_context.run() 到底在做什么?
io_context 是 Asio 事件循环的引擎。当我们调用 io_context.run() 时,它会在当前线程中启动一个无限循环,内部大致等价于下面的伪代码:
cpp
void run() {
while (has_pending_operations()) {
// 1. 调用 epoll_wait(Linux)或等效系统调用,阻塞等待事件
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, timeout);
// 2. 遍历所有就绪的 fd
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
// 3. 找到之前挂起的异步操作,执行相应的回调
pending_operation* op = find_operation(fd);
if (op->type == READ) {
// 尝试非阻塞读取,如果满足条件则调用用户回调
int bytes = recv(fd, op->buffer, op->size, 0);
if (op->is_complete(bytes)) {
op->user_callback(error_code, bytes);
remove_operation(op);
} else {
// 数据不够,继续等待下次 epoll 通知
}
}
// 类似处理 write、accept 等
}
// 4. 执行由 post/dispatch 投递的内部任务
run_internal_tasks();
}
}
核心要点:
io_context.run()本身不会创建新线程,它只是霸占了调用它的线程(在我们的例子里就是主线程)。- 这个循环的唯一目的是:等待 epoll 报告就绪事件,读取数据,然后取出预先登记的回调并执行。
- 所有异步操作最终都通过这个循环串行化(除非你显式用多线程运行同一个
io_context)。
2.2 async_read_until 内部做了什么?
当我们在 Session::do_read() 中调用:
cpp
asio::async_read_until(socket_, buffer_, '\n', handler);
Asio 会立刻执行以下步骤:
-
尝试立即非阻塞读取 :
Asio 会先用
recv系统调用(非阻塞模式)尝试从 socket 读取数据到buffer_。- 如果数据已经包含
'\n',那么async_read_until会在当前函数调用栈内直接调用handler,整个操作同步完成,甚至不经过 epoll。 - 如果数据不足或没有数据(
recv返回EAGAIN),则进入第 2 步。
- 如果数据已经包含
-
挂起操作,注册 epoll 事件 :
Asio 将这次读取操作打包成一个"挂起的异步操作对象",里面记录了:
- socket 的文件描述符
- 用户提供的缓冲区
- 读取条件(读到
'\n') - 用户回调函数
handler
然后,Asio 会调用
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, EPOLLIN),告诉内核:"当这个 socket 上有数据可读时通知我"。做完这一步,async_read_until立即返回,不阻塞当前线程。 -
事件循环接手后续 :
当远程数据到达,网卡 DMA → 内核处理 → socket 接收队列有数据后,epoll 会标记该 fd 可读。下一次
io_context.run()调用epoll_wait时就会拿到这个 fd。事件循环找到对应的挂起操作,再次尝试非阻塞
recv,并将新数据追加到buffer_。- 如果这次读取满足了条件(遇到
'\n'),则立即调用用户的handler(也就是我们在 lambda 里写的处理逻辑)。 - 如果还不满足,则继续让该操作保持挂起,等待下一次
epoll_wait唤醒。
- 如果这次读取满足了条件(遇到
所以,async_read_until 实际上是把"等待数据 + 反复读取直到满足条件 + 最后调用回调"这一连串工作,拆分成了立即尝试 + 注册 epoll + 回调驱动"的模式。 你的回调本质上就是 epoll 事件循环里的一段处理函数,只不过被 Asio 用优雅的方式封装起来了。
2.3 与原始 epoll 代码的对应关系
理解了上述机制,再看下面这段功能完全等价的原始 epoll 代码,你会发现它们之间的映射清晰无比:
| Asio 概念 | 原始 epoll 中的对应物 |
|---|---|
io_context.run() |
while(true) + epoll_wait |
async_read_until 发起 |
尝试 recv,不满足则保持 fd 在 epoll 中(但不会移除) |
| 用户回调 lambda | handle_client 函数 |
async_write |
注册可写事件,epoll_wait 返回后执行 send |
do_accept |
handle_accept |
用 shared_from_this 保持 Session 存活 |
原始代码中连接由程序逻辑隐式管理,但本质相同 |
结论:Asio 的"异步"是编程层面的异步 ------用户不需要阻塞等待,只需注册回调。但在操作系统层面,它仍是 I/O 多路复用(Reactor 模式) :主线程阻塞在 epoll_wait 上,就绪后主动调用 recv 完成数据拷贝。这套机制的优点是大幅简化了事件驱动编程,缺点是每个 I/O 操作最终都绕不开用户态的系统调用。
三、真正的异步 I/O:io_uring 登场
io_uring 是 Linux 5.1 引入的革命性异步 I/O 框架,它用两个共享内存环形队列(SQ/CQ)重新定义了异步交互。
3.1 io_uring 是什么?
- SQ (Submission Queue):用户向内核提交 I/O 请求的队列。用户把要执行的操作(读、写、接受连接等)写入 SQ,然后通知内核。
- CQ (Completion Queue):内核将已完成的操作结果放入此队列,用户从中取出处理。
- 两个队列都是用户态和内核态共享的内存区域 ,因此数据传递可以
几乎不经过系统调用。
3.2 io_uring 工作流程(含正确的数据路径)
- 用户从 SQ 中获取一个空闲的 SQE(Submission Queue Entry),填入操作类型、fd、缓冲区地址、长度等。
- 可反复获取并填写多个 SQE(例如 100 个连接的读取请求)。
- 调用一次
io_uring_submit(ring)(或者io_uring_enter系统调用),将一批请求批量提交给内核。 - 内核收到请求后,会为每个 I/O 操作在后台执行以下步骤:
- 当网络数据到达时,网卡通过 DMA 将数据写入内核环形缓冲区(ring buffer)。
- 内核网络栈(软中断)处理协议后,数据被移入该 socket 的接收队列(内核缓冲区)。
- 如果该 socket 有一个挂起的 io_uring 读取请求,内核会从接收队列将数据拷贝到用户提交 SQE 时指定的用户缓冲区。
- 拷贝完成后,内核将操作结果(成功字节数或错误码)写入 CQ 中的 CQE(Completion Queue Entry)。
- 用户从 CQ 中批量收割 CQE,通过
user_data字段识别是哪个连接、哪个请求,然后直接使用缓冲区中的数据。
关键点 :数据路径依然是 网卡 DMA → 内核环形缓冲区 → 内核 socket 接收队列 → 用户缓冲区。io_uring 并没有减少拷贝的次数,但它将最后一步拷贝的发起者从用户线程(recv 系统调用)变成了内核自己。用户只需从 CQ 收割结果即可。
3.3 一个基于 io_uring 的 Echo 服务器 Demo(小白友好版)
前置准备:安装
liburing库
bashsudo apt install liburing-dev # Debian/Ubuntu
下面的代码包含详细的注释,帮助你理解每一行在做什么。
cpp
// io_uring_echo.cpp
#include <liburing.h>
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
constexpr int PORT = 8080;
constexpr int QUEUE_DEPTH = 256;
constexpr int BUF_SIZE = 4096;
struct Connection {
int fd;
char buf[BUF_SIZE];
int state; // 0:等待读,1:等待写
};
int setup_listening_socket(int port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = INADDR_ANY;
bind(fd, (struct sockaddr*)&addr, sizeof(addr));
listen(fd, 128);
return fd;
}
void submit_accept(struct io_uring* ring, int listen_fd) {
struct io_uring_sqe* sqe = io_uring_get_sqe(ring);
io_uring_prep_accept(sqe, listen_fd, nullptr, nullptr, 0);
io_uring_sqe_set_data(sqe, reinterpret_cast<void*>(-1)); // 标记为 accept 完成
}
void submit_read(struct io_uring* ring, Connection* conn) {
struct io_uring_sqe* sqe = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe, conn->fd, conn->buf, BUF_SIZE, 0);
io_uring_sqe_set_data(sqe, conn);
conn->state = 0;
}
void submit_write(struct io_uring* ring, Connection* conn, int len) {
struct io_uring_sqe* sqe = io_uring_get_sqe(ring);
io_uring_prep_send(sqe, conn->fd, conn->buf, len, 0);
io_uring_sqe_set_data(sqe, conn);
conn->state = 1;
}
int main() {
struct io_uring ring;
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
int listen_fd = setup_listening_socket(PORT);
submit_accept(&ring, listen_fd);
io_uring_submit(&ring);
while (true) {
struct io_uring_cqe* cqe;
io_uring_wait_cqe(&ring, &cqe);
void* data = io_uring_cqe_get_data(cqe);
int result = cqe->res;
if (data == reinterpret_cast<void*>(-1)) {
int client_fd = result;
if (client_fd >= 0) {
auto* conn = new Connection{client_fd, {0}, 0};
submit_read(&ring, conn);
submit_accept(&ring, listen_fd); // 继续接受
}
} else {
Connection* conn = static_cast<Connection*>(data);
if (result <= 0) {
close(conn->fd);
delete conn;
} else {
if (conn->state == 0)
submit_write(&ring, conn, result);
else
submit_read(&ring, conn);
}
}
io_uring_cqe_seen(&ring, cqe);
io_uring_submit(&ring); // 批量提交所有新请求
}
io_uring_queue_exit(&ring);
return 0;
}
观察这段代码与 Asio/epoll 的差异:
- 没有显式的
recv/send循环,只有提交请求和收割结果。 - 内核直接将数据填入
conn->buf,我们拿到完成事件时数据已经就绪。 - 批量提交与收割让系统调用频率骤降。
3.4 io_uring 到底高效在哪里?扫清常见误区
误区:io_uring 高效是因为减少了数据拷贝的次数。
真相:数据拷贝的次数并没有减少。 读取路径上,数据依然要经历 网卡 DMA → 内核环形缓冲区 → 内核 socket 接收队列 → 用户缓冲区 的拷贝。io_uring 的真正优势在于:
- 异步拷贝+批量收割使得系统调用次数大幅降低 :io_uring不是完全没有系统调用,每次提交任务都是一次系统调用,但是io_uring可以采用先把任务写到SQ(写SQ是用户层操作),然后一次性提交内核,将系统调用开销摊薄到极致 。Asio/epoll 中每处理一次读取都需要
recv(一次系统调用)。极端情况:SQPOLL 模式:启动一个内核线程轮询 SQ,用户态完全不需要进行任何系统调用就能提交和收割 I/O,延迟和 CPU 开销进一步降低。
简单来说:epoll/Asio 解决了多线程的切换和内存开销;io_uring 则进一步解决了大量系统调用的开销问题。
四、深入 io_uring:CQ 与批量收割
4.1 CQ 到底是什么?
CQ 是 Completion Queue,一块环形缓冲区,由内核写、用户读。每个条目(CQE)包含:
c
struct io_uring_cqe {
__u64 user_data; // 用户提交时塞进去的"身份证"
__s32 res; // 操作结果(正数 = 字节数,负数 = 错误码)
__u32 flags;
};
在提交 SQE 时,通过 io_uring_sqe_set_data(sqe, ptr) 设置 user_data,通常是一个连接对象的指针。当 CQE 返回时,你直接通过这个指针找到对应的连接,无需遍历查找 fd。
4.2 批量收割
epoll 虽然能一次性返回多个就绪 fd,但之后你需要逐个调用 recv。而在 io_uring 中,收割也可以批量进行:
cpp
struct io_uring_cqe *cqes[BATCH_SIZE];
int n = io_uring_peek_batch_cqe(&ring, cqes, BATCH_SIZE);
for (int i = 0; i < n; i++) {
handle_completion(cqes[i]);
}
io_uring_cq_advance(&ring, n); // 一次性消费掉
io_uring_peek_batch_cqe 仅读取共享内存中的 CQ 环形缓冲区,不需要陷入内核。这意味着一批 I/O 操作从提交到收割,可能只需要 1~2 次系统调用,而 epoll 则需要 N 次。
五、内核是怎么自动把数据拷贝到用户缓冲区的?是注册回调吗?
当你在 io_uring 中提交一个 read 请求后,内核不会像 JavaScript 那样注册一个"事件到来时调用的函数"。但确实有一个内核内部的"回调链"。
5.1 数据到达的全过程
- 硬件中断:网卡收到数据包,通过 DMA 将数据写入内核内存中的环形缓冲区(ring buffer),然后发起硬件中断。
- 中断处理(上半部) :CPU 执行网卡驱动注册的中断处理程序,屏蔽中断并发出一个软中断(如
NET_RX_SOFTIRQ),然后立刻返回。 - 软中断(下半部) :内核在适当时机执行网络软中断处理:
- 解析以太网、IP、TCP 头。
- 找到目标 socket,将数据放入接收队列(内核缓冲区)。
- 如果该 socket 有挂起的 io_uring 读取请求,内核会将数据拷贝到用户指定的缓冲区,然后向 CQ 写入 CQE。
- 通知用户 :如果配置了 eventfd,内核会向其写入值,唤醒
io_uring_wait_cqe。
5.2 epoll 的"回调"呢?
epoll 也使用了内核等待队列:
epoll_ctl向 socket 的等待队列注册一个epitem。- 数据到达后,协议栈唤醒等待队列,触发 epoll 回调将 fd 放入就绪列表。
epoll_wait检查就绪列表并返回。
这些回调都是内核态函数,从不直接调用用户态函数 。用户必须通过 epoll_wait 或 io_uring_wait_cqe 主动拉取通知。
六、补充知识点:异步与对象生命周期管理
在 C++ 异步编程中,必须确保回调执行时对象还活着。Asio 示例中使用了 std::enable_shared_from_this:
- 当
Session被shared_ptr管理时,内部的weak_ptr被自动初始化。 do_read中调用shared_from_this()捕获一个shared_ptr到 lambda 中。- 只要异步操作未完成,引用计数就不归零,对象不会被销毁。
在 io_uring 原生编程中,我们用原始指针并手动管理生命周期;在更高级的封装中也会借鉴类似机制。
七、总结:三种模型一表对比
| 特性 | 多线程阻塞 | Asio / epoll (Reactor) | io_uring (Proactor) |
|---|---|---|---|
| 线程模型 | 每连接一线程 | 单线程或少量线程 | 同样少量线程 |
| 并发能力 | 数百上千 | 数万 | 数万+ |
| 数据拷贝 | 用户 recv |
用户 recv |
内核自动拷贝 |
| 系统调用频次 | 每连接多次 | 每次 I/O 需 recv/send |
批量提交/收割,极低 |
| 编程风格 | 顺序同步 | 回调驱动(异步感) | 回调/协程(真异步感) |
| 底层机制 | 阻塞 I/O | epoll 事件循环 + 非阻塞 I/O | 共享内存环形队列 + 内核自动完成 |
核心结论:
io_context.run()本质上是一个while+epoll_wait事件循环;async_read_until将"非阻塞读取 + 挂起 + 回调"封装成异步形式。- 日常的"异步服务器"(如 Asio)在 Linux 上本质是 Reactor 模式 :用 epoll 等待事件,用户主动调用
recv拷贝数据,编程层面异步,I/O 层面同步。 - epoll 解决了多线程的调度和内存问题,让单机承载海量连接成为可能。
- io_uring 在 epoll 基础上更进一步,利用异步从根本上改变io架构,在epoll模型的基础上通过异步拷贝+批量收割减少系统调用,将系统调用的开销降至冰点,提高了效率实现操作系统层面真正的异步 I/O。
理解这些,你就掌握了现代高性能网络编程的基石。希望这篇文章能帮你拨开"异步"的迷雾,踏实地走好底层开发的每一步。