Skip to content

IO多路复用使用

  • IO多路复用是一种同时监控多个文件描述符(文件、套接字等)的机制,当某些文件描述符变为可读、可写或有错误时,程序能够及时得到通知并进行相应的处理。具体见Linux网络IO模型
  • 主要看介绍selectepoll,重点看epoll

select

原理和特点

  • 早期IO多路复用机制
  • 核心
    • 通过一个固定大小的文件描述符集合(fd_set),将需要监控的文件描述符加入集合
    • 内核检查
      • 如果文件描述符变为可用,返回就绪的描述符
      • 如果没有就绪则阻塞或等到超时
  • 特点
    • 跨平台支持
    • 集合大小有限linux默认1024,无法处理高并发
    • 效率低:每次调用都需要将文件描述符集合从用户态拷贝到内核态,随着监控的文件描述符数量增多,开销会显著增加
    • 水平触发:如果文件描述符就绪但未被处理,select 会在后续调用中再次返回它

流程

  • 创建 fd_set 变量

  • 循环准备:调用 select 函数

    • FD_ZERO():清空 fd_set
    • FD_SET():将所有需要监视的 FD(如监听套接字、已连接的客户端套接字)加入到相应的 fd_set 中。
    • 确定 nfds 参数:这是所有被监视 FD 中的最大值加 1。
    • 设置 timeout(可选)。
  • 处理返回

    • 检查 select() 的返回值:错误、超时或有 FD 就绪。
    • 如果返回值大于 0,表示有 FD 就绪。
  • 识别就绪 FD:使用 FD_ISSET() 遍历所有之前加入 fd_setFD,判断它们是否仍在 select() 返回后的 fd_set 中(即是否就绪)

  • I/O 操作:对就绪的 FD 执行相应的 I/O 操作(如 accept(), read(), write()

实操

了解即可,重点关注epoll

cpp
#ifndef SERVER_H
#define SERVER_H

#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <sys/select.h> // select
#include <vector>       // stl vector
#include <algorithm>

class Server
{
public:
    Server(int port);
    ~Server();
    void init();
    void handel();

private:
    // 服务端套接字
    int server_fd;
    // 不使用struct sockaddr,绑定时需要强转
    // 实际使用sockaddr_in或sockaddr_in6来区分ip类型
    struct sockaddr_in address;

    // 主文件描述符集合,保存所有活动的连接
    fd_set master_fds;
    // 临时文件描述符集合,用于select调用
    fd_set read_fds;
    // 最大的文件描述符值,用于select调用
    int max_fd;
    // 客户端文件描述符列表
    std::vector<int> client_fds;

    // 消息处理方法
    void echoMsg(int client_fd);
    std::string readMsg(int client_fd);
    void writeMsg(int client_fd, const std::string msg);

    // 处理新连接
    void handleNewConnection();
    // 处理客户端消息
    void handleClientMessage(int client_fd);
    // 移除客户端连接
    void removeClient(int client_fd);
};

#endif

image-20250506173310919

epoll

原理和特点

  • selectpoll的不足
    • 效率随 FD 数量增加而下降,每次都要把所有文件描述符集合完整地拷贝到内核空间
    • 可处理的连接太小
    • 重复的数据拷贝fd集合
  • epoll通过数据结果和机制解决了上述问题
    • 基于事件驱动
      • 不需要在每次调用时传递所有要监控的 FD,它维护了一个内核级的事件表(通常基于红黑树实现),用于存储用户关心的 FD 及其事件
      • 当某个 FD 的状态发生变化(例如,数据可读、可写),内核会主动通过回调机制将这个 FD 加入到一个就绪链表
      • epoll_wait 调用只需要检查这个就绪链表是否为空
    • 高效的就绪 FD 获取
      • epoll_wait 的核心操作是检查就绪链表并将就绪事件返回给用户(拷贝回用户缓冲区)。这个操作的时间复杂度是 O(K),其中 K 是就绪的 FD 数量。与被监控的总 FD 数量 N 无关,这使得 epoll 在处理大量连接但只有少数活跃连接时效率极高
    • 可继续优化
      • 可以使用其他技术优化,如epoll可以使用mmap实现映射就绪链表,而非每次都拷贝
    • 本身没有处理上限,受限于系统配置
    • 避免了多线程线程带来的上下文切换和CPU资源占用

底层原理详解

  • 核心数据结构 (eventpoll)
    • 内核为每个 epoll 实例维护一个 struct eventpoll 对象。
    • 红黑树 (rbr): 高效地存储和查找所有被监控的 fd 对应的 epitem。插入、删除、查找的时间复杂度都是 O(log N),其中 N 是被监控的 FD 总数。epoll_ctl 操作主要作用于此。
    • 就绪链表 (rdllist): 一个双向链表,存储已经就绪的 fd 对应的 epitemepoll_wait 主要从此链表获取结果。添加和移除操作是 O(1)。
    • 等待队列 (wq): 当 epoll_wait 发现就绪链表为空时,调用进程会在此等待队列上睡眠。
    • 锁 (lock, mtx): 保护 eventpoll 结构内部数据(如红黑树、就绪链表)在多线程环境下的并发访问。
  • 回调机制 (关键)
    • epoll_ctl(ADD) 时,不仅仅是将 fd 加入红黑树,更重要的是,它会将一个回调函数ep_poll_callback)与该 fd 关联起来。这是通过将 epoll 实例注册到 fd 对应 struct filepoll 等待队列上实现的。
    • 当底层设备驱动程序(如网络驱动)检测到 fd 的状态变化(如网卡收到数据包),它会唤醒在该 fd 的等待队列上等待的进程/回调。
    • ep_poll_callback 被触发执行。它检查发生的事件类型是否与用户在 epoll_ctl 时指定的 events 匹配。
    • 如果匹配,该回调函数会将与 fd 关联的 epitem 添加到 eventpoll 对象的就绪链表 (rdllist) 中。
    • 唤醒 epoll_wait:如果就绪链表在添加 epitem 之前是空的,ep_poll_callback 还会唤醒在 eventpoll 自身等待队列 wq 上睡眠的进程(即正在调用 epoll_wait 的进程)。
  • 边缘触发
    • 水平触发 (LT - Level Triggered) (默认): 只要文件描述符处于可读/可写状态,epoll_wait就会持续通知。例如,如果socket的接收缓冲区有数据,每次调用epoll_wait都会返回该socket可读,直到数据被完全读完。
    • 边缘触发 (ET - Edge Triggered): 当文件描述符从未就绪状态变到就绪状态时,epoll_wait才会通知一次。之后,即使数据没读完,也不会再通知,直到有的数据到达。
  • ET模式在处理大量连接时效率更高,因为它减少了epoll_wait被重复唤醒的次数
  • 使用ET模式时,如果需要读完所有数据,只能自己判断是否是读取完成(通常使用循环读取直到返回EAGAIN)。具体见下方readMsg函数

流程

  • 创建epoll实例epoll_create(int size) / epoll_create1(int flags)

    • 创建一个 epoll 实例,并返回一个指向该实例的文件描述符(epfd)。这个 epfd 后续将用于 epoll_ctlepoll_wait 调用。
    • poll_create(size): size 参数在早期内核中用于提示内核数据结构的大小,但现在已被忽略,只需传入一个大于 0 的正数即可。
    • epoll_create1(flags): 是推荐使用flags 参数可以设置 EPOLL_CLOEXEC,使得 epfd 在执行 exec 系列调用时自动关闭,避免文件描述符泄漏。
    • 内核创建一个 eventpoll 结构体实例。这个结构体内部包含了用于存储被监控 FD 的红黑树 (rbr) 和用于存放就绪 FD 的双向链表 (rdllist),以及相关的锁和等待队列 (wq)。
  • 注册epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

    • epfd 指向的 epoll 实例注册、修改或删除需要监控的文件描述符 fd 及其关心的事件。
    • op: 操作类型:
    • EPOLL_CTL_ADD: 注册新的 fd 到 epfd 上。
    • EPOLL_CTL_MOD: 修改已经注册的 fd 的监听事件。
    • EPOLL_CTL_DEL: 从 epfd 中移除 fd,不再监听。
    • event: 定义了需要监听的事件类型和行为
    c
    struct epoll_event {
        uint32_t     events;   
        epoll_data_t data;
    };
    typedef union epoll_data {
        void        *ptr;
        int          fd;
        uint32_t     u32;
        uint64_t     u64;
    } epoll_data_t;
    • events: 位掩码,指定关心的事件,如 EPOLLIN (可读), EPOLLOUT (可写), EPOLLPRI (高优先级数据可读), EPOLLERR (错误发生), EPOLLHUP (对端挂断), EPOLLET (设置为边缘触发模式)。
    • data: 用户数据,当 epoll_wait 返回此 fd 的事件时,会将这个 data 一并返回。通常用来存储与 fd 相关的信息,如指向连接对象的指针或 fd 本身。
  • 等待epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

    • 等待 epfd 上注册的 fd 发生事件。

    • events: 用户提供的 struct epoll_event 数组,用于接收就绪事件。内核会将就绪 FD 的信息(包括 epoll_ctl 时设置的 data)填充到这个数组中

    • timeout: 等待的超时时间(毫秒)。

      • -1: 阻塞等待,直到有事件发生。
      • 0: 立即返回,非阻塞检查。
      • >0: 最多等待 timeout 毫秒。
    • 返回值:成功时返回就绪的 FD 数量(大于 0);超时返回 0;出错返回 -1 并设置 errno

    • 当某个被监控的 fd 对应的设备驱动检测到状态变化(如数据到达),会调用其等待队列上的回调函数
    • 之前通过 epoll_ctl(ADD) 注册的 ep_poll_callback 被执行。这个回调函数检查事件是否是用户关心的,如果是,则将对应的 epitem 添加到 eventpoll 的就绪链表 (rdllist)。
    • 如果 rdllist 从空变为非空,则唤醒在 eventpoll 等待队列 wq 上睡眠的进程/线程。

实操

cpp
#ifndef SERVER_H
#define SERVER_H

#include <sys/socket.h>
#include <sys/epoll.h> // epoll
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <vector>
#include <unordered_map>
#include <fcntl.h>
#include <errno.h>

class Server
{
public:
    Server(int port);
    ~Server();
    void init();
    void run();
    std::string readMsg(int client_fd);
    void writeMsg(int client_fd, const std::string &msg);

private:
    int server_fd;
    int epoll_fd;
    struct sockaddr_in address;

    // epoll事件数组,在epoll_wait那里使用
    std::vector<struct epoll_event> events;

    // 设置Socket非阻塞(必须,否则程序阻塞【如write、read】会导致epoll_wait通知不能及时处理)
    void setNonBlocking(int fd);

    // 添加到epoll监听,使用ET模式(默认是LT)
    void addToEpoll(int fd, bool et_mode = true);

    // 从epoll中移除(连接断开时移除)
    void removeFromEpoll(int fd);

    // 处理新连接
    void handleAccept();

    // 处理客户端数据
    void handleClient(int client_fd);

    // 最大事件数
    static const int MAX_EVENTS = 1024;
};

#endif

image-20250506182133808