多路转接epoll

1. epoll初识

核心定位:epoll 是一种基于多个 fd 的就绪事件通知机制,通过监控这些 fd 上的事件(如可读、可写),在事件就绪时通知应用程序,从而避免阻塞等待。这与 select 和 poll 的目标一致,但设计更高效。

历史背景:epoll 是在 Linux 内核 2.5.44 版本中引入的,按照 man 手册的说法,它是为处理大批量句柄(fd)而改进的 poll,旨在解决传统方法在高并发场景下的局限性。

性能优势:epoll 几乎具备了之前多路 I/O 方法的所有优点,被公认为 Linux 2.6 下性能最好的多路 I/O 就绪通知方法。相比之下,poll 随着 fd 数量的增多,效率会逐渐降低,而 epoll 通过内部优化(如使用事件驱动和回调机制)避免了这一问题,能够高效处理成千上万的并发连接。

实现与使用:epoll 的实现原理、接口使用与 select 和 poll 差别非常大。它通过 epoll_create、epoll_ctl 和 epoll_wait 等系统调用,实现了更灵活的事件注册和触发机制,减少了遍历所有 fd 的开销,从而提升了性能。

2. epoll的相关系统调用

2.1 epoll_create

epoll_create 函数用于创建一个 epoll 模型,其原型为:

cpp 复制代码
int epoll_create(int size);

epoll_create 是创建 epoll 实例的核心函数。它返回一个 epoll 文件描述符,后续所有对 epoll 的操作都通过这个描述符进行。虽然参数 size 在早期版本中用于提示内核分配内存大小,但在现代 Linux 内核中,该参数已被忽略,内核会动态调整内部数据结构的大小。因此,通常传入 1 或任意正整数即可。


2.2 epoll_ctl

epoll_ctl 函数用于控制 epoll 文件描述符,其原型为:

cpp 复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

其中:

  • epfd 是由 epoll_create 返回的 epoll 文件描述符;
  • op 操作类型包括:
    • EPOLL_CTL_ADD:首次注册一个文件描述符到 epoll 实例中,同时指定关心的事件(如 EPOLLIN)。
    • EPOLL_CTL_MOD:修改已注册文件描述符的监听事件,例如从只读改为可读可写。
    • EPOLL_CTL_DEL:从 epoll 实例中移除某个文件描述符,不再监听其事件,此时 event参数可忽略。
  • fd 是要操作的文件描述符;
  • `event 是指向 struct epoll_event 结构体的指针,用于指定关注的事件。

结构体定义如下:

cpp 复制代码
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;

struct epoll_event {
uint32_t events;      /* Epoll events */
epoll_data_t data;    /* User data variable */
};

事件标志位包括:

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

struct epoll_event 中:

  • events 字段是一个位掩码,表示关心的事件集合(如EPOLLIN | EPOLLET);
  • data 字段是用户自定义数据,可用于存储与文件描述符关联的上下文信息(如 socket 对应的客户端地址等),内核不会修改该字段。

`epoll_ctl 是管理 epoll 监听事件的核心接口。通过它,用户可以向 epoll 实例添加、修改或删除需要监听的文件描述符及其事件。

交互逻辑用户通过此调用告诉内核 :"请帮我关心 fd上发生的 events事件"。


2.3 epoll_wait

epoll_wait 函数用于等待 I/O 事件,其原型为:

cpp 复制代码
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

其中:

  • epfd 必须是由 epoll_create 创建的有效描述符;
  • events 数组长度由 maxevents 限制,实际返回值不会超过该值;
  • maxevents 是 events 数组的最大长度,必须大于0;
  • timeout 控制等待时间,单位为毫秒,支持非阻塞(0)和无限阻塞(-1);
  • 返回值:
    • 正数:就绪事件数量,并将对应的事件信息填充到 events 数组中。;
    • >0:超时无事件;
    • 负数:出错(如 errno 设置)。

`epoll_wait 是阻塞等待事件发生的函数,当有文件描述符就绪时,内核会将就绪事件填充到用户提供的 events 数组中,并返回就绪事件的数量。

交互逻辑内核通过此调用通知用户:"你让我关心的那些 fd 中,events数组里这些已经就绪了"。


3. epoll的原理

epoll 的核心原理是通过在内核中维护一个高效的数据结构(红黑树+就绪队列),实现事件从"用户注册"到"内核通知"的 O(1) 时间复杂度管理,从而解决了 select/poll 线性扫描的性能瓶颈。

用户告诉内核关心什么 :通过 epoll_ctl将需要监控的 文件描述符 (fd) 和其对应的事件 注册到内核。内核将此信息存储在一棵红黑树 中,键就是 fd

内核告诉用户什么就绪了 :当某个 fd上的事件就绪(如数据到达),内核会通过图中的回调机制 ,将该事件对应的节点放入一个就绪队列 。用户调用 epoll_wait时,内核只需直接检查并返回这个队列的内容即可,无需遍历所有监控的fd。这使得"检测是否有fd就绪"的时间复杂度为 O(1)

我们分别来看这三个系统调用在内核中的主要动作。

1. epoll_create

当用户调用epoll_create (或epoll_create)时,内核会创建一个eventpoll结构体,并返回一个文件描述符,这个文件描述符对应的是eventpoll实例。

主要步骤:

  1. 分配并初始化 struct eventpoll `:
    • 分配一个struct eventpoll对象,并初始化其中的成员,如等待队列(wq)、就绪链表(rdllist)、红黑树根(rbr)等。
    • 初始化自旋锁和互斥锁,用于保护eventpoll的访问。
  2. 创建一个匿名文件
    • 内核会创建一个新的匿名文件,并分配一个文件描述符。这个文件对应的file_operationseventpoll_fops,即该文件的操作集合(如pollrelease等)都是由epoll模块提供的。
    • eventpoll实例的指针存储在匿名文件的private_data字段中,这样通过文件描述符就可以找到对应的eventpoll实例。
  3. 返回文件描述符
    • 将文件描述符返回给用户。这个文件描述符就是epoll实例的句柄,后续的epoll_ctlepoll_wait都会使用它。

注意:epoll_create 的参数 size 在较新的内核中已经被忽略,但必须大于0。

2. epoll_ctl

epoll_ctl用于向epoll实例中添加、修改或删除被监视的文件描述符。这里以添加(EPOLL_CTL_ADD)为例:

主要步骤:

  1. 根据文件描述符找到对应的 struct file
    • 通过用户传递的epoll文件描述符,找到对应的eventpoll实例(即struct eventpoll)。
    • 通过用户传递的目标文件描述符,找到对应的struct file
  2. 检查是否已经添加
    • eventpoll的红黑树中查找是否已经存在该文件描述符对应的epitem。如果已经存在,则返回错误(EEXIST)。
  3. 创建并初始化 struct epitem
    • 分配一个struct epitem,并初始化其成员,包括设置要监视的文件描述符、事件掩码(用户传递的struct epoll_event)等。
    • epitemep指针指向eventpoll实例。
  4. 设置回调函数
    • 调用目标文件描述符的poll操作(即调用file->f_op->poll),并将一个回调函数(ep_ptable_queue_proc)传递给poll操作。这个回调函数会在文件描述符有事件发生时被调用。
    • 在回调函数中,内核会创建一个eppoll_entry,将其添加到文件描述符的等待队列中,并设置回调函数为ep_poll_callback
  5. epitem插入红黑树
    • 将新创建的epitem插入到eventpoll的红黑树中,以便后续快速查找。
  6. 如果当前文件描述符已经有事件就绪,将其添加到就绪链表
    • 在调用poll操作后,如果文件描述符已经有事件就绪,那么回调函数ep_poll_callback会被调用,将epitem添加到就绪链表(rdllist)中,并唤醒等待在epoll_wait上的进程。

注意:修改(EPOLL_CTL_MOD)和删除(EPOLL_CTL_DEL)操作类似,但修改会更新事件掩码,删除则会从红黑树中移除epitem,并清理相关的等待队列项。

3. epoll_wait

epoll_wait用于等待在epoll实例上注册的文件描述符有事件发生。

主要步骤:

  1. 根据文件描述符找到 eventpoll实例
    • 通过用户传递的epoll文件描述符,找到对应的struct eventpoll
  2. 检查就绪链表
    • 如果就绪链表(rdllist)不为空,则说明已经有文件描述符就绪,直接处理就绪事件。
    • 如果就绪链表为空,且用户设置的超时时间不为0,则当前进程需要等待。
  3. 等待事件发生
    • 将当前进程加入到eventpoll的等待队列(wq)中,然后让出CPU进入睡眠状态。
    • 当有事件发生时(例如,某个被监视的文件描述符可读或可写),回调函数ep_poll_callback会被调用,它会将对应的epitem添加到就绪链表,并唤醒等待队列(wq)中的进程。
  4. 收集就绪事件
    • 进程被唤醒后,将就绪链表中的epitem取出,遍历这些epitem,将发生的事件复制到用户空间。
    • 在复制过程中,会根据用户传递的maxevents参数来控制最多返回多少个事件。
  5. 返回就绪事件的数量
    • 返回给用户就绪事件的数量,并更新用户传递的events数组。

注意:epoll_wait可以设置超时时间,如果超时时间设置为0,则立即返回,即使没有事件就绪;如果设置为-1,则无限等待直到有事件发生。

总结

  • epoll_create:创建eventpoll实例和对应的文件描述符。
  • epoll_ctl:向eventpoll实例中添加、修改或删除被监视的文件描述符,并设置回调函数。
  • epoll_wait:等待被监视的文件描述符有事件发生,并返回就绪的事件。

高效的管理结构:红黑树与就绪队列

内核为每个epoll实例维护一个eventpoll结构体。其中的红黑树(rbr 负责管理所有被监控的文件描述符(fd),这使得对fd的增、删、改操作的时间复杂度都是O(log n),非常适合管理大量连接。就绪链表(rdllist 则是一个双向链表,专门存放有事件发生的fd。当epoll_wait调用时,内核无需遍历所有监控的fd,只需检查这个链表是否为空即可,这使检测就绪事件的时间复杂度是O(1)。

事件就绪的回调(Callback)机制

这是epoll高效的关键。通过epoll_ctl注册监听事件时,内核会通过ep_ptable_queue_proc函数在目标文件(如socket)的等待队列上添加一个表项eppoll_entry,并设置其回调函数为ep_poll_callback。当网络数据到达,导致socket状态变化时,内核协议栈就会调用这个回调函数。该回调函数的核心工作就是将对应的epitem快速添加到就绪队列rdllist中,并唤醒等待在epoll_wait上的进程。这是一种"中断"式通知,避免了像select/poll那样需要主动轮询所有fd。

怎么看待就绪队列??

就绪队列是 epoll 机制中的核心数据结构之一,用于存储当前已经就绪(可读、可写或有异常)的文件描述符(fd)事件。从图中可以看出,epoll 的本质是一个基于事件就绪的"生产者-消费者"模型:

  • 生产者 :内核在检测到某个 fd 上有数据到达、连接建立、写缓冲区空闲等事件时,会将该 fd 对应的 struct epitem 节点插入到就绪队列(wait_queue_head_t wq)中。
  • 消费者 :用户态调用 epoll_wait 时,会从就绪队列中取出所有已就绪的事件,返回给应用程序处理。

就绪队列的本质是一个链表结构,由 struct epitem 组成,每个节点包含:

  • int fd------ 对应的文件描述符
  • uint32_t events------ 注册的事件类型(如 EPOLLIN)
  • list head link------ 链入就绪队列的指针
  • rb_node left; rb_node right ------ 红黑树节点,用于在 epoll 的红黑树中定位该 fd

注意:以上这些节点包含的成员是为了便于理解简化的,实际要复杂一点

epoll 的本质不是基于轮询的,而是生产者-消费者模型! 这意味着:

  • 内核主动将就绪事件推送到就绪队列(生产)
  • 用户程序通过 epoll_wait 主动拉取事件(消费)

此外,就绪队列是线程安全的,因为其访问受到 rwlock_t lock 保护,避免多线程竞争。

获取就绪事件,如果缓冲区大小不够了怎么办??

当调用 epoll_wait 时,如果用户提供的 events 缓冲区大小不足以容纳所有就绪事件,会发生以下情况:

  • 不会阻塞或失败 ,而是只返回能容纳的事件数量
  • 剩余未返回的事件会 保留在就绪队列中,不会丢失。
  • 下一次调用 epoll_wait 时,这些事件仍会被返回(即默认给你保留着,下次拿

这体现了 epoll 的非丢弃式事件通知机制 ,确保了事件的完整性。应用层,处理就绪事件的时候,处理的全都是就绪的,根本不需要非法检测!

从实现角度看,epoll_wait 函数内部会遍历就绪队列,逐个填充 events 数组,直到填满或队列为空。如果缓冲区不足,函数返回实际填充的数量,并保留剩余事件供下次使用。

因此,解决方案是:增大 maxevents 参数,或者分批调用 epoll_wait`,以确保所有事件都能被处理。


4. epoll demo代码

为了先了解一下怎么使用epoll和相关函数的用法以及代码怎么写,我们这里demo代码和之前的select和poll一样,实现一个EchoServer,而且只处理读事件

cpp 复制代码
#pragma once

#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include <unistd.h>
#include "Socket.hpp"

using namespace SocketModule;
using namespace LogModule;

class EpollServer
{
    const static int size = 64;
    const static int defaultfd = -1;

public:
    EpollServer(int port) : _listensock(std::make_unique<TcpSocket>()), _isrunning(false), _epfd(defaultfd)
    {
        _listensock->BuildTcpSocketMethod(port);
        // 创建epoll模型
        _epfd = epoll_create(256);
        if (_epfd < 0)
        {
            LOG(LogLevel::FATAL) << "epoll_create error...";
            exit(EPOLL_CREATE_ERR);
        }
        LOG(LogLevel::INFO) << "epoll_create success, _epfd: " << _epfd;

        // 将listensocket设置到内核中!
        struct epoll_event ev; // 此时还没有设置到内核中,也没有在rb_tree中新增节点
        ev.events = EPOLLIN;
        ev.data.fd = _listensock->Fd(); // 这里未来是维护的是用户的数据,常见的是fd

        int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Fd(), &ev);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "epoll_ctl error...";
            exit(EPOLL_CTL_ERR);
        }
        LOG(LogLevel::INFO) << "epoll_ctl success..., listensockfd: " << _listensock->Fd();
    }

    void Start()
    {
        int timeout = -1;
        _isrunning = true;
        while (_isrunning)
        {
            int n = epoll_wait(_epfd, _revs, size, timeout);
            switch (n)
            {
            case -1:
                LOG(LogLevel::ERROR) << "epoll_wait error ...";
                break;
            case 0:
                LOG(LogLevel::INFO) << "time out ...";
                break;
            default:
                // 有事件就绪, 就不仅仅是新连接到来, 还有可能是读事件就绪
                LOG(LogLevel::DEBUG) << "有事件就绪了..., 事件个数n : " << n;
                Dispatcher(n); // 处理就绪的事件
                break;
            }
        }
        _isrunning = false;
    }

    // 事件派发器
    void Dispatcher(int rnum)
    {
        LOG(LogLevel::DEBUG) << "event ready ..."; // LT: 水平触发模式--epoll默认
        // 有事件就绪, 不仅仅是新连接到来, 还有可能是读事件就绪
        for (int i = 0; i < rnum; i++)
        {
            int sockfd = _revs[i].data.fd;
            uint32_t revent = _revs[i].events;
            if (revent & EPOLLIN)
            {
                // 此时事件就绪,是新连接到来,还是读事件就绪?
                if (sockfd == _listensock->Fd())
                {
                    // 如果是监听套接字就绪,那就是新连接到来
                    Accepter();
                }
                else
                {
                    // 读事件就绪
                    Recver(sockfd);
                }
            }
        }
    }

    // 连接管理器
    void Accepter()
    {
        // 新连接到来,我们需要accept接受新连接
        InetAddr client;
        int sockfd = _listensock->Accept(&client);
        if (sockfd >= 0)
        {
            // 获取新链接到来成功, 然后呢??能不能直接read/recv()
            // 当然不行,sockfd是否读就绪,我们不清楚
            // 只有谁最清楚,未来sockfd上是否有事件就绪?肯定是epoll!
            // 所以我们需要将新的sockfd,托管给epoll!
            LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client is: " << client.StringAddr();
            struct epoll_event ev;
            ev.events = EPOLLIN;
            ev.data.fd = sockfd;
            int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
            if (n < 0)
            {
                LOG(LogLevel::FATAL) << "epoll_ctl error...";
            }
            else 
                LOG(LogLevel::INFO) << "epoll_ctl success..., sockfd: " << sockfd;
        }
    }

    // IO处理器
    void Recver(int sockfd)
    {
        LOG(LogLevel::INFO) << "开始读取数据";
        // 处理 sockfd 读事件
        // 我们在这里读取的时候,就不会阻塞了 --- 因为 epoll 已经完成等操作了!
        char buffer[1024];
        ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
        // recv 读的时候会有bug!因为无法保证能够读取到一个完整的请求!--- TCP 是流式协议!
        // 我们目前先不做处理,等到后面的时候,再做处理!
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client say# " << buffer << std::endl;
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit ...";
            // 此时不再需要让epoll帮我们再关心fd
            // 从epoll中移除fd的关心 && 关闭fd -- 细节:epoll_ctl: 只能移除合法fd -- 先移除,在关闭!!
            int m = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);//删除不需要再关心任何事件直接填空指针
            if(m > 0)
            {
                LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success: " << sockfd;
            }
            close(sockfd);
        }
        else
        {
            LOG(LogLevel::ERROR) << "recv error ...";
            // 此时不再需要让epoll帮我们再关心fd
            // 从epoll中移除fd的关心 && 关闭fd -- 细节:epoll_ctl: 只能移除合法fd -- 先移除,在关闭!!
            int m = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);//删除不需要再关心任何事件直接填空指针
            if(m > 0)
            {
                LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success: " << sockfd;
            }
            close(sockfd);
        }
    }

    ~EpollServer() {}

private:
    std::unique_ptr<Socket> _listensock;
    bool _isrunning;
    int _epfd;
    struct epoll_event _revs[size];
};

运行结果


5. epoll的优点(和 select 的缺点对应)

1. 接口设计更高效

接口使用方便,这体现在 epoll将功能拆分为三个独立的系统调用:

  • epoll_create: 创建 epoll 实例,初始化内核数据结构。
  • epoll_ctl: 负责注册、修改或删除需要监控的文件描述符(fd)及其事件。这是一个增量操作,只需在连接建立或关闭时调用。
  • epoll_wait: 专责等待事件发生,并将就绪事件返回给用户。

这种设计将"管理监控列表"和"等待事件"解耦 。相比之下,select/poll每次调用时,都需要从用户态向内核态传递一个完整的 fd 集合,告诉内核"这次要监控这些",参数既作为输入也作为输出,每次调用前都必须重新初始化,非常繁琐且低效。

2. 数据拷贝开销小

select/poll的瓶颈 : 每次调用 selectpoll,都需要将整个需要监控的 fd 集合从用户空间全量拷贝到内核空间。当监控数千个 fd 时,这种频繁的内存拷贝会成为巨大的性能开销。

epoll的优化 : 在通过 epoll_ctl添加一个 fd 时,会将其信息永久地 拷贝到内核中一次。之后,在事件循环中调用 epoll_wait时,不再需要传递整个 fd 集合 ,内核已经保存了这份列表。这使得 epoll_wait的参数非常轻便,极大地减少了用户态和内核态之间不必要的数据拷贝。

3. 事件检测:O(1) 复杂度 vs. O(n) 复杂度

**select**/ **poll**的轮询 : 无论 fd 是否就绪,select/poll在内核中都需要线性扫描(遍历) 整个传入的 fd 集合,检查每个 fd 的状态。算法时间复杂度是 O(n),其中 n 是监控的 fd 总数。当 n 很大但活跃连接很少时,效率极低。

epoll的回调机制

  • 注册回调 : 在通过 epoll_ctl添加 fd 时,内核会为其注册一个回调函数。
  • 事件就绪 : 当某个 fd 上的数据就绪时(例如,网络数据到达),设备驱动会触发中断,内核网络栈处理数据后,会调用该 fd 对应的回调函数
  • 加入就绪队列 : 这个回调函数的工作很简单:将对应的 fd 添加到一个内核维护的就绪链表中。
  • 高效返回epoll_wait被调用时,内核只需检查这个就绪链表是否为空。如果不为空,就将链表中的项目复制到用户空间。这个过程的复杂度是 O(1),因为它只与就绪的 fd 数量相关,而与监控的 fd 总数无关。

简单来说,epoll是"事件驱动"的,只有活跃的 fd 才会主动通知内核;而 select/poll是"主动轮询"的,需要一次次地问所有 fd:"你们准备好了吗?"

4. 支持的文件描述符数量无硬性限制

select的硬伤 : 受限于 FD_SETSIZE的默认值(通常为 1024),一个进程能监控的 fd 数量有严格上限。虽然可以修改并重新编译内核,但会带来其他问题。

poll的改进: 使用链表存储,理论上无数量限制。

epoll的优势 : 和 poll一样,没有数量上的硬性限制。其能监控的 fd 上限取决于系统所能打开的最大文件描述符数量(可通过 /proc/sys/fs/file-max查看),通常远高于 1024,足以应对高并发场景

关于"内存映射(mmap)"的澄清

网上流传的"epoll通过 mmap在用户态和内核态共享就绪队列,实现零拷贝"的说法是不准确甚至是错误的

实际情况是:

  • 存在数据拷贝 : 当 epoll_wait返回时,内核确实需要将就绪事件的数据 (即 struct epoll_event数组)从内核空间拷贝到你在用户空间预先分配好的数组中。
  • 优势所在epoll的优势并不在于"零拷贝",而在于我们前面阐述的:1)只在 epoll_ctl(ADD)时的一次 fd 信息拷贝;2) epoll_wait返回时只拷贝就绪事件,而非全量 fd 集合。

各方案优缺点详解

select:经典但受限

select 的最大优势在于其出色的跨平台支持 ,几乎在所有主流操作系统上都能使用。此外,它提供的超时精度可以达到微秒级 。然而,它的缺点也非常突出:单个进程能监控的文件描述符数量有严格限制(通常为1024) ;每次调用都需要在用户态和内核态之间完整拷贝fd集合 ,并且内核和应用程序都需要线性扫描所有fd,这在连接数增多时性能损耗很大。其接口设计也较为繁琐,需要多次设置fd集合。

poll:改进的select

poll 对 select 的主要改进在于取消了监控fd数量的上限 ,改用了链表结构。同时,它将文件描述符和事件绑定在一起 ,通过 eventsrevents分离输入输出参数,接口设计更合理,无需每次调用前重置监控集合。但是,poll 依然没有解决select的根本性能问题 :每次调用仍需整体拷贝fd结构,并且内核仍然需要轮询所有fd来判断就绪状态,性能随fd数量增加线性下降的问题依旧存在。

epoll:Linux下的高性能之选

epoll 是 Linux 为处理大规模并发连接而设计的高性能方案。其核心优势在于事件驱动(回调)机制 ,内核只在fd就绪时通过回调函数通知应用程序,使得检测就绪fd的时间复杂度为 O(1)。它通过 epoll_ctl进行fd的一次性注册 ,避免了每次调用的巨大拷贝开销。此外,它支持更高效的边缘触发(ET)模式 ,可以减少相同事件被重复触发的次数。当然,epoll 的主要缺点是缺乏跨平台性,通常只能在 Linux 系统上使用。它的编程模型,尤其是ET模式,需要设置非阻塞I/O并循环读写直到结束,比select/poll更复杂一些。

如何选择

了解了它们的特性后,你可以根据具体场景做出选择:

选择 select :通常仅在对跨平台性有严格要求 ,或需要微秒级超时控制 ,且监控的连接数非常少的场景下考虑。

选择 poll :当需要监控的连接数超过1024,但又不具备使用epoll的条件(如非Linux平台),且连接数尚未达到需要事件驱动模型的程度时,可作为select的替代方案。

选择 epoll :这是构建高性能Linux网络服务器首选 。尤其适用于处理大量并发连接 ,但其中只有小部分是活跃连接的场景(例如Web服务器、即时通讯网关等)。在连接非常活跃的情况下,epoll的回调开销可能使其优势不明显,甚至性能略低于poll。


6. epoll的工作方式

问题:如果事件就绪但未处理,epoll 会一直通知吗?为什么?

答案:取决于工作模式。

  • 在 LT(水平触发)模式下:会一直通知。
    • 原因:只要文件描述符处于就绪状态(如可读、可写),epoll_wait 就会持续返回该事件,直到你处理它(例如读取数据)。
    • 适用于初学者或对可靠性要求高的场景。
  • 在 ET(边缘触发)模式下:不会重复通知。
    • 原因:只在状态变化时通知一次(从非就绪 → 就绪)。如果未处理完数据,下次状态不变时不会再次通知。
    • 要求用户必须一次性读取或写完所有数据,否则可能遗漏事件。
    • 适用于高性能场景,减少系统调用次数。

📌因此,"epoll 会一直通知我们"的说法,仅适用于 LT 模式。

问题:如何理解水平和边缘触发?

核心比喻解读

  • 小王 :代表应用程序
  • 快递(商品) :代表Socket接收缓冲区中的数据
  • 快递员(张三/李四)打电话通知 :代表内核通过 epoll_wait返回事件,通知应用程序
  • 小王下楼取快递 :代表应用程序调用 read等函数读取数据
  • 快递放在楼下 :代表数据仍留在内核缓冲区中

两种触发模式详解

1. 水平触发(LT)- 示例1:耐心的张三

模式特点只要缓冲区中还有数据,就会持续通知。

  • 过程:无论小王一次取走几个快递,只要楼下(缓冲区)还有剩余的快递(数据),快递员张三就会不停地打电话通知他,直到全部取完。
  • 内核行为 :只要某个socket的读缓冲区中还有数据可读,每次调用epoll_wait时,都会报告这个socket的EPOLLIN(可读)事件。
  • 对程序的影响
    • 编程友好 :如果一次read没有读完所有数据,下次调用epoll_wait时依然会得到通知,程序有机会继续读取。容错性高。
    • 可能效率较低:如果数据就绪后,应用程序因为某些原因没有立刻处理完,会导致内核反复通知,产生一些不必要的开销。

2. 边缘触发(ET)- 示例2:只通知一次的李四

模式特点仅在缓冲区数据状态发生变化时(从无到有/从有到更多)通知一次。

  • 过程 :李四只在快递到达楼下的那一刻 打电话通知小王一次。之后无论小王下来取走几个,只要他没一次性取完,李四都不会再主动通知。直到有新的快递送达(数据从无到有或增加),他才会再次通知。
  • 内核行为 :仅当socket的读缓冲区从空变为不空 (即有新数据到达)时,才会报告EPOLLIN事件。如果缓冲区中一直有旧数据未被读走,epoll_wait不会再通知。
  • 对程序的影响(非常关键!)
    • 高性能:避免了同一事件在数据未处理完时的重复通知,减少了系统调用和上下文切换,效率更高。
    • 编程要求严格 :如图中所述,"倒逼上层,必须收到通知,把本轮数据取完" 。这意味着:
      1. 必须一次性读完/写完 :当epoll_wait返回一个socket的可读事件后,应用程序必须循环调用 read,直到其返回错误EAGAINEWOULDBLOCK(表示本轮数据已读完),否则会永远丢失这部分数据的通知。
      2. 必须使用非阻塞IO :为了支持上面的循环读取,socket必须设置为非阻塞模式(O_NONBLOCK)。否则,在最后一次read时,如果缓冲区已空,线程会阻塞在那里,导致程序卡死。

select和poll其实也是工作在LT模式下,epoll既可以支持LT,也可以支持ET。不过LT是 epoll 的默认行为.

问题:为什么 ET 必须与非阻塞 I/O 配合?

假设使用阻塞 I/O,在 ET 模式下:

  • recv(fd, 100)读取了部分数据(如 100 字节),但缓冲区还有 2000 字节未读。
  • 若此时再次调用 recv ,由于缓冲区仍有数据,系统调用不会返回 EAGAIN,而是阻塞等待更多数据
  • 但 ET 模式下,epoll 不会再次通知你,因为状态没有变化(仍然是"有数据"),导致你的程序卡死在recv上,无法处理其他事件。

因此,必须设置 fd 为非阻塞模式:

cpp 复制代码
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

这样,当 recv 无法立即读取到数据时,会立即返回 EAGAIN 或 EWOULDBLOCK,程序可以继续处理其他任务。

问题:LT可以设置非阻塞吗?

可以,LT也可以设置非阻塞,通过循环来一次性读取完数据,只要你想就可以这么做,但是这么做就会增加编程复杂性

那么你ET倒逼上层要一次性读完数据,我 LT 也可以这样啊!!!(指一次性读完数据),那为什么不这样呢?

  • LT 模式 :允许"惰性"处理。程序在收到通知后,可以只读取部分数据,下次调用 epoll_wait时,内核会因为数据未读完而再次通知。这种行为是合法的。
  • ET 模式操作系统(OS)通过其机制"约束程序员" 。如果程序不一次性读完,剩余的数据将不会再触发通知,可能导致数据滞留。这种"约束"创造了一种确定性的 I/O 行为

所以,ET模式是通过OS来约束程序员要这么写代码,但是LT模式取决于程序员想怎么写就怎么写,那程序员肯定是怎么省心怎么写

LT 对比 ET效率

我们知道ET模式可以减少 epoll_wait 的唤醒次数,从而降低系统调用开销。

但是还有一方面可以看出ET模式效率更高

TCP 流量控制与接收窗口 :TCP 通信中,接收方通过 接收窗口(Window Size) 告诉发送方"我还能收多少数据"。这个窗口大小取决于接收缓冲区的剩余空间。

ET 如何提升效率

  1. 及时清空缓冲区:ET 模式强制程序尽快、尽可能多地读取数据。
  2. 更快、更大程度地更新窗口 :接收缓冲区被迅速清空后,接收方能更快、更频繁 地向发送方回送一个更大的新窗口(更大的 win)。
  3. 提高发送并发度 :发送方一旦获知接收方有更大的可用窗口,就可以连续发送更多数据包,而无需等待之前的包被确认。这显著提高了网络管道的利用率和整体吞吐量,减少了发送方的等待时间。

LT 模式的问题:在 LT 模式下,如果程序不"自律"地一次性读完数据,接收缓冲区可能长期处于较满的状态。这会导致回送给发送方的接收窗口很小,发送方因此"畏手畏脚",只能发送少量数据,然后等待,严重限制了网络传输的并发度和整体速度。

所以,在 ET 模式下,程序被强制"一次性读完",从而:

快速清空接收缓冲区 → 向对端通告更大的接收窗口(win)→ 对端可以发送更多数据 → 提高 TCP 传输吞吐量。

ET 模式倒逼程序员的本质是:通过强制用户快速消费数据,最大化 TCP 窗口利用率,提升传输效率。


7. epoll中的惊群问题(选学)

惊群问题有些面试官可能会问到,参考:epoll惊群效应解析-CSDN博客


下一篇文章我们将会基于Reactor反应堆模式实现ET模式的epoll服务器

相关推荐
三不原则3 分钟前
网站慢、掉线?可能是TCP/IP在“闹情绪”
网络·网络协议·tcp/ip
Gofarlic_oms130 分钟前
跨国企业Cadence许可证全球统一管理方案
java·大数据·网络·人工智能·汽车
xlq223221 小时前
4.LInux权限
linux·运维·服务器
Bdygsl1 小时前
Linux(10)—— 进程控制(等待)
linux·运维·服务器
c++逐梦人1 小时前
进程的优先级与切换
linux·服务器·操作系统
重生之绝世牛码1 小时前
Linux软件安装 —— Redis集群安装(三主三从)
大数据·linux·运维·数据库·redis·数据库开发·软件安装
网安CILLE1 小时前
Wireshark 抓包实战演示
linux·网络·python·测试工具·web安全·网络安全·wireshark
是jin奥1 小时前
Ubuntu 18 安装 nodejs 合适版本
linux·ubuntu·vim
网硕互联的小客服2 小时前
如何彻底删除CentOS自带的postfix服务释放25端口?
linux·运维·centos