资讯动态

深入理解 Linux 异步 I/O:从 epoll 到 io_uring

发布时间:2026/8/4 2:01:23 来源:尧图企业网站定制
深入理解 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_waitrecv信号驱动立即返回内核发信号通知线程自己做SIGIOrecv异步 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#includeasio.hpp#includeiostream#includememory#includestringusingasio::ip::tcp;classSession:publicstd::enable_shared_from_thisSession{public:Session(tcp::socket socket):socket_(std::move(socket)){}voidstart(){do_read();}private:voiddo_read(){autoselfshared_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 replyecho: line\n;do_write(reply);}});}voiddo_write(conststd::stringmessage){autoselfshared_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_contextio_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_sharedSession(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_waitLinux或等效系统调用阻塞等待事件intnepoll_wait(epoll_fd,events,MAX_EVENTS,timeout);// 2. 遍历所有就绪的 fdfor(inti0;in;i){intfdevents[i].data.fd;// 3. 找到之前挂起的异步操作执行相应的回调pending_operation*opfind_operation(fd);if(op-typeREAD){// 尝试非阻塞读取如果满足条件则调用用户回调intbytesrecv(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_waitasync_read_until发起尝试recv不满足则保持 fd 在 epoll 中但不会移除用户回调 lambdahandle_client函数async_write注册可写事件epoll_wait返回后执行senddo_accepthandle_accept用shared_from_this保持 Session 存活原始代码中连接由程序逻辑隐式管理但本质相同结论Asio 的“异步”是编程层面的异步——用户不需要阻塞等待只需注册回调。但在操作系统层面它仍是I/O 多路复用Reactor 模式主线程阻塞在epoll_wait上就绪后主动调用recv完成数据拷贝。这套机制的优点是大幅简化了事件驱动编程缺点是每个 I/O 操作最终都绕不开用户态的系统调用。三、真正的异步 I/Oio_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 中获取一个空闲的 SQESubmission Queue Entry填入操作类型、fd、缓冲区地址、长度等。可反复获取并填写多个 SQE例如 100 个连接的读取请求。调用一次io_uring_submit(ring)或者io_uring_enter系统调用将一批请求批量提交给内核。内核收到请求后会为每个 I/O 操作在后台执行以下步骤当网络数据到达时网卡通过 DMA 将数据写入内核环形缓冲区ring buffer。内核网络栈软中断处理协议后数据被移入该 socket 的接收队列内核缓冲区。如果该 socket 有一个挂起的 io_uring 读取请求内核会从接收队列将数据拷贝到用户提交 SQE 时指定的用户缓冲区。拷贝完成后内核将操作结果成功字节数或错误码写入 CQ 中的 CQECompletion 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#includeliburing.h#includeiostream#includecstring#includeunistd.h#includesys/socket.h#includenetinet/in.hconstexprintPORT8080;constexprintQUEUE_DEPTH256;constexprintBUF_SIZE4096;structConnection{intfd;charbuf[BUF_SIZE];intstate;// 0等待读1等待写};intsetup_listening_socket(intport){intfdsocket(AF_INET,SOCK_STREAM,0);intopt1;setsockopt(fd,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt));structsockaddr_inaddr{};addr.sin_familyAF_INET;addr.sin_porthtons(port);addr.sin_addr.s_addrINADDR_ANY;bind(fd,(structsockaddr*)addr,sizeof(addr));listen(fd,128);returnfd;}voidsubmit_accept(structio_uring*ring,intlisten_fd){structio_uring_sqe*sqeio_uring_get_sqe(ring);io_uring_prep_accept(sqe,listen_fd,nullptr,nullptr,0);io_uring_sqe_set_data(sqe,reinterpret_castvoid*(-1));// 标记为 accept 完成}voidsubmit_read(structio_uring*ring,Connection*conn){structio_uring_sqe*sqeio_uring_get_sqe(ring);io_uring_prep_recv(sqe,conn-fd,conn-buf,BUF_SIZE,0);io_uring_sqe_set_data(sqe,conn);conn-state0;}voidsubmit_write(structio_uring*ring,Connection*conn,intlen){structio_uring_sqe*sqeio_uring_get_sqe(ring);io_uring_prep_send(sqe,conn-fd,conn-buf,len,0);io_uring_sqe_set_data(sqe,conn);conn-state1;}intmain(){structio_uringring;io_uring_queue_init(QUEUE_DEPTH,ring,0);intlisten_fdsetup_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*dataio_uring_cqe_get_data(cqe);intresultcqe-res;if(datareinterpret_castvoid*(-1)){intclient_fdresult;if(client_fd0){auto*connnewConnection{client_fd,{0},0};submit_read(ring,conn);submit_accept(ring,listen_fd);// 继续接受}}else{Connection*connstatic_castConnection*(data);if(result0){close(conn-fd);deleteconn;}else{if(conn-state0)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_uringCQ 与批量收割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];intnio_uring_peek_batch_cqe(ring,cqes,BATCH_SIZE);for(inti0;in;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/Oepoll 事件循环 非阻塞 I/O共享内存环形队列 内核自动完成核心结论io_context.run()本质上是一个whileepoll_wait事件循环async_read_until将“非阻塞读取 挂起 回调”封装成异步形式。日常的“异步服务器”如 Asio在 Linux 上本质是Reactor 模式用 epoll 等待事件用户主动调用recv拷贝数据编程层面异步I/O 层面同步。epoll 解决了多线程的调度和内存问题让单机承载海量连接成为可能。io_uring 在 epoll 基础上更进一步利用异步从根本上改变io架构在epoll模型的基础上通过异步拷贝批量收割减少系统调用将系统调用的开销降至冰点提高了效率实现操作系统层面真正的异步 I/O。理解这些你就掌握了现代高性能网络编程的基石。希望这篇文章能帮你拨开“异步”的迷雾踏实地走好底层开发的每一步。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价