深入理解 Linux 异步 I/O:从 epoll 到 io_uring
深入理解 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)编写出的“异步”服务器:
// asio_echo_server.cpp —— 看起来非常“异步”的回声服务器#defineASIO_STANDALONE#include<asio.hpp>#include<iostream>#include<memory>#include<string>usingasio::ip::tcp;classSession:publicstd::enable_shared_from_this<Session>{public:Session(tcp::socket socket):socket_(std::move(socket)){}voidstart(){do_read();}private:voiddo_read(){autoself=shared_from_this();asio::async_read_until(socket_,buffer_,'\n',[this,self](std::error_code ec,std::size_t length){if(!ec){std::istreamis(&buffer_);std::string line;std::getline(is,line);std::string reply="echo: "+line+"\n";do_write(reply);}});}voiddo_write(conststd::string&message){autoself=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_;};classServer{public:Server(asio::io_context&io_context,shortport):acceptor_(io_context,tcp::endpoint(tcp::v4(),port)){do_accept();}private:voiddo_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_;};intmain(){asio::io_context io_context;Serverserver(io_context,12345);io_context.run();}这段代码给人的感觉非常“异步”:调用async_read_until、async_accept后立刻返回,数据到来时回调被自动调用,我们完全没有参与任何拷贝甚至数据的处理。但要真正理解这种“魔法”,我们必须深入io_context.run()和async_read_until的内部。
二、Asio 的魔法:io_context 与 async_read_until 内部探秘
2.1io_context.run()到底在做什么?
io_context是 Asio 事件循环的引擎。当我们调用io_context.run()时,它会在当前线程中启动一个无限循环,内部大致等价于下面的伪代码:
voidrun(){while(has_pending_operations()){// 1. 调用 epoll_wait(Linux)或等效系统调用,阻塞等待事件intn=epoll_wait(epoll_fd,events,MAX_EVENTS,timeout);// 2. 遍历所有就绪的 fdfor(inti=0;i<n;++i){intfd=events[i].data.fd;// 3. 找到之前挂起的异步操作,执行相应的回调pending_operation*op=find_operation(fd);if(op->type==READ){// 尝试非阻塞读取,如果满足条件则调用用户回调intbytes=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.2async_read_until内部做了什么?
当我们在Session::do_read()中调用:
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库sudoaptinstallliburing-dev# Debian/Ubuntu
下面的代码包含详细的注释,帮助你理解每一行在做什么。
// io_uring_echo.cpp#include<liburing.h>#include<iostream>#include<cstring>#include<unistd.h>#include<sys/socket.h>#include<netinet/in.h>constexprintPORT=8080;constexprintQUEUE_DEPTH=256;constexprintBUF_SIZE=4096;structConnection{intfd;charbuf[BUF_SIZE];intstate;// 0:等待读,1:等待写};intsetup_listening_socket(intport){intfd=socket(AF_INET,SOCK_STREAM,0);intopt=1;setsockopt(fd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));structsockaddr_inaddr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);addr.sin_addr.s_addr=INADDR_ANY;bind(fd,(structsockaddr*)&addr,sizeof(addr));listen(fd,128);returnfd;}voidsubmit_accept(structio_uring*ring,intlisten_fd){structio_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 完成}voidsubmit_read(structio_uring*ring,Connection*conn){structio_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;}voidsubmit_write(structio_uring*ring,Connection*conn,intlen){structio_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;}intmain(){structio_uringring;io_uring_queue_init(QUEUE_DEPTH,&ring,0);intlisten_fd=setup_listening_socket(PORT);submit_accept(&ring,listen_fd);io_uring_submit(&ring);while(true){structio_uring_cqe*cqe;io_uring_wait_cqe(&ring,&cqe);void*data=io_uring_cqe_get_data(cqe);intresult=cqe->res;if(data==reinterpret_cast<void*>(-1)){intclient_fd=result;if(client_fd>=0){auto*conn=newConnection{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);deleteconn;}else{if(conn->state==0)submit_write(&ring,conn,result);elsesubmit_read(&ring,conn);}}io_uring_cqe_seen(&ring,cqe);io_uring_submit(&ring);// 批量提交所有新请求}io_uring_queue_exit(&ring);return0;}观察这段代码与 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)包含:
structio_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 中,收割也可以批量进行:
structio_uring_cqe*cqes[BATCH_SIZE];intn=io_uring_peek_batch_cqe(&ring,cqes,BATCH_SIZE);for(inti=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。
理解这些,你就掌握了现代高性能网络编程的基石。希望这篇文章能帮你拨开“异步”的迷雾,踏实地走好底层开发的每一步。