深入理解 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)编写出的"异步"服务器:

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

  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 中(但不会移除)
用户回调 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 工作流程(含正确的数据路径)

  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

bash 复制代码
sudo 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 数据到达的全过程

  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/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。

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

相关推荐
wunaiqiezixin1 天前
MIT 6.S081 syscall 实验篇(lab2):System call tracing (moderate)
linux·unix·os·xv6·mit6.s081
^yi10 天前
【Linux系统编程】对操作系统的理解
linux·运维·服务器·操作系统·系统调用·os
星马梦缘1 个月前
操作系统计算题专项练习(15题)
操作系统·os·银行家算法·段页式
岑梓铭3 个月前
考研408《操作系统》复习笔记,第二章《2.3.3 + 2.3.4 经典同步问题、管程》
笔记·考研·操作系统·408·os
星马梦缘3 个月前
操作系统实验4 —— 计算机系统中的 IPC
操作系统·共享内存·os·管道·ipc
无小道3 个月前
OS——进程间关系与守护进程
os·守护进程·进程间关系
无小道4 个月前
OS——信号
信号·os
星马梦缘4 个月前
快表、页表地址获取+缓存、主存、硬盘数据获取
算法·操作系统·os·tlb
mifengxing4 个月前
操作系统(三)
操作系统·多线程·os·进程信息传递