Skip to content

网络服务器 I/O:select、poll、epoll 与 io_uring ​

网络服务器通常要同时维护大量连接,而绝大多数连接在某一时刻并没有数据可处理。核心问题不是“如何创建很多线程”,而是:如何用有限线程高效等待大量 socket,并在事件到来后正确处理状态、缓冲区和背压。

本文侧重服务器工程模型;select、poll、epoll 的操作系统机制概览见 I/O 多路复用。

先区分四个概念 ​

阻塞与非阻塞 ​

  • 阻塞 I/O:recv 没数据时当前线程睡眠,直到有数据、连接关闭或发生错误;
  • 非阻塞 I/O:recv 暂时无法完成时立即返回 -1,并设置 errno = EAGAIN/EWOULDBLOCK。

非阻塞只说明“调用不会长时间睡眠”,不等于异步。使用 epoll 的事件循环仍然由应用在收到就绪通知后主动调用 accept/recv/send。

同步与异步 ​

  • 同步 I/O:应用发起并参与完成 I/O 操作;即使先等就绪,最终仍由应用调用 recv/send;
  • 异步 I/O:应用提交“执行读写”的请求,内核完成后再通知结果。

就绪通知与完成通知 ​

模型典型 API内核通知的含义应用下一步
就绪(readiness)select / poll / epoll“现在执行某类 I/O 很可能不会阻塞”应用调用 accept/recv/send,处理部分读写和 EAGAIN
完成(completion)io_uring、Windows IOCP“此前提交的 I/O 已完成,结果是 N 字节或错误码”应用消费完成项并更新连接状态

“socket 可读”不等于一条完整业务消息已经到齐:TCP 是字节流,可能只收到包头的一部分、一个完整消息或多个粘在一起的消息。

select ​

select 用多个 fd_set 位图表示关心的可读、可写和异常 fd:

  1. 用户态准备位图和超时时间;
  2. 每次调用都把集合传入内核;
  3. 内核检查所有 fd,把不就绪位清零;
  4. 返回后应用还要从 0 扫描到 max_fd 找出就绪 fd。

关键限制 ​

  • fd_set 可表达的 fd 数量通常受 FD_SETSIZE 限制,常见为 1024;这是 fd 数值限制,不只是连接数量限制;
  • 内核和应用都要扫描整个范围,主要成本为 O(n);
  • select 会修改传入的集合和 timeval,因此每轮都要从主集合复制工作集合;
  • 第一个参数必须是 max_fd + 1,不是 fd 数量;
  • 优点是接口历史久、可移植性好,适合连接少、跨平台或教学场景。
cpp
fd_set master;
FD_ZERO(&master);
FD_SET(listen_fd, &master);
int max_fd = listen_fd;

for (;;) {
    fd_set ready = master;              // select 会修改集合
    int n = select(max_fd + 1, &ready, nullptr, nullptr, nullptr);
    if (n < 0) {
        if (errno == EINTR) continue;
        break;
    }

    for (int fd = 0; fd <= max_fd && n > 0; ++fd) {
        if (!FD_ISSET(fd, &ready)) continue;
        --n;
        // listen_fd: accept;连接 fd: recv / 解析 / 写入发送队列
    }
}

poll ​

poll 用连续的 pollfd 数组替代位图:

cpp
struct pollfd {
    int   fd;
    short events;   // 关心的事件
    short revents;  // 实际发生的事件
};

它解决了 fd_set 固定大小和 max_fd 位图扫描问题,但没有解决本质上的全量处理:

  • 每次调用仍要把整个数组交给内核;
  • 内核逐项检查,返回后应用逐项扫描 revents,仍是 O(n);
  • fd 数量主要受进程文件描述符上限和内存限制,不再受 FD_SETSIZE 固定上限;
  • 将 fd 设为负数可临时忽略该数组项;删除大量连接时要考虑数组压缩成本。

poll 适合连接规模中等、希望接口简单且兼顾 POSIX 可移植性的场景。

epoll ​

epoll 把“兴趣集合”持久保存在内核中:

  1. epoll_create1 创建实例;
  2. epoll_ctl(ADD/MOD/DEL) 增量维护 fd 及兴趣事件;
  3. epoll_wait 只返回已经就绪的事件,而不是每轮传入全部 fd。

因此,大量连接长期空闲时,应用处理成本主要与本轮返回的活跃事件数 k 有关。不要把它简化为“所有操作都是 O(1)”:epoll_wait 获取就绪项可近似看作 O(k),而兴趣集合维护、内核唤醒和回调仍有成本,具体内部结构也属于 Linux 实现细节。

LT、ET 与 ONESHOT ​

模式语义使用要求
LT(水平触发,默认)只要仍可读/可写,就会继续通知更容易写对;单次未读完,下轮还会提醒
ET(边缘触发)仅在状态从未就绪变为就绪时通知fd 必须非阻塞;循环 accept/recv/send 直到 EAGAIN
EPOLLONESHOT通知一次后自动禁用该 fd,需 MOD 重新武装多线程处理同一连接时避免并发消费,但必须保证重新武装

ET 并不天然比 LT 快。它可以减少重复通知,但会增加状态机复杂度;若漏读到 EAGAIN、漏重新武装或错误处理半关闭,连接可能永久“沉默”。

服务器事件循环骨架 ​

cpp
int epfd = epoll_create1(EPOLL_CLOEXEC);
set_nonblocking(listen_fd);

struct epoll_event ev {};
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

std::array<epoll_event, 1024> events {};

for (;;) {
    int n = epoll_wait(epfd, events.data(), events.size(), -1);
    if (n < 0) {
        if (errno == EINTR) continue;
        break;
    }

    for (int i = 0; i < n; ++i) {
        const auto flags = events[i].events;
        const int fd = events[i].data.fd;

        if (fd == listen_fd) {
            for (;;) {
                int conn = accept4(listen_fd, nullptr, nullptr,
                                   SOCK_NONBLOCK | SOCK_CLOEXEC);
                if (conn < 0) {
                    if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                    if (errno == EINTR) continue;
                    break;
                }
                add_connection(epfd, conn);
            }
            continue;
        }

        if (flags & EPOLLERR) {
            handle_socket_error_and_close(fd);
            continue;
        }
        if (flags & (EPOLLIN | EPOLLRDHUP | EPOLLHUP)) {
            // 读到 EAGAIN;recv == 0 表示对端关闭写方向。
            if (!read_until_eagain(fd)) {
                close_connection(fd);
                continue;
            }
        }
        if (flags & EPOLLOUT) flush_until_eagain(fd);
    }
}

代码只是结构示意,真实服务器还要维护连接对象、输入/输出缓冲区、协议解析器、定时器、发送队列和关闭状态。

事件循环辅助函数:职责速查 ​

函数通常要做什么关键边界
set_nonblocking(fd)用 fcntl(F_GETFL) 读取旧状态,再以 `F_SETFL(oldO_NONBLOCK)` 设置非阻塞
add_connection(epfd, conn)创建 Connection 状态,初始化收/发缓冲和超时器,以 EPOLL_CTL_ADD 注册 `EPOLLINEPOLLRDHUP`
read_until_eagain(fd)循环 recv;读到的字节追加输入缓冲并增量拆包;EINTR 重试,EAGAIN 表示本轮读空recv == 0 是对端关闭写方向,不一定立刻关闭本端;若还需发送响应,应标记半关闭,发完再关。限制输入缓冲,避免慢客户端耗尽内存
flush_until_eagain(fd)循环 send 待发送队列,记录部分写偏移;写空时取消 EPOLLOUTEPOLLOUT 不保证一次发完;遇到 EAGAIN 保留剩余数据并继续监听。Linux 可用 MSG_NOSIGNAL 防止 SIGPIPE 结束进程
close_connection(fd)取消定时器/任务,EPOLL_CTL_DEL,清理连接状态和缓冲,最后 close(fd)保持幂等;避免当前事件批次中关闭 fd 后又被系统复用,导致旧事件误作用于新连接

更严谨的实现不宜让 read_until_eagain 只返回 bool,至少应区分“暂时读空”“对端半关闭”和“致命错误”。同样,flush_until_eagain 需要区分“暂时写满”和“连接错误”。业务处理、磁盘 I/O、数据库等待等耗时工作应投递给有界工作队列,不能直接阻塞事件循环。

epoll 高频易错点 ​

  1. ET 没有读到 EAGAIN:剩余数据可能不再触发新边缘;
  2. 始终监听 EPOLLOUT:socket 大部分时间可写,会造成事件循环空转;只有发送队列非空时才开启,写空后关闭;
  3. 假设一次 send 发完:非阻塞发送可能只写部分字节,剩余数据必须进入连接发送队列;
  4. 只处理 EPOLLIN:还要处理 EPOLLERR、EPOLLHUP、EPOLLRDHUP、半关闭和 recv == 0;
  5. 连接对象提前释放:event.data.ptr 可能变成悬空指针。可使用 fd + generation token,并把销毁推迟到当前事件批次结束;
  6. fd 复用:旧 fd 关闭后数值可能很快分配给新连接,不能仅靠整数 fd 判断事件归属;
  7. 惊群:多线程/多进程共同等待监听 socket 时,可评估 EPOLLEXCLUSIVE 或每 worker 使用 SO_REUSEPORT;
  8. 事件循环执行耗时任务:数据库慢查询、磁盘同步 I/O、压缩和复杂计算会阻塞所有连接,应提交给有界工作队列。

io_uring ​

io_uring 是 Linux 的异步 I/O 接口,主要通过用户态与内核共享的两个环通信:

  • SQ(Submission Queue):应用放入 SQE,描述要执行的操作;
  • CQ(Completion Queue):内核放入 CQE,res 是结果字节数或负错误码,user_data 用于关联请求上下文;
  • 可一次提交多条 SQE,批量减少系统调用;可选 SQPOLL 由内核线程轮询 SQ,但会消耗额外 CPU,不应默认启用;
  • 请求可能乱序完成,必须通过 user_data 区分;需要顺序时使用连接状态机或 IOSQE_IO_LINK 等机制;
  • SQE 引用的缓冲区、地址结构和用户上下文必须至少存活到 CQE 到来,不能提交后立即复用或释放。

io_uring 的网络能力 ​

能力作用
ACCEPT / multishot accept一次提交可产生多次连接完成通知,减少重复提交
RECV / multishot recv连续接收并产生多个 CQE;常与 provided buffer ring 配合
provided buffers / registered buffers预先提供缓冲区,减少分配和注册开销
fixed files预注册 fd,降低每次查找和引用成本
linked requests / linked timeout把操作与超时、依赖顺序组合提交
SEND_ZC在满足条件时减少发送路径的数据复制;仍要等待通知 CQE 后才能安全复用缓冲区

这些能力依赖内核和 liburing 版本。上线前应通过 feature probing 检测 opcode/flag 支持,并保留降级路径,不能仅凭“内核版本看起来够新”就假定全部可用。

io_uring 不是“默认零拷贝” ​

共享 SQ/CQ 减少的是提交和完成元数据在用户态/内核态之间的复制与系统调用次数。普通 recv/send 的网络载荷仍可能复制;只有使用 SEND_ZC、注册缓冲区或特定内核路径时,才可能进一步减少载荷复制,而且仍有缓冲区生命周期和回退语义。

io_uring 高频易错点 ​

  1. CQ 消费不及时:完成队列积压甚至溢出,必须把 CQ 当作有界资源并持续回收;
  2. 默认完成有序:多个在途请求可能乱序完成。对同一 TCP socket,一般不要无约束地并发提交多个 send 或多个 recv;
  3. 缓冲区过早复用:内核尚未完成操作时修改/释放缓冲区会造成数据错误或生命周期问题;
  4. 取消等于立即停止:取消请求也可能与原请求完成竞态,必须同时处理“原操作已完成”和“取消完成”;
  5. 所有操作都是真异步:部分操作或文件类型可能回退到内核 worker 线程,仍可能受阻塞和线程池拥塞影响;
  6. 一个 ring 被所有线程共享:SQ/CQ 竞争和 cache line 抖动可能抵消收益。常见设计是每个 I/O worker 一个 ring,连接固定归属;
  7. 只看吞吐不看尾延迟:批量越大并不一定越好,过度 batching 可能增加排队延迟。

Reactor 与完成式模型 ​

epoll 的优势是成熟、语义清晰、生态广;io_uring 的优势是批量提交、统一文件和网络异步操作、减少系统调用,并能使用更丰富的网络 opcode。两者不是简单的新旧替代关系。

对比速查 ​

维度selectpollepollio_uring
模型就绪就绪就绪完成(也支持 poll 类操作)
集合维护每轮传入位图每轮传入数组内核持久维护兴趣集合应用提交具体 I/O 请求
返回结果修改位图,应用全扫描设置 revents,应用全扫描返回活跃事件返回已完成操作的 CQE
典型处理成本O(n)O(n)约 O(k),k 为活跃数与完成数相关,可批量
FD 限制常见 FD_SETSIZE资源限制资源限制资源和 ring 深度限制
可移植性较好POSIXLinuxLinux,且依赖较新内核
工程复杂度低低中高
推荐场景小规模/教学中小规模/兼容Linux 高并发网络默认选择高 IOPS、批处理、磁盘与网络统一异步,并经压测证明确有收益

服务器工程结构 ​

连接分片 ​

常见模型是“监听/分发 + N 个 I/O worker”:

text
accept / SO_REUSEPORT
        ↓
按连接哈希或轮询分配
        ↓
I/O worker 0   I/O worker 1   ...   I/O worker N-1
(event loop)    (event loop)          (event loop)
        ↓
同一连接始终由固定 worker 维护状态

固定归属可避免每条消息都跨线程加锁。若同一玩家或房间还要求业务有序,可再按 player_id/room_id 哈希到固定业务队列。

每个连接至少维护什么 ​

cpp
struct Connection {
    int fd;
    ReadBuffer input;
    WriteQueue output;
    ProtocolParser parser;
    ConnectionState state;
    TimePoint last_active;
    std::uint64_t generation;
};
  • 输入缓冲区负责累计 TCP 字节并处理半包/粘包;
  • 输出队列保存未发送完的数据,支持部分写;
  • 状态机处理 TLS 握手、认证、正常通信、半关闭和最终关闭;
  • generation 防止 fd 复用后旧事件误操作新连接;
  • 活跃时间用于空闲超时和慢客户端清理。

背压与过载保护 ​

高并发服务器不能只追求“尽可能多读”:

  1. 连接级高水位:发送队列超过阈值时暂停该连接读取、拒绝非关键消息或断开慢客户端;
  2. 全局有界队列:I/O 线程到业务线程的队列必须有上限,满时阻塞、拒绝或降级,不能无限堆积;
  3. 仅按需监听可写事件:输出队列为空就取消 EPOLLOUT;
  4. 读写预算:单轮给每个连接限制最大字节/消息数,避免一个热点连接饿死其他连接;
  5. CPU 任务隔离:压缩、序列化、寻路、脚本执行等提交到有界工作池,完成后把结果投递回原 I/O worker;
  6. 过载指标:事件循环延迟、队列深度、发送积压、丢弃数、连接数、P95/P99、CQ 深度和 EAGAIN 比例。

定时器 ​

事件循环还需要处理握手超时、心跳、重传、空闲关闭等定时任务。连接多时可用时间轮降低大量定时器更新成本;数量较少或精度要求高时可用最小堆。不要为每个连接单独创建系统线程或频繁系统定时器。

如何选择 ​

  • 默认 Linux 网络服务器:先用成熟的非阻塞 socket + epoll,把连接状态机、背压和线程模型写正确;
  • 大量文件/磁盘 I/O 与网络 I/O 混合、高 syscall 密度、可批量提交:评估 io_uring;
  • 连接数很少或重视简单可移植:select/poll 足够;
  • 跨平台:考虑 Asio、libuv 等抽象;底层可能分别使用 epoll、kqueue、IOCP;
  • 不要为了“新”而使用 io_uring:必须在目标内核、真实连接数、包大小和 CPU 亲和性下比较吞吐、P99、CPU、内存与故障恢复。

面试速答 ​

select 使用 fd 位图,每轮在用户态和内核态之间传递并全量扫描,且通常受 FD_SETSIZE 限制;poll 改成 pollfd 数组,取消固定上限但仍 O(n) 扫描。epoll 在内核持久维护兴趣集合,epoll_wait 返回活跃事件,适合大量空闲连接;ET 必须配合非阻塞并读写到 EAGAIN。io_uring 是完成式异步接口,通过共享 SQ/CQ 批量提交和回收 I/O,可减少系统调用并支持 multishot、注册缓冲区和 SEND_ZC,但完成可能乱序、缓冲区生命周期复杂、特性依赖内核版本。工程上先保证状态机、部分读写、背压和连接分片正确,再用压测决定 epoll 还是 io_uring。

参考 ​

使用 Markdown 与 VitePress 构建