【Linux】多路转接之epoll

1. epoll概念

epoll 是 Linux 2.5.44 引入的高性能多路 I/O 就绪事件通知机制,是 select/poll 的升级版,专门解决大规模文件描述符(fd)场景下的效率问题,被公认为 Linux 2.6+ 性能最优的多路 I/O 就绪通知方案

2. epoll 三大核心接口

epoll 提供了 3 个关键系统调用,对应「创建实例→管理事件→等待就绪」的完整流程:

2.1 epoll_create

cpp 复制代码
int epoll_create(int size);
  • 作用:在内核创建一个 epoll 模型(本质是struct eventpoll 结构),返回一个代表该实例的文件描述符epfd
  • 说明:size早期用于指定监听 fd 数量上限,现在内核自动管理,仅需传大于 0 的值即可
  • 用完之后,必须调用close()关闭

2.2 epoll_ctl

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

作用:

  • 用户进程向内核「注册 / 修改 / 删除」要监听的 fd 及其事件,是用户与内核交互的核心接口

参数说明:

  • epfd:epoll_create返回的 epoll 实例句柄。
  • op:操作类型,用三个宏来表示
  • fd:要监听的目标文件描述符(如listensocket)
  • event:struct epoll_event结构体,描述监听事件 ,是单个事件**(添加 / 修改 / 删除 fd 时,必须传入一个全新配置好的 epoll_event 结构),**总结来说就是想要内核帮忙监听哪个fd,就往epoll_event.events里面放什么

op操作说明:

  • EPOLL_CTL_ADD:注册新的fd到epfd中
  • EPOLL_CTL_MOD:修改已注册 fd 的监听事件
  • EPOLL_CTL_DEL:从epfd中删除一个fd

关键结构体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_data_t data;  /* 用户自定义数据,内核不修改,仅原样返回 */
};

events常用宏定义(其中读写最常用):

  • EPOLLIN:fd 可读(含对端正常关闭)
  • EPOLLOUT:fd 可写
  • EPOLLERR:fd 发生错误
  • EPOLLHUP:fd 被挂断
  • EPOLLET:边缘触发模式(Edge Triggered)
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个事件加入到EPOLL红黑树里

2.3 epoll_wait

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

作用:

  • 收集在epoll监控的事件中已经发送的事件,通知用户进程处理

参数说明:

  • epfd:epoll 实例句柄
  • events:epoll将会把发生的事件赋值到用户提供的events数组中,内核会按从 0 开始的下标依次保存所有就绪事件
  • maxevents数组最大长度,maxevents的值不能大于创建epoll_create()时的 size
  • imeout:超时时间(毫秒,-1 表示阻塞等待,0 表示非阻塞)

返回值:

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

对比一下ctl和wait的epoll_event结构体中events作用:

  • ctl:用来添加想让内核监视的fd**(用户-->内核)**
  • wait:用来存放所有就绪的事件**(内核-->用户)**

3. epoll 核心工作原理

epoll 的性能优势,源于内核中「红黑树 + 就绪队列 」的双数据结构设计,以及「底层回调机制

3.1 内核三大核心结构体

1. struct eventpoll(epoll 总管理器)

创建实例epoll_create后内核自动分配eventpoll对象,初始化以下结构

cpp 复制代码
struct eventpoll {
    struct rb_root rbr;        // 红黑树根节点:存放所有监听fd
    struct list_head rdllist;  // 就绪双向链表:存放已触发事件fd
    wait_queue_head_t wq;      // epoll_wait 阻塞等待队列
    // ...
};
  • 红黑树rbr:存储所有用户通过epoll_ctl 注册的 fd 和事件
  • 就绪链表rdllist :存储所有已就绪的 fd(由内核自动添加),epoll_wait 只需检查该队列即可,检查时时间复杂度为 O(1) ,获取就绪事件时时间复杂度为O(N)
  • wq:进程调用 epoll_wait 无事件时,在此休眠

2. struct epitem(epoll_entry 监听条目)

每一个被监听的 fd 对应一个epitem

cpp 复制代码
struct epitem {
    struct rb_node rbn;        // 红黑树节点
    struct list_head rdllink;  // 就绪链表节点
    struct epoll_filefd ffd;   // 存储fd和file结构体
    struct eventpoll *ep;      // 归属哪个epoll实例
    struct epoll_event event;  // 用户监听事件 IN/OUT/ERR
    struct list_head pwqlist;  // 挂载等待队列项
};
  • 红黑树节点epitem里ffd 字段负责指向 fd 和 struct file

3. struct eppoll_entry(等待队列回调载体)

绑定fd自带的内核等待队列,挂载回调函数:ep_poll_callback

4. epoll使用完整过程

步骤一:创建 epoll 实例 epoll_create

  1. 内核分配一个eventpoll对象(初始化epoll 实例本体:红黑树、就绪队列、锁等)
  2. 分配一个struct file,并把它的 private_data指向这个 eventpoll
  3. 从当前进程的 fd 表中找一个空闲fd项,把这个 struct file 挂上去,把这个fd返回给用户态**(这里解释为什么epoll_create要返回一个fd,而后续函数都需要这个fd,因为靠fd找到红黑树、就绪队列等)**

步骤二:添加监听 fd epoll_ctl (EPOLL_CTL_ADD)

  1. 用户传入 fd、监听事件
  2. 内核创建 epitem对象,填充 fd、事件,将epitem插入 eventpoll 红黑树中保存
  3. 拿到该 fd 内核自带的等待队列
  4. 创建eppoeppoll_entry,挂载回调ep_poll_callback(把 eppoll_entry 挂入 fd 等待队列作用:让内核知道:这个 fd 有事发生,就调用回调通知 epoll

步骤三:内核事件触发(数据到来)

  1. socket / 文件 收到数据、可写、异常
  2. 内核唤醒该 fd等待队列
  3. 执行回调函数ep_poll_callback
  4. 核心动作:
    • 把当前epitem从红黑树摘出
    • 加入 eventpoll 的 rdllist 就绪链表
    • 唤醒阻塞在 epoll_wait 的进程

步骤四:epoll_wait 获取就绪事件

  1. 检测rdllist 就绪链表是否为空
  2. 为空 → 进程休眠,等待事件唤醒
  3. 不为空 → 遍历就绪链表
  4. 把就绪事件拷贝到用户态数组
  5. 返回就绪数量,用户只处理有事件 fd

4. 实现epoll echo服务

epoll实现echo服务代码

EpollServer.hpp

cpp 复制代码
#include <iostream>
#include <memory>
#include <sys/select.h>
#include <sys/epoll.h>
#include "Socket.hpp"

using namespace SocketModule;
using namespace LogModule;

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

    EpollServer(int port)
        : _listensock(std::make_unique<TcpSocket>()), _isrunning(false), _epfd(defaultfd)
    {
        // 1. 创建listensocket
        _listensock->BuildTcpSocketMethod(port);

        // 2. 创建epoll模型
        _epfd = epoll_create(256);
        if (_epfd < 0)
        {
            LOG(LogLevel::ERROR) << "epoll_create error";
            exit(EPOLL_CREATE_ERR);
        }
        // 成功创建
        LOG(LogLevel::DEBUG) << "epoll create success,epfd: " << _epfd;

        // 3. 将listensocket设置到内核中
        struct epoll_event ev;
        ev.data.fd = _listensock->Fd(); // TODO : 这里未来是维护的是用户的数据,后面要用,常见的是fd
        ev.events = EPOLLIN;
        int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Fd(), &ev);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "add listensockfd failed";
            exit(EPOLL_CTL_ERR);
        }
    }
    void Start()
    {
        _isrunning = true;
        int timeout = -1;
        while (_isrunning)
        {
            // 从就绪队列里拿就绪事件
            int n = epoll_wait(_epfd, _revs, size, timeout);
            if (n < 0)
            {
                LOG(LogLevel::ERROR) << "epoll error";
                continue;
            }
            else if (n == 0)
            {
                LOG(LogLevel::DEBUG) << "timeout...";
                continue;
            }
            else
            {
                // 有就绪连接
                Dispatcher(n);
                continue;
            }
        }
        _isrunning = false;
    }
    void Dispatcher(int nums)
    {
        LOG(LogLevel::DEBUG) << "event ready ...";
        // // epoll也要循环处理就绪事件--有可能有多个fd就绪

        for (int i = 0; i < nums; i++)
        {

            int sockfd = _revs[i].data.fd;
            if (_revs[i].events & EPOLLIN)
            {
                // 读就绪
                if (sockfd == _listensock->Fd())
                {
                    // 说明是新来的连接
                    Accepter();
                }
                else
                {
                    // 派发任务
                    Recver(sockfd);
                }
            }
            // if(_revs[i].events & EPOLLOUT)
            // {// 写事件就绪
            // }
        }
    }

    void Accepter()
    {
        InetAddr client;
        int sockfd = _listensock->Accept(&client);
        if (sockfd < 0)
        {
            LOG(LogLevel::ERROR) << "accept error";
        }
        else
        {
            // accept到新连接,把新fd交给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) << "add listensockfd failed";
                exit(EPOLL_CTL_ERR);
            }
            else
            {
                LOG(LogLevel::INFO) << "epoll_ctl add sockfd success: " << sockfd;
            }
        }
    }

    void Recver(int sockfd)
    {
        
        char buffer[1024];
        ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client say@ " << buffer << std::endl;
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit...";
            // 1. 从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;
            }
            // 2. 关闭
            close(sockfd);
        }
        else
        {
            LOG(LogLevel::ERROR) << "recv error";
            // 1. 从epoll中移除fd的关心 && 关闭fd
            // 细节:epoll_ctl: 只能移除合法fd -- 先移除,在关闭!!
            int j = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            if (j > 0)
            {
                LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success: " << sockfd;
            }
            // 2. 关闭
            close(sockfd);
        }
    }

    void Stop()
    {
        _isrunning = false;
    }
    ~EpollServer()
    {
        _listensock->Close();
        if (_epfd > 0)
        {
            close(_epfd);
        }
    }

private:
    std::unique_ptr<Socket> _listensock;
    bool _isrunning;
    int _epfd;

    // 错错错!! 不能全程使用一个ev
    // 添加 / 修改 / 删除 fd 时,必须传入一个全新配置好的 epoll_event 结构
    // struct epoll_event ev;         // ctl_ev
    struct epoll_event _revs[size]; // epoll_revs_event
};

5. epoll的优点

对比维度 select/poll epoll
接口易用 每次循环都要重置 / 拷贝关注的文件描述符,输入输出不分离 接口拆分成三个函数,不需要每次循环重置关注的文件描述符,也做到了输入输出参数分离
数据拷贝开销 每次调用都要把文件描述符列表拷贝到内核 仅在调用EPOLL_CTL_ADD时拷贝一次,后续操作无频繁拷贝
事件就绪效率 遍历所有文件描述符判断是否就绪,时间复杂度 O (n) 内核通过回调机制将就绪事件加入就绪队列,epoll_wait 直接访问,时间复杂度 O (1)
文件描述符数量 有数量上限(select 默认 1024) 无数量上限,仅受系统资源限制

注意:

内存映射误区:很多博客说 epoll 用了 mmap 内存映射,这种说法不准确,用户态定义的struct epoll_event是在用户空间中分配好的内存,仍需内核拷贝数据到用户空间,并非零拷贝

对比select,poll,epoll之间优点和缺点,十分重要!!

6. epoll工作方式

Epoll 支持两种触发模式:水平触发 Level Triggered工作模式和边缘触发EdgeTriggered工作模式,决定了事件通知的行为逻辑,是面试高频考点

6.1 场景

socket 收到 2KB 数据 → epoll_wait 返回可读 → 你只读了 1KB,还剩 1KB

1.LT 水平触发模式(默认)
  • 第二次调用epoll_wait
  • 立刻返回,继续告诉你:这个 socket 还有数据可读
  • 直到你把剩下 1KB 全部读完,epoll_wait 才不再通知
  • **就像:**没读完也没关系,下次还会提醒你
2.ET 边缘触发模式(加了EPOLLET)
  • 如果在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志,epoll进入ET工作模式
  • 第二次调用epoll_wait
  • 阻塞 / 不返回,再也不提醒你
  • 哪怕缓冲区还剩 1KB 数据,也不会再触发事件
  • 就像:只通知一次,你必须一次性把数据读完,否则剩下的数据会 "卡住"

select和poll也是工作在LT模式下,epoll既可以支持LT,也可以支持ET,这也是 epoll 高性能的核心原因之一

6.2 对比LT和ET

特性 LT 水平触发(默认) ET 边缘触发
触发时机 只要就绪就一直触发 仅状态变化时触发一次
处理要求 可以分次处理,不用一次读完 必须一次读完 / 写完所有数据
IO 模式 阻塞 / 非阻塞 都支持 必须用非阻塞 IO
性能 较低(epoll_wait 调用多) 更高(系统调用少)
常用场景 简单程序、初学者 Nginx、Redis 等高并发服务器

7. 为什么 ET 必须用非阻塞 IO

ET 模式要求 FD 设为非阻塞,不是内核接口强制规定,是工程实践必做规范

7.1 阻塞IO的缺陷

阻塞read 无法一次性读完所有数据:

  • 可能被系统信号中断中断读取
  • 内核缓冲区数据分批就绪导致单次读取必然读不全

7.2 死锁阻塞场景(死循环)

  • 客户端规则:收不到服务端应答,绝不发新请求
  • 服务端规则:读完完整 10K 请求,才返回应答
  • 服务端用阻塞 read,单次仅读 1K,剩余 9K 滞留缓冲区。又触发了ET 特性:仅新数据抵达时触发一次可读事件,存量残留数据不再触发
  • 结果:epoll_wait 永久阻塞,读不到剩余 9K → 凑不齐完整请求 → 服务端不回应答,调用不到read → 客户端不发新数据 → 永远无新事件触发,程序卡死死锁

那假设强行在事件里面写循环拿取缓冲区数据呢?

虽然可以读完所有残留数据,但此时缓冲区为空了,**没数据 → 立刻把进程等待挂起(休眠),这是OS的设计规则,**所以阻塞 read 会直接挂起卡死程序

非阻塞情况为什么不挂起? 没数据 → 直接返回 -1,不会挂起、卡住,直接告诉用户:EAGAIN(没数据了) → 退出循环 → 程序正常继续

7.3 总结

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

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

7.4 面试高频问题补充

  1. 为什么 ET 模式必须用非阻塞 fd? 因为 ET 模式下,你需要循环 recv 直到 EAGAIN,如果是阻塞 fd,缓冲区没数据时 recv 会阻塞,导致整个事件循环卡死。
  2. **LT 模式可以做到和 ET 一样高效吗?**可以,但需要手动一次性处理完所有数据,否则 LT 会一直通知,效率反而更低。ET 是通过内核强制约束程序员这么做,所以天然更高效。
  3. **Epoll 为什么比 select 高效?**核心原因:① 无需每次拷贝 fd 列表;② 就绪事件通过回调直接通知,O (1) 获取就绪 fd;③ 无数量上限。

7.5 epoll 的使用场景

epoll 的高性能,是有一定的特定场景的。如果场景选择的不适宜,epoll 的性能可能适得其反。

  • 对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用 epoll。例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll
  • 如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用 epoll 就并不合适。具体要根据需求和场景特点来决定使用哪种 IO 模型
相关推荐
utf8mb4安全女神2 小时前
⽇志管理与深层防⽕墙
java·开发语言·spring boot
心满意足的大脸猫2 小时前
Win11 开启 SSH 服务器与密钥登录配置记录
服务器·microsoft·ssh
Mr.Lu ‍2 小时前
QT调试查看QT内部数据时显示无可用信息,未为 Qt5Cored.dll 加载任何符号
开发语言·qt
Cat_Rocky2 小时前
Jenkins通过kubernetes连接K8s集群
运维·kubernetes·jenkins
Plastic garden2 小时前
Docker(2)数据挂载
运维·docker·容器
久邦科技2 小时前
爪云主机深度测评:2026年免备案海外主机的硬件配置与性能实测
网络
Plastic garden2 小时前
Docker(4) Compose
运维·docker·容器
qq_452396232 小时前
第九篇:《Dockerfile 指令精讲(二):WORKDIR、ENV、ARG、EXPOSE》
java·开发语言·docker
JAVA社区2 小时前
Java高级全套教程(九)—— SpringCloud超详细实战详解
java·开发语言·后端·spring cloud·面试·职场和发展