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

深入理解 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_untilasync_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 会立刻执行以下步骤:

  1. 尝试立即非阻塞读取
    Asio 会先用recv系统调用(非阻塞模式)尝试从 socket 读取数据到buffer_

    • 如果数据已经包含'\n',那么async_read_until在当前函数调用栈内直接调用handler,整个操作同步完成,甚至不经过 epoll。
    • 如果数据不足或没有数据(recv返回EAGAIN),则进入第 2 步。
  2. 挂起操作,注册 epoll 事件
    Asio 将这次读取操作打包成一个“挂起的异步操作对象”,里面记录了:

    • socket 的文件描述符
    • 用户提供的缓冲区
    • 读取条件(读到'\n'
    • 用户回调函数handler

    然后,Asio 会调用epoll_ctl(epfd, EPOLL_CTL_ADD, fd, EPOLLIN),告诉内核:“当这个 socket 上有数据可读时通知我”。做完这一步,async_read_until立即返回,不阻塞当前线程。

  3. 事件循环接手后续
    当远程数据到达,网卡 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 中(但不会移除)
用户回调 lambdahandle_client函数
async_write注册可写事件,epoll_wait返回后执行send
do_accepthandle_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 工作流程(含正确的数据路径)

  1. 用户从 SQ 中获取一个空闲的 SQE(Submission Queue Entry),填入操作类型、fd、缓冲区地址、长度等。
  2. 可反复获取并填写多个 SQE(例如 100 个连接的读取请求)。
  3. 调用一次io_uring_submit(ring)(或者io_uring_enter系统调用),将一批请求批量提交给内核。
  4. 内核收到请求后,会为每个 I/O 操作在后台执行以下步骤:
    • 当网络数据到达时,网卡通过 DMA 将数据写入内核环形缓冲区(ring buffer)
    • 内核网络栈(软中断)处理协议后,数据被移入该 socket 的接收队列(内核缓冲区)
    • 如果该 socket 有一个挂起的 io_uring 读取请求,内核会从接收队列将数据拷贝到用户提交 SQE 时指定的用户缓冲区
    • 拷贝完成后,内核将操作结果(成功字节数或错误码)写入 CQ 中的 CQE(Completion Queue Entry)。
  5. 用户从 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 数据到达的全过程

  1. 硬件中断:网卡收到数据包,通过 DMA 将数据写入内核内存中的环形缓冲区(ring buffer),然后发起硬件中断。
  2. 中断处理(上半部):CPU 执行网卡驱动注册的中断处理程序,屏蔽中断并发出一个软中断(如NET_RX_SOFTIRQ),然后立刻返回。
  3. 软中断(下半部):内核在适当时机执行网络软中断处理:
    • 解析以太网、IP、TCP 头。
    • 找到目标 socket,将数据放入接收队列(内核缓冲区)。
    • 如果该 socket 有挂起的 io_uring 读取请求,内核会将数据拷贝到用户指定的缓冲区,然后向 CQ 写入 CQE。
  4. 通知用户:如果配置了 eventfd,内核会向其写入值,唤醒io_uring_wait_cqe

5.2 epoll 的“回调”呢?

epoll 也使用了内核等待队列:

  • epoll_ctl向 socket 的等待队列注册一个epitem
  • 数据到达后,协议栈唤醒等待队列,触发 epoll 回调将 fd 放入就绪列表。
  • epoll_wait检查就绪列表并返回。

这些回调都是内核态函数,从不直接调用用户态函数。用户必须通过epoll_waitio_uring_wait_cqe主动拉取通知。


六、补充知识点:异步与对象生命周期管理

在 C++ 异步编程中,必须确保回调执行时对象还活着。Asio 示例中使用了std::enable_shared_from_this

  • Sessionshared_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()本质上是一个while+epoll_wait事件循环;async_read_until将“非阻塞读取 + 挂起 + 回调”封装成异步形式。
  • 日常的“异步服务器”(如 Asio)在 Linux 上本质是Reactor 模式:用 epoll 等待事件,用户主动调用recv拷贝数据,编程层面异步,I/O 层面同步。
  • epoll 解决了多线程的调度和内存问题,让单机承载海量连接成为可能。
  • io_uring 在 epoll 基础上更进一步,利用异步从根本上改变io架构,在epoll模型的基础上通过异步拷贝+批量收割减少系统调用,将系统调用的开销降至冰点,提高了效率实现操作系统层面真正的异步 I/O。

理解这些,你就掌握了现代高性能网络编程的基石。希望这篇文章能帮你拨开“异步”的迷雾,踏实地走好底层开发的每一步。