欢迎光临
我们一直在努力

【Linux网络】深入理解Linux epoll:从内核原理到实战代码全解析

在这里插入图片描述

🔥草莓熊Lotso:个人主页

❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》

✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:

在这里插入图片描述


文章目录

  • 前言:
  • 一. 为什么要有 epoll?epoll 到底是什么?
    • 1.1 select 与 poll 的先天局限
    • 1.2 epoll 的定位
  • 二. epoll 的三个核心系统调用
    • 2.1 epoll_create:创建 epoll 实例
    • 2.2 epoll_ctl:管理监听事件
    • 2.3 epoll_wait:等待就绪事件
  • 三. epoll 内核工作原理
    • 3.1 两个核心结构体
    • 3.2 回调机制:谁来把节点放进就绪队列?
    • 3.3 完整工作流程总结
  • 四. 实战:基于 epoll 的回显服务器实现
    • 4.1 项目整体架构
    • 4.2 服务器核心类定义
    • 4.3 构造函数:初始化监听套接字与 epoll 模型
    • 4.4 Dispatcher:事件循环主入口
    • 4.5 EventHandler:事件分发逻辑
    • 4.6 Acceptor:处理新连接接入
    • 4.7 IOHandler:数据读写与连接清理
    • 4.8 主函数入口
    • 4.9 完整代码和运行测试(epollServer.hpp)
  • 五. 深入:epoll 的两种工作模式 LT vs ET
    • 5.1 水平触发(Level Triggered)
    • 5.2 边缘触发(Edge Triggered)
    • 5.3 为什么 ET 模式必须用非阻塞 IO?
    • 5.4 LT 与 ET 对比总结
    • 5.5 其他补充事件
  • 结尾:

前言:

在 Linux 高性能网络编程的世界里,I/O 多路转接是绕不开的核心技术。从早期的 select 到 poll,再到如今几乎成为行业标准的 epoll,每一次演进都是为了解决高并发场景下的效率痛点。很多同学都用过 epoll,但对它的理解往往停留在 “三个系统调用” 的表面,对内核里红黑树、就绪队列、回调机制的工作逻辑一知半解,面试时被问到 LT/ET 模式、epoll 高效的本质也常常答不透彻。这篇文章我们就从 “为什么需要 epoll” 讲起,深入内核拆解它的工作原理,再结合一套完整的 C++ 封装代码,带你从零实现一个基于 epoll 的回显服务器,最后梳理清楚水平触发与边缘触发的核心区别。全文兼顾理论与实战,不管是入门学习还是面试复盘都能有所收获。

在这里插入图片描述


一. 为什么要有 epoll?epoll 到底是什么?

1.1 select 与 poll 的先天局限

在 epoll 出现之前,Linux 下主流的 I/O 多路转接方案是 select 和 poll。它们解决了 “单线程等待多个文件描述符” 的问题,但都存在无法忽视的性能瓶颈:

  • 内核层面需要遍历所有 fd:每次检测就绪事件,内核都要轮询一遍所有被监听的文件描述符。当连接数成千上万时,大部分连接并不活跃,这种遍历就成了无效开销,性能随连接数线性下降。
  • 频繁的用户态内核态拷贝:select 和 poll 每次调用都需要把完整的 fd 集合从用户态拷贝到内核态,调用结束后再把结果拷贝回来,连接越多拷贝开销越大。
  • select 还有额外的文件描述符数量上限(默认 1024),无法支撑大规模并发。

1.2 epoll 的定位

按照 Linux 手册的说法,epoll 是 “为处理大批量句柄而改进的 poll”。它在 2.5.44 版本内核中被引入,在 Linux 2.6 之后成为高性能服务器的标配,也是目前 Linux 平台应用最广泛、公认性能最好的 I/O 多路转接方案。

本质上,epoll 和 select、poll 一样,都是I/O 就绪事件通知机制—— 它不负责数据的读写,只负责告诉你 “哪个文件描述符可以读 / 可以写了”,真正的读写操作还是由业务代码完成。

epoll 的核心改进,就是用红黑树管理监听集合 + 就绪队列 + 回调激活的设计,彻底解决了遍历和频繁拷贝的问题。


二. epoll 的三个核心系统调用

和 select/poll 只用一个函数不同,epoll 把 “创建实例、管理监听集合、等待事件” 拆成了三个独立的系统调用,职责更清晰,使用也更灵活。

2.1 epoll_create:创建 epoll 实例

#include <sys/epoll.h>
int epoll_create(int size);

这个函数用来在内核中创建一个 epoll 模型实例,返回值是一个文件描述符,用来标识这个 epoll 实例。

关于size参数有一段历史演变:

  • 在 Linux 2.6.8 之前,size 是给内核的预分配提示,告诉内核预计要监听多少个 fd,内核以此预分配内部数据结构的空间。
  • 2.6.8 及之后,内核可以动态扩容红黑树,size 参数已经被忽略,但为了向后兼容,仍然要求传入一个大于 0 的整数。
  • 2.6.27 之后新增了epoll_create1,直接去掉了 size 参数,通过 flags 设置行为,是更推荐的写法。

为什么 epoll 的返回值也是文件描述符?因为 Linux 的设计哲学是 “一切皆文件”,epoll 实例本身也会被抽象成一个文件,通过文件描述符可以被进程唯一标识和访问。

2.2 epoll_ctl:管理监听事件

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

这是 epoll 的事件控制接口,用来向 epoll 模型中添加、修改、删除对某个文件描述符的监听。可以理解为对内核里的红黑树做增删改操作。

参数说明:

  • epfd:epoll_create 返回的 epoll 实例句柄
  • op:操作类型,有三个宏可选:
    • EPOLL_CTL_ADD:添加新的 fd 到 epoll 中
    • EPOLL_CTL_MOD:修改已注册 fd 的监听事件
    • EPOLL_CTL_DEL:从 epoll 中移除一个 fd
  • fd:要操作的目标文件描述符
  • event:告诉内核要监听这个 fd 的哪些事件

核心的epoll_event结构体长这样:

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;

常用的事件标志:

  • EPOLLIN:文件描述符可读(包括对端正常关闭)
  • EPOLLOUT:文件描述符可写
  • EPOLLERR:文件描述符发生错误(内核自动上报,不需要手动设置)
  • EPOLLHUP:文件描述符被挂断(内核自动上报)
  • EPOLLET:设置为边缘触发模式
  • EPOLLONESHOT:只监听一次事件,触发后自动移除,需要重新注册

data是一个联合体,最常用的是data.fd,用来在事件就绪时带回对应的文件描述符。

2.3 epoll_wait:等待就绪事件

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

这个函数用来等待事件就绪,是我们事件循环的核心。

参数说明:

  • epfd:epoll 实例句柄
  • events:输出型参数,用户提前分配好的数组,内核会将就绪的事件填充到这个数组里
  • maxevents:告诉内核 events 数组的最大长度,防止内核写越界
  • timeout:超时时间,单位毫秒
    • 传-1:永久阻塞,直到有事件就绪
    • 传0:非阻塞,立刻返回,没有事件就返回 0
    • 传正数:最多等待指定毫秒数,超时返回 0

返回值:

  • 大于 0:就绪的文件描述符数量
  • 等于 0:超时
  • 小于 0:调用出错

这里要特别理解 “输出型参数” 的含义:调用前你只需要准备一块空内存,不需要填任何内容;调用后内核会把结果写进去。这和 select 需要每次都重新设置 fd 集合有本质区别,也是 epoll 更高效的原因之一。

在这里插入图片描述 在这里插入图片描述 在这里插入图片描述


三. epoll 内核工作原理

只看 API 只能学会怎么用,要理解 epoll 为什么快,必须深入内核看它的数据结构和工作流程。

在这里插入图片描述

3.1 两个核心结构体

epoll 在内核里的实现围绕两个关键结构体展开:eventpoll和epitem。

eventpoll:epoll 实例本体

每个 epoll 文件描述符对应一个eventpoll结构体,它就是 epoll 模型的本体:

struct eventpoll {
spinlock_t lock;
struct mutex mtx;
wait_queue_head_t wq; // epoll_wait的等待队列
struct list_head rdllist; // 就绪队列(双向链表)
struct rb_root rbr; // 红黑树根节点
struct epitem *ovflist; // 溢出链表
struct user_struct *user;
};

核心是两个数据结构:

  • 红黑树(rbr):存储所有被监听的文件描述符对应的节点。红黑树是一种高效的平衡二叉搜索树,增删查的时间复杂度都是 O (logN),用来管理海量 fd 也不会有性能问题。
  • 就绪队列(rdllist):双向链表,存放所有已经有事件就绪的节点。epoll_wait 只需要检查这个队列是不是空,就能知道有没有就绪事件,时间复杂度 O (1)。
  • epitem:每个 fd 的 “档案”

    每一个被添加到 epoll 的文件描述符,内核都会为它创建一个epitem结构体:

    struct epitem {
    struct rb_node rbn; // 红黑树节点,挂在红黑树上
    struct list_head rdllink; // 链表节点,挂在就绪队列上
    struct epoll_filefd ffd; // 对应的文件描述符信息
    struct eventpoll *ep; // 所属的epoll实例
    struct epoll_event event; // 用户设置的监听事件
    unsigned int revents; // 实际发生的就绪事件
    // … 其他字段
    };

    这个设计非常精妙:同一个 epitem 节点,同时属于红黑树和就绪队列两个数据结构。平时它安静地挂在红黑树上等待被管理;一旦对应的 fd 上有事件发生,就通过rdllink链入就绪队列,等待被 epoll_wait 取走。

    打个通俗的比方:

    • 红黑树就是档案室的文件柜,所有客户的档案都按编号存好,查找很快。
    • 就绪队列就是前台的加急处理台,有业务要办的档案直接放到这里。
    • 你调用 epoll_wait,就是直接去前台加急台拿档案处理,不用一个个文件柜去翻。

    在这里插入图片描述 在这里插入图片描述

    3.2 回调机制:谁来把节点放进就绪队列?

    你可能会问:内核怎么知道哪个 fd 就绪了?是谁把 epitem 放到就绪队列里的?

    答案是回调机制。 每个套接字在内核里都对应一个sock结构体,里面有一系列函数指针,比如sk_data_ready(读数据就绪时调用)、sk_write_space(写缓冲区可用时调用)。默认情况下这些函数只会唤醒等待在套接字上的进程。

    当我们用epoll_ctl把一个套接字加入 epoll 时,内核会做两件事:

  • 创建 epitem 节点,插入红黑树。
  • 修改套接字底层的回调函数,把 epoll 自己的ep_poll_callback注册到套接字的等待队列上。
  • 当网卡收到数据,经过协议栈处理后放到套接字的接收缓冲区,就会调用sk_data_ready回调,最终触发ep_poll_callback。这个回调函数做的事情非常简单: 把对应的 epitem 节点链入 eventpoll 的就绪队列,然后唤醒阻塞在 epoll_wait 上的进程。

    整个过程完全由事件驱动,不需要内核遍历所有 fd 去检查状态。这就是 epoll 在高并发下依然高效的核心秘密 ——用回调代替遍历。

    在这里插入图片描述 在这里插入图片描述

    3.3 完整工作流程总结

    我们把三个系统调用和内核结构对应起来:

  • 调用 epoll_create:内核创建 eventpoll 实例,初始化空的红黑树和就绪队列。
  • 调用 epoll_ctl ADD:在内核中创建 epitem,插入红黑树,同时在目标 fd 上注册回调函数。
  • 调用 epoll_wait:
  • 检查就绪队列,如果不为空,就把就绪事件拷贝到用户态数组,返回数量。
  • 如果队列为空,就挂起进程等待,直到回调唤醒或者超时。
  • 整个过程中,检测是否有就绪事件是 O (1),获取就绪事件是 O (N)(N 是就绪的数量,不是总监听数)。在大部分连接不活跃的场景下,每次就绪的数量很少,效率远高于 select/poll 的全量遍历。

    补充一个常见误区:网上有说法称 epoll 用了 mmap 内存映射实现零拷贝,这是不准确的。用户传入的 events 数组是用户空间内存,内核还是需要把就绪事件拷贝进去。但拷贝的只是少量就绪节点,而不是全部监听的 fd,开销已经非常小了。

    在这里插入图片描述


    四. 实战:基于 epoll 的回显服务器实现

    讲完原理,我们结合一套完整的 C++ 代码,动手实现一个基于 epoll 的 echo 回显服务器,看看 epoll 在实际工程中是怎么封装和使用的。

    4.1 项目整体架构

    代码分为几个模块,都是 Linux 网络编程常用的基础设施:

    • Mutex.hpp:封装 POSIX 互斥锁和 RAII 风格的 LockGuard,保证临界资源操作的原子性。
    • Logger.hpp:日志模块,采用策略模式支持控制台 / 文件输出,利用 RAII 临时对象析构自动刷新日志。
    • InetAddr.hpp:封装 sockaddr_in 地址结构,处理字节序转换、IP 字符串与整数转换。
    • Socket.hpp:用模板方法模式封装 TCP 套接字,统一了服务器和客户端的启动流程。
    • epollServer.hpp:核心的 epoll 服务器类,完成事件循环和分发逻辑。
    • Main.cpp:程序入口。

    前面几个组件是通用工具,我们重点解读核心的 epollServer 实现。

    4.2 服务器核心类定义

    class epollServer
    {
    public:
    epollServer(int port);
    void Dispatcher(); // 事件分发主循环
    ~epollServer();
    private:
    void EventHandler(struct epoll_event revs[], int ready_num);
    void Acceptor(); // 处理新连接接入
    void IOHandler(int fd); // 处理普通套接字的读写事件
    private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensockfd; // 监听套接字智能指针
    int _epfd; // epoll实例句柄
    };

    4.3 构造函数:初始化监听套接字与 epoll 模型

    构造函数完成两项核心初始化:启动监听套接字,创建 epoll 实例。

    epollServer(int port)
    :_port(port)
    ,_listensockfd(std::make_unique<TcpSocket>())
    {
    // 模板方法:一键完成socket创建、绑定、监听三步
    _listensockfd->BulidSocketMethod(_port);
    // 创建epoll模型
    _epfd = epoll_create(gsize);
    if(_epfd < 0)
    {
    LOG(LogLevel::FATAL) << "epoll_create error";
    exit(1);
    }
    LOG(LogLevel::INFO) << "listen sockfd : " << _listensockfd->Socketfd()
    << " epfd: " << _epfd;
    }

    这里复用了之前封装的 TcpSocket,BulidSocketMethod按顺序执行创建、绑定、监听,失败直接终止进程,符合服务器 “启动失败不继续运行” 的工程习惯。

    4.4 Dispatcher:事件循环主入口

    Dispatcher是服务器的主循环,负责把监听套接字注册进 epoll,然后不断调用 epoll_wait 派发事件。

    void Dispatcher()
    {
    // 第一步:将监听套接字加入epoll,监听读事件
    struct epoll_event evn;
    evn.events = EPOLLIN;
    evn.data.fd = _listensockfd->Socketfd();
    int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensockfd->Socketfd(), &evn);
    if(n == 0)
    {
    LOG(LogLevel::DEBUG) << "epoll_ctl add : "
    << _listensockfd->Socketfd() << " success";
    }

    int timeout = 2000; // 超时时间2秒
    struct epoll_event revs[gnum]; // 存放就绪事件的用户态数组
    while(true)
    {
    int n = epoll_wait(_epfd, revs, gnum, timeout);
    if(n == 0)
    {
    LOG(LogLevel::INFO) << "time out…";
    }
    else if(n < 0)
    {
    LOG(LogLevel::ERROR) << "epoll_wait error…";
    break;
    }
    else
    {
    LOG(LogLevel::DEBUG) << "有事件就绪…";
    EventHandler(revs, n); // 分发处理就绪事件
    }
    }
    }

    这是 epoll 编程的标准骨架:注册监听 fd → 进入循环等待 → 分发处理事件。

    4.5 EventHandler:事件分发逻辑

    事件就绪后,我们需要区分是 “新连接就绪” 还是 “数据可读就绪”,分别走不同的处理分支。

    void EventHandler(struct epoll_event revs[], int ready_num)
    {
    for(int i = 0; i < ready_num; i++)
    {
    uint32_t events = revs[i].events;
    int fd = revs[i].data.fd;

    if(events & EPOLLIN)
    {
    // 通过fd对比,区分是监听套接字还是普通通信套接字
    if(fd == _listensockfd->Socketfd())
    {
    Acceptor(); // 处理新连接
    }
    else
    {
    IOHandler(fd); // 处理数据读写
    }
    }
    }
    }

    这是 epoll 编程的固定套路:通过 fd 和监听套接字对比,将连接事件和 IO 事件分流处理。

    4.6 Acceptor:处理新连接接入

    当监听套接字可读,说明有客户端完成了三次握手,调用 accept 取出连接,并将新连接交给 epoll 托管。

    void Acceptor()
    {
    InetAddr clientaddr;
    int sockfd = _listensockfd->Accepter(&clientaddr);
    LOG(LogLevel::INFO) << "sockfd is: " << sockfd
    << " client addr: " << clientaddr.StringAddress();
    if(sockfd >= 0)
    {
    // 将新连接的fd加入epoll,监听读事件
    struct epoll_event evn;
    evn.events = EPOLLIN;
    evn.data.fd = sockfd;
    epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &evn);
    LOG(LogLevel::INFO) << "epoll_ctl add event: " << sockfd;
    }
    else
    {
    LOG(LogLevel::ERROR) << "Accept error…";
    }
    }

    注意:accept 拿到新 fd 之后,绝对不能直接调用 recv 读数据。连接建立不代表对方立刻发送了数据,直接读会阻塞整个事件循环。正确做法是把 fd 交给 epoll,等数据就绪后由 epoll 通知再读取。

    4.7 IOHandler:数据读写与连接清理

    普通套接字可读时,读取客户端数据并回显;如果对端关闭或读取出错,则清理对应资源。

    void IOHandler(int fd)
    {
    char inbuffer[1024];
    ssize_t n = recv(fd, inbuffer, sizeof(inbuffer) 1, 0);
    if(n > 0)
    {
    inbuffer[n] = 0;
    LOG(LogLevel::INFO) << "client say# " << inbuffer;
    // 回显消息
    std::string echo_string = "echo# ";
    echo_string += inbuffer;
    send(fd, echo_string.c_str(), echo_string.size(), 0);
    }
    else if(n == 0)
    {
    // 对端正常关闭:先从epoll移除,再关闭文件描述符
    LOG(LogLevel::INFO) << "client quit, epoll_ctl del event: " << fd;
    epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
    close(fd);
    }
    else
    {
    LOG(LogLevel::ERROR) << "recv error…";
    epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
    close(fd);
    }
    }

    这里有一个非常重要的细节:必须先调用 epoll_ctl 删除 fd,再调用 close 关闭 fd。因为 epoll_ctl_DEL 操作要求 fd 是合法有效的,如果先 close 了,文件描述符就会失效,删除操作可能失败。

    4.8 主函数入口

    #include "epollServer.hpp"
    #include <cstdint>
    #include <memory>
    const uint16_t gport = 8080;
    int main()
    {
    std::unique_ptr<epollServer> select_server= std::make_unique<epollServer>(gport);
    select_server->Dispatcher();
    return 0;
    }

    编译运行后,服务器会在 8080 端口监听,支持多个客户端同时连接并回显消息。

    在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

    4.9 完整代码和运行测试(epollServer.hpp)

    #ifndef __EPOLLSERVER__HPP
    #define __EPOLLSERVER__HPP

    #include "Logger.hpp"
    #include "InetAddr.hpp"
    #include "Socket.hpp"
    #include <cstdint>
    #include <sys/epoll.h>
    #include <memory>
    #include <sys/socket.h>
    #include <sys/types.h>
    #include <unistd.h>

    using namespace LogModule;
    static const int gsize = 128;
    static const int gnum = 64;

    class epollServer
    {
    public:
    epollServer(int port)
    :_port(port)
    ,_listensockfd(std::make_unique<TcpSocket>())
    {
    // 创建监听套接字
    _listensockfd->BulidSocketMethod(_port);
    // 创建epoll模型
    // [核心注释] 这一步在内核中创建了 eventpoll 结构体(包含一棵空的红黑树和一个空的就绪队列双向链表)
    // 参数 gsize 在现代 Linux 中已被忽略,只要大于 0 即可。
    _epfd = epoll_create(gsize);
    if(_epfd < 0)
    {
    LOG(LogLevel::FATAL) << "epoll_create error";
    exit(1);
    }
    LOG(LogLevel::INFO) << "listen sockfd : " << _listensockfd->Socketfd() << " " << "epfd: " << _epfd;
    }
    void Dispatcher()
    {
    // 先将 listensockfd 加进去
    struct epoll_event evn;
    evn.events = EPOLLIN;
    evn.data.fd = _listensockfd->Socketfd();

    // [核心注释] EPOLL_CTL_ADD 本质:
    // 1. 在内核红黑树中新增一个节点(存放 _listensockfd 及其关心的 EPOLLIN 事件)。
    // 2. 建立底层网卡驱动到该 fd 的回调机制(当有新连接到来时,自动将该节点推入就绪队列)。
    int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensockfd->Socketfd(), &evn);
    if(n == 0)
    {
    LOG(LogLevel::DEBUG) << "epoll_ctl add : " << _listensockfd->Socketfd() << " success";
    }

    int timeout = 2000;
    struct epoll_event revs[gnum]; // 用来记录结果的
    while(true)
    {
    // [核心注释] epoll 的性能核心:
    // 这里的时间复杂度是 O(1) 检测。内核只检查就绪队列是否为空。
    // 如果不为空,将就绪队列中的节点事件依次“拷贝”到用户态传入的 revs 数组中,返回值 n 就是实际发生事件的数量。
    int n = epoll_wait(_epfd, revs, gnum, timeout);
    if(n == 0)
    {
    LOG(LogLevel::INFO) << "time out…";
    }
    else if(n < 0)
    {
    LOG(LogLevel::ERROR) << "epoll_wait error…";
    break;
    }
    else
    {
    LOG(LogLevel::DEBUG) << "有事件就绪…";
    EventHandler(revs, n); // 事件处理
    }
    }
    }
    ~epollServer()
    {
    if(_epfd >= 0)
    close(_epfd);
    // _listensockfd 会自动释放
    }
    private:
    void EventHandler(struct epoll_event revs[], int ready_num)
    {
    // [核心注释] 性能碾压 select/poll 的地方:
    // 我们不需要遍历所有被监听的 fd,只需要遍历确切发生事件的 ready_num 个 fd。
    // revs 数组的前 ready_num 个元素,百分之百都是有效的就绪事件。
    for(int i = 0; i < ready_num; i++)
    {
    uint32_t events = revs[i].events; // 那些事件就绪了
    int fd = revs[i].data.fd; // 那一个 fd
    // 其实这里是可以不需要判断就绪了的, 但是为了健壮性我们还是判断一下
    if(events & EPOLLIN)
    {
    // 再来判断是 listensockfd 还是 normal sockfd
    // [核心注释] 身份路由:通过检查就绪的 fd 是谁,来决定做什么业务
    if(fd == _listensockfd->Socketfd())
    {
    // litensockfd
    // [核心注释] 监听套接字就绪,说明底层全连接队列有新的 TCP 连接可以 accept 了
    Acceptor();
    }
    else
    {
    // normal sockfd
    // [核心注释] 普通通信套接字就绪,说明对端发送的数据已经到达了本机的 TCP 接收缓冲区
    IOHandler(fd);
    }
    }
    else if(events & EPOLLOUT)
    {
    // TODO — 大家可以自己扩展
    }
    }
    }
    void Acceptor()
    {
    InetAddr clientaddr;
    int sockfd = _listensockfd->Accepter(&clientaddr); // 获取新连接
    LOG(LogLevel::INFO) << "sockfd is: " << sockfd << " client addr: " << clientaddr.StringAddress();

    // [核心注释] 极其重要:拿到新连接后,绝对不能直接读写!
    // 必须立刻将其托付给 epoll 模型,让内核帮你监控它后续的读写动作。
    if(sockfd >= 0)
    {
    struct epoll_event evn;
    evn.events = EPOLLIN;
    evn.data.fd = sockfd;
    // [核心注释] 将新的通信 socket 加入内核的红黑树中
    int m = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &evn);
    (void)m;
    LOG(LogLevel::INFO) << "epoll_ctl add event: " << sockfd;
    }
    else
    {
    LOG(LogLevel::ERROR) << "Accept error…";
    }
    }
    void IOHandler(int fd)
    {
    // normal fd — 直接读就行了
    // [核心注释] 因为是 epoll 通知我们 EPOLLIN 的,所以这里调用 recv 绝对不会阻塞,一定会读到数据或感知到断开。
    char inbuffer[1024];
    ssize_t n = recv(fd, inbuffer, sizeof(inbuffer) 1, 0);
    if(n > 0)
    {
    inbuffer[n] = 0;
    LOG(LogLevel::INFO) << "client say# " << inbuffer;

    // 我们回显回去
    std::string echo_string = "echo# ";
    echo_string += inbuffer;
    send(fd, echo_string.c_str(), echo_string.size(), 0);
    }
    else if(n == 0)
    {
    // 对端关闭了
    LOG(LogLevel::INFO) << "client quit, epoll_ctl del event: " << fd;
    // 要删除fd, 首先这个fd得是合法的, 所以我们close一定是在删除之后执行
    // [核心注释] 安全清理双步骤顺序:
    // 1. EPOLL_CTL_DEL:将该 fd 的节点从内核红黑树上摘除,并注销底层的回调机制。
    // 2. close(fd):归还系统文件描述符资源。如果先 close,fd 失效,epoll_ctl 会报错。
    epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
    close(fd);
    }
    else
    {
    LOG(LogLevel::ERROR) << "recv error…";
    // 要删除fd, 首先这个fd得是合法的, 所以我们close一定是在删除之后执行
    epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
    close(fd);
    }
    }
    private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensockfd;
    // epoll 句柄
    int _epfd;
    };
    #endif

    在这里插入图片描述

    • 多个客户端也没问题 在这里插入图片描述

    五. 深入:epoll 的两种工作模式 LT vs ET

    epoll 最经典的面试考点,就是水平触发(LT)和边缘触发(ET)的区别。上面我们实现的代码使用的是 epoll 默认的 LT 模式。

    5.1 水平触发(Level Triggered)

    LT 是 epoll 的默认工作模式。它的规则很简单:只要文件描述符的缓冲区里还有未处理的数据,epoll_wait 就会持续通知该事件就绪。

    打个通俗的比方:就像妈妈喊你吃饭,你坐在那儿不动,她就会一遍一遍地喊,直到你起身去吃为止。

    举个具体例子: 套接字接收缓冲区来了 2KB 数据,epoll 通知可读;你只读了 1KB,缓冲区还剩 1KB。那么下一次调用 epoll_wait 时,它会立刻返回,继续通知这个 fd 可读,直到缓冲区里的数据被全部读完。

    LT 模式的特点:

    • 编程简单,逻辑直观,不容易出 bug。
    • 同时支持阻塞 IO 和非阻塞 IO。
    • 缺点是触发次数多,高并发场景下会产生一定的系统调用开销。

    5.2 边缘触发(Edge Triggered)

    ET 模式需要手动加上EPOLLET标志位才会开启。它的规则是:只有当文件描述符的状态发生变化的那一刻,才通知一次;哪怕缓冲区还有数据没读完,也不会再重复通知,直到下一次新事件到来。

    还是那个比方:妈妈只喊你一次,你不来她就再也不喊了,等下一顿饭做好了才会再喊一次。

    对应到刚才的例子: 缓冲区来了 2KB 数据,epoll 通知一次可读。如果你只读了 1KB,剩下 1KB 留在缓冲区,接下来调用 epoll_wait 不会再返回这个事件。只有当对方又发送了新数据,状态再次变化时,才会再触发一次通知。

    ET 模式的特点:

    • 触发次数少,减少了 epoll_wait 的系统调用次数,性能更高。Nginx 默认就采用 ET 模式。
    • 编程复杂度高,必须一次性把缓冲区数据读完,否则数据就会留在缓冲区里无法被处理。
    • 必须搭配非阻塞 IO 使用。

    在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

    5.3 为什么 ET 模式必须用非阻塞 IO?

    这是面试高频问题,也是工程上的硬性要求。

    假设我们用阻塞 IO + ET 模式: 事件就绪后,我们调用 read 循环读取数据,当缓冲区的数据被读完后,阻塞的 read 会一直挂起等待新数据。但 ET 模式下没有新事件就不会再通知,于是整个线程就阻塞在这个 read 上,其他连接的事件都无法得到处理,整个服务器就卡住了。

    而用非阻塞 IO + ET 模式: 事件就绪后,我们循环调用 read,一直读到返回 – 1,并且 errno 为EAGAIN或EWOULDBLOCK,这说明缓冲区已经空了,就可以停止读取,转头去处理其他事件。这样既保证了数据全部读完,又不会阻塞事件循环。

    在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

    5.4 LT 与 ET 对比总结

    对比维度LT(水平触发)ET(边缘触发)
    触发规则 缓冲区有数据就持续触发 仅状态变化时触发一次
    编程难度 低,逻辑简单不易出错 高,需非阻塞循环读写
    触发次数
    系统调用开销 相对较高 更低
    支持的 IO 类型 阻塞 / 非阻塞均可 必须非阻塞
    适用场景 通用场景,追求代码可靠性 高性能场景,追求极致效率

    在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

    5.5 其他补充事件

    • EPOLLONESHOT:只监听一次事件,触发后该 fd 就不再被 epoll 监听,需要重新执行 EPOLL_CTL_MOD 注册。常用于多线程场景,防止多个线程同时处理同一个连接的事件。
    • EPOLLRDHUP:TCP 连接被对端关闭写半连接时触发,比 EPOLLHUP 更精细,可以用来优雅检测对端关闭。
    • EPOLLPRI:紧急数据可读,对应 TCP 的带外数据,实际工程中使用很少。

    在这里插入图片描述

    总结与面试考点梳理

    epoll 核心优点

  • 无连接数硬上限:只受系统内存限制,支持上万甚至十万级并发连接。
  • 就绪检测高效:基于回调机制,检测就绪事件时间复杂度 O (1),不会随连接数增加而线性下降。
  • 数据拷贝开销小:只在增删 fd 时进行用户态内核态拷贝,epoll_wait 只拷贝少量就绪事件,远优于 select/poll 的全量拷贝。
  • 接口设计更合理:输入输出参数分离,不需要每次循环重置监听集合。
  • 局限性

    • Linux 平台特有,跨平台性差,跨平台项目一般使用 libevent、libuv 等库做封装。
    • ET 模式编程门槛高,处理不当容易出现数据残留、阻塞等 bug。
    • 短连接、少量连接的场景下,epoll 的优势不明显,甚至因为初始化开销而不如 select。

    高频面试考点

  • epoll 和 select/poll 有什么区别?各自的优缺点是什么?
  • 简述 epoll 的工作原理,红黑树、就绪队列、回调机制分别起什么作用?
  • LT 和 ET 模式的区别是什么?为什么 ET 模式必须搭配非阻塞 IO?
  • epoll 一定比 select 快吗?什么场景下 epoll 性能优势最明显?
  • 关闭套接字之前,需要先调用 epoll_ctl 删除 fd 吗?为什么?

  • 结尾:

    🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
    👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
    ❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
    ⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
    💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
    🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
    技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

    结语:epoll 是 Linux 高性能网络编程的基石,也是后端开发必须吃透的知识点。它用红黑树管理连接、用回调机制响应事件、用就绪队列交付结果,这套设计思想不仅造就了 epoll 本身的高性能,也影响了后续很多事件驱动框架的设计。这篇文章里我们实现的还只是一个最基础的 LT 模式回显服务器,真实的工业级服务器还会在此基础上引入 Reactor 反应堆模式、线程池、序列化协议、异常处理等机制。但万变不离其宗,epoll 的核心逻辑永远是那棵红黑树、那条就绪队列,和那三个系统调用。如果觉得有帮助,不妨动手编译代码跑一跑,再试着把它改成 ET 模式,亲手踩一遍非阻塞循环读写的坑,对 epoll 的理解会更深刻。

    ✨把这些内容吃透超牛的!放松下吧✨

    ʕ˘ᴥ˘ʔ

    づきらど

    在这里插入图片描述

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux网络】深入理解Linux epoll:从内核原理到实战代码全解析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址