13. 多路转接 epoll

1. I/O多路转接之poll(了解)

pool:只负责等,一次可以等待多个fd,事件就绪,就可以对上层进行事件通知

调用的时候:fd && events 有效:用户告诉内核,你要帮我关心,fd上面的events事件

poll成功返回时:fd && revents 有效:内核告诉用户,你让我关心的fd上面的events事件,已经就绪了

@1:poll输入输出参数分离了,所以不用在poll之前进行参数重置了

@2:poll等待的fd个数,没有上限!fd == -1,在内核中,内核不关心这类fd的events

poll 函数接口

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

// pollfd 结构
struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events */
    short revents; /* returned events */
};

参数说明

fds 是一个 poll 函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合.
• nfds 表示 fds 数组的长度.
timeout 表示 poll 函数的超时时间, 单位是毫秒(ms).
events 和 revents 的取值:

返回结果

返回值小于 0, 表示出错;
返回值等于 0, 表示 poll 函数等待超时;
返回值大于 0, 表示 poll 由于监听的文件描述符就绪而返回

socket 就绪条件

select

poll的优点

1. 突破文件描述符数量限制

在 select 中,受限于 FD_SETSIZE 宏(通常默认值是 1024 ),能监控的文件描述符数量是有限的。而 poll 使用链表来管理文件描述符集合,理论上对所监控的文件描述符数量没有固定的上限限制,它可以根据实际需求动态地添加和删除要监控的文件描述符,从而能适应需要处理大量并发连接的场景,比如大规模的网络服务器。

2. 接口设计更灵活

每个 pollfd 结构体对应一个要监控的文件描述符,events 字段由用户设置想要监控的事件(如 POLLIN 表示监控读事件,POLLOUT 表示监控写事件等),revents 字段由内核填充,表示实际发生的事件。这种方式使得对每个文件描述符的事件监控设置更加清晰直观,相比 select 中使用三个独立的文件描述符集合(读、写、异常) ,poll 对于不同类型事件的管理和操作更加灵活。

3. 数据结构更新机制更优

select 在返回时会修改传入的文件描述符集合,只保留就绪的描述符,每次调用前都需要重新初始化集合。而 poll 不会修改用户传入的 pollfd 数组,只是在每个 pollfd 结构体的 revents 字段中填充实际发生的事件,用户可以直接通过检查这个字段来判断文件描述符的状态,不需要像 select 那样重新设置整个集合,减少了重复的初始化操作,提高了编程的便利性和效率。

4. 性能表现相对较好

不同于 select 使用三个位图来表示三个 fdset 的方式,poll 使用一个 pollfd 的指针实现.
pollfd 结构包含了要监视的 event 和发生的 event,不再使用 select“参数-传递的方式. 接口使用比 select 更方便.
poll 并没有最大数量限制 (但是数量过大后性能也是会下降).

poll的缺点

poll 中监听的文件描述符数目增多时:
• 和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符. 
• 每次调用 poll 都需要把大量的 pollfd 结构从用户态拷贝到内核中. 
• 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视
的描述符数量的增长, 其效率也会线性下降.

2. I/O多路转接之epoll

2.1 epoll初识

epoll 是基于对多个fd等待的就绪事件通知机制.(性能超好)epoll 是 Linux 下高效的 I/O 多路复用机制,用于解决在处理大量并发连接时的性能问题,它通过巧妙的数据结构和事件通知机制,大幅提升了系统对 I/O 操作的管理效率。

2.2 epoll工作原理

epoll 主要依赖内核中的两个关键数据结构来实现高效管理:

  • 红黑树用于管理用户注册要监控的文件描述符。在 epoll 中,每个节点对应一个要监控的文件描述符,通过红黑树可以快速地添加、删除以及查找文件描述符,这使得当有大量文件描述符需要管理时,依然能保持高效的操作性能。
  • 双向链表用于存储就绪的文件描述符。当有文件描述符所监控的事件(如可读、可写、异常等)发生时,内核会将该文件描述符对应的节点添加到这个双向链表中。在 epoll_wait 返回时,用户空间可以快速地获取到所有就绪的文件描述符,而不需要像 select 和 poll 那样遍历所有注册的文件描述符。
  • 这里红黑树的本质:用户告诉内核,你要帮我关心哪一个fd,哪一个事件
  • 就绪队列本质:内核告诉用户,哪一个fd上面的那些事件已经就绪了

2. 回调机制的作用

  • 特定 fd 数据就绪 → 触发回调
    当网卡收到数据(硬件中断),驱动处理后会触发协议栈回调,把对应 fd 的事件从红黑树 “激活” 到就绪链表。
  • 为什么需要回调
    为了实现 “事件驱动”:不用轮询检查 fd 状态,数据就绪时自动通知 epoll,大幅提升效率。

3. 工作流程

  1. 注册阶段epoll_ctl 把 fd 插入红黑树,同时为该 fd 注册回调(告诉内核:这个 fd 有数据时,调用我的回调函数)。
  2. 事件触发:网卡收数据 → 驱动处理 → 协议栈触发回调 → 把 fd 从红黑树移到 就绪链表
  3. 用户态获取epoll_wait 直接从就绪链表拿数据,返回给用户处理。

总结

epoll 用 红黑树管理监控的 fd,用 就绪链表快速返回事件,靠 回调机制实现自动通知,让内核和用户态高效配合,处理高并发 I/O 事件。

epoll 用 生产者 - 消费者模型 管理就绪事件,epoll_wait 不怕缓冲区小(没拿完下次继续),epoll_ctl 负责 “插节点 + 注册回调”,且接口线程安全,用户处理事件时不用额外校验,拿到的都是真・就绪事件

当某一进程调用 epoll_create 方法时,Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关.
struct eventpoll{
    ....
    /*红黑树的根节点,这颗树中存储着所有添加到 epoll 中的需要监控的事件*/
    struct rb_root rbr;
    /*双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件*/
    struct list_head rdlist;
    ....
};
每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件.
这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是 lgn,其中 n 为树的高度)
而所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法.
这个回调方法在内核中叫 ep_poll_callback,它会将发生的事件添加到 rdlist 双链表中.
在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体
struct epitem{
    struct rb_node rbn;//红黑树节点
    struct list_head rdllink;//双向链表节点
    struct epoll_filefd ffd; //事件句柄信息
    struct eventpoll *ep; //指向其所属的 eventpoll 对象
    struct epoll_event event; //期待发生的事件类型
}

• 当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的
rdlist 双链表中是否有 epitem 元素即可.
 
• 如果 rdlist 不为空,则把发生的事件复制到用户态,同时将事件数量返回给用
户. 这个操作的时间复杂度是 O(1)
总结一下, epoll 的使用过程就是三部曲: 
• 调用 epoll_create 创建一个 epoll 句柄; 
• 调用 epoll_ctl, 将要监控的文件描述符进行注册
• 调用 epoll_wait, 等待文件描述符就绪;

工作模式(LT,ET)

epoll 提供了两种工作模式,不同模式下对事件的处理方式有所差异:

  • 水平触发(Level Triggered,LT):这是 epoll 的默认工作模式。在这种模式下,如果文件描述符对应的读缓冲区中有数据可读,或者写缓冲区中有空间可写,epoll 就会一直通知用户程序。也就是说,只要条件满足,就会不断触发事件。白话:只要底层有报文,就要一直通知上层!--- 上层可以不读完毕,因为我知道你下次还会通知我支持阻塞读写和非阻塞读写
  • 边缘触发(Edge Triggered,ET):在边缘触发模式下,只有当文件描述符的状态发生变化时(比如从无数据可读变为有数据可读,或者从无空间可写变为有空间可写 ),epoll 才会通知用户程序。这种模式要求应用程序在收到通知后尽可能一次性地处理完所有相关事件。白话:从网络中拿到数据,导致底层数据变化的时候,才会通知上层,即使上层把数据拿走了一部分,后来再也没有新增了,ET再也不通知上层了!(倒逼上层,必须收到通知,把本轮数据取完)只支持非阻塞的读写

哪一个效率高些?效率高不高,取决于有效通知有多少!--- ET高

理解 ET 模式和非阻塞文件描述符

 为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮训的方式来读缓冲区, 保证一定能把完整的请求都读出来.

而如果是 LT 没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪.

使用非阻塞文件描述符(通过 O_NONBLOCK 标志设置),配合循环读取 / 写入,
确保一次性处理完所有数据:
// 设置文件描述符为非阻塞模式
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

// 在 ET 模式下读取数据(必须循环读直到返回 EAGAIN)
while (1) {
    char buf[1024];
    int n = read(sockfd, buf, sizeof(buf));
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // 已读完所有数据,退出循环
            break;
        }
        // 处理其他错误
        perror("read error");
        break;
    } else if (n == 0) {
        // 客户端关闭连接
        close(sockfd);
        break;
    }
    // 处理读取到的数据
    process_data(buf, n);
}

 ET 模式通过 “状态变化触发” 和 “非阻塞 I/O” 的组合,将数据处理的控制权完全交给应用程序,迫使开发者主动管理数据流动,从而减少内核与用户空间的交互次数,实现更高的性能。但这种高效是以增加编程复杂度为代价的,需要开发者深入理解其工作机制,才能正确应用。

 2.3 epoll的相关系统调用

 epoll_create

用于创建一个 epoll 实例,并在内核中分配相应的数据结构(红黑树和就绪链表等)

int epoll_create(int size);

•  自从 linux2.6.8 之后,size 参数是被忽略的.

•  用完之后, 必须调用 close()关闭

 epoll_ctl

用于对 epoll 实例进行管理,包括添加、修改或删除要监控的文件描述符及其对应的事件;用户告诉内核,你要帮我关心哪一个fd,上面的event事件

int epoll_ctl(int epfd, int op, int fd, struct epoll_event
*event);

epoll 的事件注册函数.

• 它不同于 select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里
先注册要监听的事件类型. 
• 第一个参数是 epoll_create()的返回值(epoll 的句柄). 

• 第二个参数表示动作,用三个宏来表示. 

• 第三个参数是需要监听的 fd. 

• 第四个参数是告诉内核需要监听什么事

第二个参数的取值:
• EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
• EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件;
• EPOLL_CTL_DEL:从 epfd 中删除一个 fd;

 struct epoll_event 结构如下:

events 可以是以下几个宏的集合:
• EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭); 
• EPOLLOUT : 表示对应的文件描述符可以写; 
• EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外
数据到来); 
• EPOLLERR : 表示对应的文件描述符发生错误; 
• EPOLLHUP : 表示对应的文件描述符被挂断; 
• EPOLLET : 将 EPOLL 设为边缘触发(Edge Triggered)模式, 这是相对于水平
触发(Level Triggered)来说的. 
• EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继
续监听这个 socket 的话, 需要再次把这个 socket 加入到 EPOLL 队列里.

 epoll_wait

用于等待所监控的文件描述符上有事件发生;内核通知用户:你让我关心的那个fd们,上面的哪些事件已经就绪

int epoll_wait(int epfd, struct epoll_event * events, int
maxevents, int timeout);

收集在 epoll 监控的事件中已经发送的事件.

• 参数 events 是分配好的 epoll_event 结构体数组.

• epoll 将会把发生的事件赋值到 events 数组中 (events 不可以是空指针,内核
只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存). 

• maxevents 告之内核这个 events 有多大,这个 maxevents 的值不能大于创建
epoll_create()时的 size. • 参数 timeout 是超时时间 (毫秒,0 会立即返回,-1 是永久阻塞). 

• 如果函数调用成功,返回对应 I/O 上已准备好的文件描述符数目,如返回 0 表
示已超时, 返回小于 0 表示函数失败.

2.4 epoll的优点

相较于 select 和 poll ,epoll 的优势显著:

  • 解决文件描述符数量限制select 受限于 FD_SETSIZE 宏,poll 虽然理论上无限制,但在实际使用中随着文件描述符增多性能下降明显。而 epoll 借助红黑树管理文件描述符,能够高效处理大量文件描述符,适应高并发场景。
  • 降低遍历开销(回调机制)select 和 poll 每次调用都需要遍历所有注册的文件描述符来检查就绪状态,时间复杂度为 O(n) 。epoll 采用事件回调机制,使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度 O(1). 即使文件描述符数目很多, 效率也不会受到影响
  • 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开

2.5 epoll的使用场景

epoll 的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll 的性能可能适得其反.
对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用 epoll
例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll.
如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用 epoll 就并不合适. 具体要根据需求和场景特点来决定使用哪种 IO 模型

2.6 epoll中的惊群问题(了解)

2.7 epoll示例:epoll服务器(ET模式)

基于 ET(边缘触发)+ 非阻塞 设计高性能 TCP 服务器的思路拆解,核心解决 “如何正确管理连接、处理数据”:

1. 核心目标:用 ET + 非阻塞 实现高效服务器

  • ET 模式:事件只触发一次,必须配合非阻塞 I/O,循环读写直到 EAGAIN
  • 非阻塞:避免 read/write 阻塞,保证一次处理完所有就绪数据。

2. 关键设计点

(1)“给每个 fd 封装 buffer + Connection”
  • 问题:读数据时,需为每个客户端(fd)保存未处理完的数据,否则会丢数据。
  • 方案:用 Connection 结构体封装 fd、读写缓冲区、状态等,比如:
  • struct Connection {
        int fd;               // 文件描述符
        char read_buf[4096];  // 读缓冲区
        int read_len;         // 已读数据长度
        // 其他状态...
    };
  • 作用:解耦模块,让每个 fd 的数据独立管理,方便后续处理(如粘包、业务逻辑)。
(2)“监听 socket + 普通 socket 统一处理”
  • 监听 socket(listensock):负责接受新连接,触发 EPOLLIN 时调用 accept
  • 普通 socket(客户端连接):负责收发数据,触发 EPOLLIN 时读数据,EPOLLOUT 时写数据。
  • 统一:都通过 epoll 监控 EPOLLIN 事件,区别仅在于事件触发后的处理逻辑(accept 或 read)。
(3)“数据维护:用 _connections 管理所有连接”
  • 用数组 / 哈希表 _connections 保存所有 Connection,键是 fd,值是 Connection 对象。
  • 作用:快速根据 fd 找到对应的缓冲区和状态,处理读写时直接关联。
// 1. 初始化:创建监听socket,设为非阻塞 + ET模式
int listensock = socket(...);
set_nonblocking(listensock);
epoll_ctl(epfd, EPOLL_CTL_ADD, listensock, EPOLLIN | EPOLLET);

// 2. 事件循环
while (true) {
    epoll_wait(epfd, events, maxevents, -1);
    for (每个事件) {
        if (事件是 listensock) {
            // 处理新连接:accept + 封装为 Connection + 加入 _connections
            int clientfd = accept(listensock, ...);
            set_nonblocking(clientfd);
            _connections[clientfd] = Connection{clientfd, ...};
            epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, EPOLLIN | EPOLLET);
        } else {
            // 处理普通socket:循环读数据到 Connection 的 buffer
            Connection& conn = _connections[fd];
            while (true) {
                int n = read(fd, conn.read_buf + conn.read_len, sizeof(conn.read_buf) - conn.read_len);
                if (n < 0) {
                    if (errno == EAGAIN) break; // 读完,退出循环
                    // 处理错误,关闭连接
                    close(fd);
                    _connections.erase(fd);
                    break;
                } else if (n == 0) {
                    // 客户端关闭,清理资源
                    close(fd);
                    _connections.erase(fd);
                    break;
                }
                conn.read_len += n;
                // (可选)处理完整数据包(如粘包拆分)
                process_packet(conn);
            }
        }
    }
}

总结

用 ET + 非阻塞 设计服务器,需:

  • 为每个 fd 封装 Connection 管理缓冲区和状态;
  • 统一处理监听 socket 和普通 socket 的 EPOLLIN 事件;
  • 循环读写直到 EAGAIN,保证数据处理完整。

这样就能实现高效、正确的 I/O 服务器,避免数据丢失和模块耦合

Common.hpp

Connection.hpp

Epoller.hpp

InetAddr.hpp

Listener.hpp

Main.cc

makefile

Mutex.hpp

Socket.hpp

TcpServer.hpp

附录:

阻塞读是一种常见的输入读取方式,其核心特点是当程序执行读取操作时,若没有可读取的数据,会暂停当前线程的执行,直到有数据可用或超时 / 出错才继续。简单说,就是 “没数据就等,有数据才干活”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值