Linux高级IO(五)epoll 的两种工作模式(LT/ET),多路转接之epoll版本的TCP服务器,对比 select/poll/epoll

目录

一、代码实现:

整体框架

私有成员变量介绍

构造函数初始化流程

[主运行入口 Loop 主循环](#主运行入口 Loop 主循环)

[事件派发函数 HanderEvent](#事件派发函数 HanderEvent)

​编辑

[新建连接处理函数 Listener](#新建连接处理函数 Listener)

[数据读写业务 IOService](#数据读写业务 IOService)

完整代码:

二、epoll的优缺点

优点:

缺点:

[四、总结对比 select、poll、epoll 之间的优点和缺点 (重要,面试中常见)](#四、总结对比 select、poll、epoll 之间的优点和缺点 (重要,面试中常见))

select

poll

[epoll(Linux 独有,IO 多路最优方案)](#epoll(Linux 独有,IO 多路最优方案))

三、epoll的工作模式

[水平触发(LT 模式)](#水平触发(LT 模式))

[边缘触发(ET 模式)](#边缘触发(ET 模式))

[LT 模式和 ET 模式在内核层面的理解](#LT 模式和 ET 模式在内核层面的理解)

[面试题 : 为什么 ET 模式下文件描述符要设置为非阻塞?](#面试题 : 为什么 ET 模式下文件描述符要设置为非阻塞?)

ET模式的优点:

四,总结


上篇文章我们讲了 epoll 模型,epll 在内核底层依托红黑树、就绪队列、回调通知实现了完全区别于 select、poll 的运行机制,规避了全量遍历、反复拷贝的性能损耗,并且 epoll 的系统调用的执行逻辑和 select / poll 也有着本质差异,不过从上层编写代码的角度来看,编写代码的核心步骤相似,都是创建监听实例、注册待监控文件描述符、阻塞等待 IO 事件、遍历处理就绪连接这一套固定流程,接下来我们再编写一份基于 epoll 多路转接的代码。

一、代码实现:

整体框架

​​​​​​整体 epoll 服务器代码的执行框架,和此前 select、poll 的业务执行流程大体上是一致的,整体都是先完成服务初始化、创建监听套接字,接着开启循环持续阻塞等待 IO 事件到来,等到有就绪事件之后,再区分监听 fd 与普通客户端 fd 分别做接入新连接、收发数据的业务处理,连接断开时执行资源回收,这一套顶层流程没有区别。二者真正不一样的地方藏在底层系统调用的实现逻辑,上层业务编码的整体骨架高度雷同。

私有成员变量介绍

  • 第一个 uint16_t _port 是服务器绑定运行的端口号,用来对外提供网络接入服务,初始化时赋值,全程固定不变。
  • 第二个 std::unique_ptr<Socket> _listensock 是托管监听套接字的智能指针,类型是 C++ 智能指针 unique_ptr,内部封装了服务端的创建、绑定、监听套接字的逻辑,负责等待客户端发起连接,使用智能指针可以自动管理套接字资源,避免内存泄漏。
  • 第三个 int _epfd 是调用 epoll_create 创建的 epoll 实例,是内核返回的 epoll 专属文件描述符,后续所有 epoll_ctl 增删监听 fd、epoll_wait 等待事件,都必须依靠这个句柄定位对应的 epoll 内核实例。
  • 第四个 bool _quit 是服务启停的布尔标记,用来控制主循环是否持续运行,标记为 true 时,就终止循环、退出服务程序。

构造函数初始化流程

构造函数首先依靠初始化列表完成四个私有成员的初始赋值,给端口、监听套接字智能指针、epoll 句柄、退出标记赋予初始状态,正式进入函数体后,整体分成三段核心执行步骤。

第一步:搭建 TCP 监听套接字

  • 程序首先调用 BuildTcpSocketMethod 完成整套服务端套接字流程,内部封装了 socket 创建、地址绑定、端口监听全套逻辑,最终生成唯一的 listen 监听 fd,这是服务接收客户端连接的入口,日志也会打印出当前监听套接字的文件描述符,套接字创建完毕之后,本身还没有交由 epoll 托管。

第二步:调用 epoll_create 创建内核 epoll 实例

  • 紧接着执行 _epfd = epoll_create(ign_size) 来在内核开辟一整套独立的 epoll 模型。这里传入的 ign_size 就是我们此前聊到的历史占位参数,新版 Linux 内核已经不会读取这个数值,仅仅要求参数大于 0 即可,变量命名 ign_size 也直白体现了 "该参数被忽略" 的含义。调用成功后,内核会返回一个全新的文件描述符存入 _epfd,这个 fd 就是我们访问内核 eventpoll 结构的入口,后续所有 epoll 操作都要依托它;如果调用返回负数,代表内核创建 epoll 实例失败,程序直接打印致命日志并终止初始化流程。

第三步:借助 epoll_ctl,将监听套接字注册进内核 epoll 模型

  • 这一步是把 fd 交给内核长期托管的核心环节,首先我们在用户态定义局部的 struct epoll_event ev 结构体,这个结构体是用户态和内核交互的载体,其中包含两大关键部分。ev.events 用来声明我们想要监听的事件,监听套接字只需要关注新连接到来的可读事件,因此赋值为 EPOLLIN;ev.data 是一个联合体类型,既可以存放 fd、也可以存放指针,本次我们直接使用联合体内部的 fd 字段,填入监听套接字的文件描述符,后续当这个 fd 触发就绪事件时,内核就会把我们预先填写的 fd 原样拷贝返回给用户态,方便我们识别是哪个文件描述符就绪。
  • 准备好 epoll_event 之后,调用 epoll_ctl 完成注册,epoll_ctl 的第一个参数_epfd 指向我们刚刚新建的 epoll 内核实例的 fd;第二个操作参数 EPOLL_CTL_ADD 代表执行新增逻辑,因为最开始时红黑树为空,需要把监听 fd 作为首个节点插入;第三个参数就是待监听的监听套接字 fd;第四个参数是刚才配置好的 epoll_event 结构体地址。执行该系统调用时,内核会依据这个结构体信息,在 eventpoll 内部的红黑树里新建一个 epitem 节点,记录当前 fd 与监听事件,同时给这个 socket 绑定好 sk_data_ready 等回调函数指针。自此之后,监听 fd 就长期留在 epoll 的红黑树当中,全程由内核主动监测状态,一旦有客户端发起连接造成 fd 可读,内核就会触发回调,把节点移入就绪队列,不需要用户程序反复去检测,完成了提前托管的全过程。调用返回 0 就代表注册成功,日志打印提示信息,整个构造函数的初始化工作也就全部收尾。

主运行入口 Loop 主循环

整个 Loop 是服务持续运行的主干循环,依托 _quit 标识控制启停,循环内部核心就是调用epoll_wait 阻塞等待 IO 事件。首先我们在用户态定义了 struct epoll_event revsrevs_num 结构体数组,专门用来接收内核拷贝出来的就绪事件,内核会把当前已经就绪的 fd 信息批量复制到这个数组里,数组长度 revs_num 限定了单次 epoll_wait 最多能取回多少条就绪事件,这也是用户态和内核隔离的体现,内核不会直接暴露自身就绪队列,只做数据拷贝交付。

我们再来看一下epoll_wait 的四个参数:

  • 第一个_epfd 就是构造阶段 epoll_create 生成的 epoll 实例句柄,用来指定我们要监听哪一套 epoll 内核模型;
  • 第二个参数就是刚刚定义的 revs 数组,内核会把批量就绪的多个 fd 以及各自的就绪事件,统一拷贝到这片数组里,正因同一时刻可能多个连接同时就绪,所以才要用数组,后续我们遍历这个数组中的每个结构体,就能拿到对应的 fd 与就绪事件了。
  • 第三个 revs_num 代表数组最大容量,约束单次系统调用最多获取多少就绪条目;
  • 最后 timeout 设置为 - 1,代表永久阻塞,没有任何就绪事件时,进程直接休眠,不会主动超时退出。

函数返回值分三种场景处理:

  • 返回值 n > 0,代表本次拿到了 n 个有效的就绪事件,数组下标 0 到 n-1 的元素全部合法,直接把数组与就绪数量传入 HanderEvent 函数,做事件分发处理;
  • n == 0,说明阻塞时长耗尽触发超时,当前没有任何 fd 就绪,仅打印日志然后继续下一轮等待;
  • n < 0 意味着系统调用出现异常,打印错误日志后跳出循环,终止服务。

再聊聊几个函数所处执行阶段的区别,epoll_create 创建实例、epoll_ctl 注册监听 fd 全都写在构造函数里,只会在服务启动时执行仅此一次,作用是完成内核资源初始化、往红黑树里挂入监听节点,属于一次性的前置准备工作;而 Loop 循环里的 epoll_wait 会持续循环执行,程序运行期间反复调用,持续阻塞、接收内核推送的就绪事件,属于服务运行期间常驻的核心逻辑。初始化只做一次铺垫,循环反复等待事件,二者一前一后,构成了 epoll 服务完整的生命周期。

事件派发函数 HanderEvent

HanderEv**ent 接收的两个参数,正是 epoll_wait 执行完毕之后带出的结果,**revs 结构体数组属于输出型参数,内核已经把本轮全部就绪的 struct epoll_event 结构体拷贝到这个数组里,第二个参数 n 就是本次实际就绪的 fd 总数量,比如同一时刻有 3 个连接同时事件就绪了,那么 n 的值就等于 3,for 循环就会完整遍历 0、1、2 三个数组下标,逐个处理每一条就绪事件。

进入循环体之后,每一轮先从 revsi 当中取出两处核心数据,sockfd 取自当前数组元素 data 联合体内部预先存入的文件描述符,这也是注册 fd 阶段就提前绑定好的值;revents 是内核标记的实际触发就绪的事件。接下来通过按位与 & 运算,判断当前就绪事件里是否包含可读事件 EPOLLIN,这里必须使用按位与,因为一个 fd 可能同时触发多种事件,按位与可以精准校验某一个标记位是否被置 1。

一旦判定可读事件就绪,代码继续做二次区分:

  • 比对当前 sockfd 是不是全局的监听套接字 fd,如果相等,就代表有新的客户端发起 TCP 连接,监听套接字产生可读就绪,于是调用 Listener 函数执行 accept,建立客户端套接字;
  • 如果不等于监听 fd,就说明是已建立连接的普通客户端套接字,缓冲区收到了客户端发来的数据,触发了可读就绪,就调用 IOService 函数执行 recv 读取数据、业务回显逻辑。

新建连接处理函数 Listener

监听套接字 fd 触发读事件就绪时,就进入 Listener 分支执行 Listener,通过 accept 拿到全新的客户端套接字 newsockfd,这个 fd 只是完成了 TCP 握手,和客户端成功建立了连接,客户端此时未必发了任何数据,所以绝对不能立刻调用 recv 去读数据,贸然读取大概率会造成阻塞,这也是事件驱动模型的核心思路:不去主动轮询、也不盲目 IO,只把 fd 交给 epoll 托管,等内核检测到缓冲区真正有数据、可读就绪之后,再执行读取操作。

于是代码里新建了专属的 epoll_event ev 结构体,我们手动设置监听事件 EPOLLIN,并且明确写ev.data.fd = newsockfd,把刚生成的客户端 fd 存进联合体,随后调用 epoll_ctl 执行 ADD 操作,把这个结构体一并交给内核。内核会将这份数据永久绑定在红黑树对应的 epitem 节点里,不会修改 data 联合体里的内容。

后续客户端发送消息,这个 newsockfd 触发可读,内核就把当初我们在这里填写好的 ev 原样拷贝到 epoll_wait 的 revs 数组,我们在 HanderEvent 里取出 revsi.data.fd,拿到的就是此刻这个客户端连接的 fd。 总结来讲:监听 fd 在构造函数完成预填绑定,每一个新客户端 fd,都在 Listener 这个函数里逐个完成预填绑定,所有就绪后取回的 fd,根源全都是用户层在 ADD 注册时主动设置的,内核只做存储与回传。

这里核心误区就在于:每一个被加入 epoll 的 fd,全都是用户自己在执行 epoll_ctl(ADD) 的时候,手动给 ev.data.fd 赋值,再交给内核保存的,内核全程不会自行修改、生成这个 fd 字段,只会原封不动存着、就绪后原样返还,不只是监听 fd,后续所有客户端 fd 全都遵守这个规则。

最开始构造函数里,注册 listen 监听 fd,是咱们用户代码主动写了 ev.data.fd = 监听fd,再传给内核;等到后面 Listener 函数调用 accept 拿到全新客户端 newsockfd,代码同样会新建一个epoll_event ev,手动执行 ev.data.fd = newsockfd,紧接着执行 epoll_ctl 把这个结构体交给内核。也就是说,每一个要托管进 epoll 红黑树的 fd,在上层执行添加操作时,用户都必须手动填充data.fd,内核只是单纯把这份结构体绑定给对应的 epitem 节点做持久存储,不会改动里面任何内容。

等到某个 fd 发生 IO 就绪,内核做的操作,只是把当初用户递交、自己完整存下来的那一份epoll_event,复制到 epoll_wait 的 revs 数组里。所以数组里拿到的 sockfd,源头全部都是上层注册阶段我们提前填写的数值,内核只是做存储与回传,从来不会自主生成 fd 填入联合体。监听 fd 是构造阶段提前赋值,每一个客户端 fd 是建立连接后、注册入 epoll 那一瞬间赋值,来源完全一致,因此遍历就绪数组时,所有 fd 都能正常被取出识别。

数据读写业务 IOService

当 HanderEvent 判定当前就绪的 fd 不是监听套接字,就会调用 IOService 函数来处理客户端的数据收发。因为此时 fd 已经被 epoll 标记为可读就绪,内核缓冲区里已经有客户端发来的数据,所以这里的 recv 调用是安全的,不会发生阻塞。函数首先创建一个缓冲区来接收数据,调用 recv 从客户端 fd 中读取数据。如果读取成功,返回值大于 0,就把收到的数据加上一个 "echo #" 前缀,再通过 send 函数回发给客户端,实现一个简单的回显服务。

如果 recv 返回 0,说明客户端主动断开了连接,此时我们需要做两件事:首先调用 epoll_ctl 的 EPOLL_CTL_DEL 操作,把这个客户端 fd 从 epoll 的红黑树中移除,让内核不再关心它的事件;然后再调用 close 关闭这个套接字,回收系统资源。这一步的顺序不能颠倒,必须先从 epoll 中移除,再关闭 fd,否则会导致 epoll 监听一个已经失效的文件描述符,引发异常。

如果 recv 返回负数,则代表连接出现了异常,比如网络错误或者客户端强制断开。此时的处理逻辑和正常断开一样,同样是先从 epoll 中删除 fd,再关闭套接字,确保资源被安全释放。

到这里,整个 epoll 服务的核心流程就闭环了:构造函数初始化并注册监听 fd,Loop 循环调用 epoll_wait 等待事件,HanderEvent 分发处理连接和读写,Listener 接收新连接并注册客户端 fd,IOService 处理数据收发和断连清理,所有环节都依托 epoll 的事件驱动模型高效运行。

完整代码:

EpollServer.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <memory>
#include <sys/epoll.h>

#include "InetAddr.hpp"
#include "Socket.hpp"
#include "Logger.hpp"

using namespace NS_LOG_MODULE;
using namespace NS_SOCKET_MODULE;

static const int defaultport = 8080;
static const int ign_size = 256;
static const int revs_num = 64;

class EpollServer
{
public:
    EpollServer(uint16_t port = defaultport)
        : _port(port),
          _listensock(std::make_unique<TcpSocket>()),
          _epfd(-1),
          _quit(false)
    {
        // 1. 创建listensockfd
        _listensock->BuildTcpSocketMethod(_port);
        LOG(LogLevel::INFO) << "create listen socket success, sockfd is : " << _listensock->Sockfd(); // 3

        // 2. 创建Epoll模型
        _epfd = epoll_create(ign_size);
        if (_epfd < 0)
        {
            LOG(LogLevel::FATAL) << "create epoll error, return : " << _epfd;
            return;
        }
        LOG(LogLevel::INFO) << "create epoll success, epfd is : " << _epfd; // 4

        // 3. 把listensock先添加到内核epoll模型中,让OS帮你关心!
        struct epoll_event ev;
        ev.events = EPOLLIN;
        ev.data.fd = _listensock->Sockfd(); //????
        int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Sockfd(), &ev);
        if (n == 0)
        {
            LOG(LogLevel::INFO) << "add listen sock to epoll success..."; // 4
        }
    }
    void IOService(int sockfd)
    {
        char inbuffer[1024];
        ssize_t n = recv(sockfd, inbuffer, sizeof(inbuffer), 0); // 读取会不会被阻塞
        if (n > 0)
        {
            inbuffer[n] = 0;
            LOG(LogLevel::INFO) << "inbuffer: " << inbuffer;
            std::string echo_string = "echo # ";
            echo_string += inbuffer;

            send(sockfd, echo_string.c_str(), echo_string.size(), 0); // 能直接发送吗??
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit: " << sockfd;
            // 1. 不让epoll关心fd
            //坑: epoll_ctl要删除对特定fd 的事件关心,前提是fd必须在系统中是合法的!
            int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            (void)n;
            // 2. close fd
            close(sockfd);
        }
        else
        {
            LOG(LogLevel::INFO) << "recv error: " << sockfd;
            // 1. 不让epoll关心fd
            int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            (void)n;
            // 2. close fd
            close(sockfd);
        }
    }
    void Listener()
    {
        InetAddr clientaddr;
        int newsockfd = _listensock->Accepter(clientaddr);
        if (newsockfd < 0)
            return;
        // 获取新链接成功
        LOG(LogLevel::INFO) << "accept success, new sockfd: " << newsockfd << " " << clientaddr.ToString();

        // 你能不能直接读取数据??不能!,托管给epoll!!,添加到rb中就可以!
        struct epoll_event ev;
        ev.events = EPOLLIN;
        ev.data.fd = newsockfd;
        int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, newsockfd, &ev);
        (void)n;
    }

    void HanderEvent(struct epoll_event revs[], int n)
    {
        for (int i = 0; i < n; i++)
        {
            int sockfd = revs[i].data.fd;
            uint32_t revents = revs[i].events;
            if (revents & EPOLLIN)
            {
                // 读事件就绪
                // 新链接到来,也是读事件就绪哦!
                if (sockfd == _listensock->Sockfd())
                {
                    // 获取新链接
                    Listener(); // 连接管理器
                }
                else
                {
                    // 普通fd就绪
                    IOService(sockfd);
                }
            }
            // else if(revents & EPOLLOUT)
            // {
            //     // 写事件就绪
            // }
            // else if(revents & EPOLLERR)
            // {
            //     // 异常事件
            // }
        }
    }

    void Loop()
    {
        int timeout = -1;
        while (!_quit)
        {
            struct epoll_event revs[revs_num];
            int n = epoll_wait(_epfd, revs, revs_num, timeout);
            if (n > 0)
            {
                // 有事件就绪了
                LOG(LogLevel::DEBUG) << "Event Ready!, n = " << n;
                HanderEvent(revs, n);
            }
            else if (n == 0)
            {
                LOG(LogLevel::INFO) << "time out...";
            }
            else
            {
                LOG(LogLevel::ERROR) << "epoll_wait error";
                break;
            }
        }
    }

    ~EpollServer() {}

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensock;

    int _epfd;
    bool _quit;
};

二、epoll的优缺点

优点:

epoll 整体相较于 select、poll 有着压倒性的性能优势,整套接口划分为 epoll_create、epoll_ctl、epoll_wait 三步调用逻辑,结构清晰且规避了前两者诸多先天短板。

在接口设计上,epoll 做到了输入参数与输出参数相互分离,fd 仅需通过 epoll_ctl 一次性完成注册、修改、删除,无需像 select 每一轮循环都重新重置 fd 集合,代码编写维护更加简洁。

在用户态与内核态的数据拷贝开销上,select 与 poll 每次调用等待接口,都要把全部待监控 fd 完整拷贝至内核,循环执行便会产生持续冗余开销,而 epoll 只在执行 epoll_ctl 增减 fd 时完成一次拷贝,epoll_wait 阶段只拷贝已经就绪的事件,数据交互体开销被大幅缩减。

最为核心的差异在于 epoll 省去了整体遍历的开销,select 和 poll 在内核中都需要遍历全部监听 fd 去筛查就绪状态,时间复杂度为 O (n),fd 数量越多效率衰减越明显,epoll 依托 socket 的回调触发机制,一旦 fd 满足就绪条件就会主动被加入内核就绪队列,epoll_wait 直接读取队列结果即可,拿到就绪事件的复杂度接近 O (1),海量连接场景下性能十分稳定。同时 select 受 fd_set 位图结构限制存在监听上限,epoll 仅受系统进程文件描述符表的 fd 的限制,理论监听规模几乎没有硬性限制。

缺点:

当然 epoll 也存在一定局限性,它是 Linux 平台独有的系统调用,跨平台兼容性较差,Windows、macOS 等系统无法直接使用这套接口;另外在并发连接总数极少、几乎所有 fd 时刻都处于活跃就绪的场景下,epoll 的回调、红黑树管理等内核结构会带来少量额外开销,此时它的性能并不会明显优于 select 与 poll,这也是 epoll 仅在高并发闲置连接场景才能凸显优势的原因。

四、总结对比 select、poll、epoll 之间的优点和缺点 (重要,面试中常见)

select

优点:

  1. 跨平台兼容性极强,属于 POSIX 标准接口,Linux、Windows、macOS 全部支持,可用于做跨平台网络程序,适配性最广。
  2. 接口逻辑简单易懂,上手门槛低,早期网络编程使用普遍,历史生态成熟。

缺点:

  1. 文件描述存在数量硬性上限 : 依托 fd_set 位图结构存储监听 fd,默认最大长度 1024。
  2. 每次调用都要重复用户态→内核态完整拷贝 : 每一轮 select 调用,都需要把全部待监听的 fd 集合从用户空间拷贝至内核空间,循环执行会产生大量重复拷贝开销,连接越多损耗越严重。
  3. 内核与用户层均需要全量遍历,时间复杂度 O (n) : 内核会遍历全部注册 fd 筛查就绪状态;调用返回之后,用户程序依旧需要循环遍历整个 fd 集合,逐个判断哪个 fd 触发事件,监听规模越大,遍历耗时线性上涨。
  4. 输入输出参数复用,每次循环必须重置 fd 集合 : 读写异常集合共用同一组 fd_set,系统调用之后位图会被内核改写标记就绪位,下一轮循环必须手动重新初始化、添加所有 fd,编码冗余繁琐。

poll

优点:

  1. 突破了 1024 个 fd 的数量限制 : 采用 struct pollfd 数组结构维护 fd,不再使用定长位图,理论上限仅受系统最大文件句柄数约束,能够支持更多并发连接。
  2. 入参结构分离,无需每次重新构建监听列表 : 结构体内部区分需要监听的事件、实际触发的事件,内核只会修改就绪标识,每一轮调用不需要重新填充全部 fd,简化了编码工作。
  3. 依旧遵循 POSIX 规范,跨平台兼容性良好,移植成本远低于 epoll。

缺点

  1. 用户态、内核态数据拷贝开销并未优化 : 和 select 一致,每一次 poll 系统调用,都要将整个 pollfd 数组完整拷贝进入内核,海量空闲连接场景下拷贝开销依旧巨大。
  2. 时间复杂度依旧是 O (n) : 内核依旧要线性遍历全部注册的 fd 来判断就绪状态;调用返回后,用户代码依然需要遍历整个 pollfd 数组筛选就绪项,并发量升高后性能衰减明显。
  3. 没有本质上解决轮询遍历的核心痛点,仅优化了数据结构与使用体验,高并发闲置连接场景短板依旧突出。

epoll(Linux 独有,IO 多路最优方案)

优点:

  1. 按需拷贝,大幅降低数据拷贝开销 : fd 仅在 epoll_ctl 执行 ADD、MOD、DEL 时完成一次用户态到内核态拷贝;epoll_wait 只会把已经就绪的事件拷贝回用户空间,循环等待阶段不存在全量数组拷贝,开销极小。
  2. 事件回调机制,复杂度接近 O (1) : 在内核为每个 fd 绑定就绪回调,缓冲区可读、可写时主动将节点推入就绪队列;epoll_wait 直接读取就绪队列即可,无需遍历全部 fd,用户层也只遍历少量就绪条目,百万空闲连接场景性能几乎无衰减。
  3. 无固定 fd 数量上限 : 使用红黑树管理所有监听 fd,仅受系统全局句柄上限 ulimit -n 约束,天然适配百万级高并发网络服务。
  4. 接口分层设计,输入输出分离 : epoll_create 创建实例、epoll_ctl 长期管理 fd、epoll_wait 阻塞等待事件,fd 一次注册长期生效,无需每轮等待重新配置,代码结构清晰,维护性高。
  5. 支持水平触发 LT、边缘触发 ET 两种工作模式,灵活性更强,可根据业务场景做极致性能优化。

缺点:

  1. 平台绑定性极强,仅 Linux 系统原生支持,Windows、macOS 无原生实现,跨平台开发无法直接使用。
  2. 活跃连接极多、绝大多数 fd 持续就绪的场景下,红黑树管理、回调触发会带来少量内核额外开销,此时性能与 select、poll 差距不大,甚至略有劣势。
  3. ET 边缘触发模式对编码严谨性要求极高,如若缓冲区数据未读完,会丢失本次事件,极易产生 bug,开发调试成本更高。

三者核心维度横向精简总结:

  1. fd 上限:select(1024 硬限制)<poll(无硬性限制)=epoll(仅系统资源限制)
  2. 内核遍历方式:select 轮询遍历、poll 轮询遍历、epoll 就绪队列回调
  3. 时间复杂度:select O(n)、poll O(n)、epoll O(1)
  4. 数据拷贝:select 每轮全量拷贝、poll 每轮全量拷贝、epoll 仅增删 fd 时拷贝
  5. 跨平台:select 最佳,poll 次之,epoll 仅限 Linux
  6. 适用场景:小并发跨平台选 select/poll;Linux 高并发海量闲置连接,优先 epoll。

三、epoll的工作模式

epoll 的两种工作模式,本质就是内核向用户层推送 IO 事件就绪的通知机制 ,一种是LT模式,一种是ET模式。其中 LT 模式叫 Level Triggered (水平触发),ET 模式叫 Edge Triggered(边缘触发)。LT 模式也是 epoll 默认启用的模式。

下面我们通过一个故事帮我们理解这两种模式 :

  1. 张三和李四是快递员,你很喜欢在网上购物,所以自然的,你就有很多的快递,那么在学校里, 张三一次性拿了你的10个快递,所以张三到了你的宿舍楼底下,张三是一个很负责任的快递员,所以在你的宿舍楼底下,张三打电话给你说,你的10个快递来了,请快下楼来取吧。

  2. 但是此时的你正在打游戏,于是你说,等等吧,我在打游戏,然后过了一会,张三看你还不下来,于是又打电话给你,催促你让你下来拿快递,可是这局游戏没有结束,所以你还是让张三一下,别忘了张三是一个很负责任的快递员,张三在你下楼来之前,张三是会一直和你不断的打电话直到你下楼来取快递,所以此时游戏结束了,然后你就下楼了。

  3. 然后一看10个快递这么多,然后你拿了5个并且告诉张三,等我一下,我把这5个快递放上去之后,再下来取余下的5个快递,所以张三说,好的,那么你将5个快递放上去之后,然后下楼继续将余下的5个快递也取走了。

  4. 又过了好久,你又在网上买了很多物品,所以你又有5个快递了,但是此时给你送快递的不是张三了,给你送快递的是李四,李四也是一个快递员,和张三不同的是,李四不是一个靠谱的快递员,李四是一个做事情讲究效率的快递员,所以此时李四也骑着快递车载着你的5个快递来到了你的宿舍楼底下。

  5. 于是李四给你打电话,你的快递到了,请赶快下来取快递,好巧不巧,此时你仍然在打游戏,并且这局还没有结束,所以你告诉李四等一下,李四说,如果你不下来,我就走了,你不信,不信李四真的敢走,所以过了10分钟,这局游戏终于结束了,你心里想着好吧,现在我下楼去拿快递。

  6. 所以此时你下楼了,到了楼下,一眼望去只有景色,李四呢,李四呢?是的,李四真的走了,李四真的敢走,所以如果你对李四让你下落的电话忽略,那么李四就带着你的包裹走了,那么么时候李四才会来呢?不知道,或许是下一次有快递员来送快递的时候吧。

  7. 又过了好久,你又在网上买了很多物品,所以你又有5个快递了,很巧,李四又接收到了你的5个包裹,那么李四心里想,噢噢,这个人还有之前没拿走的5个包裹,连着现在的10个包裹我都给他拿着吧,所以李四又来到了你的宿舍楼底下。

  8. 那么此时李四给你打电话让你下楼拿快递,同样的,你仍然在打游戏,可是你知道此时的快递员是李四,如果你不下楼拿快递,这个李四真的敢走,此时你就拿不到快递了,下次拿快递还是猴年马月,所以此时你跟李四说,好的,我马上下来拿快递,你此时下楼了。

  9. 然后一看,噢噢,连着之前没拿的5个包裹,以及新的5个包裹,一共10个包裹,此时你一次拿了5个包裹,告诉李四,你等我一会,我把这5个包裹拿上楼之后,再下楼来拿余下的5个包裹,可是李四说,我等不及了,我马上就要走,你不信李四真的会走,你都见到李四了,你不信李四会纠结这一点时间而不等你。

  10. 所以你上楼了,将5个包裹送上去之后,下楼,一眼望去只有景色,李四呢,李四呢?他真的敢走,是的,李四真的走了,那么下次李四什么时候来,你也不知道,所以你此时就意识到了,好吧,李四不是张三,张三可以一直等我,只要张三送了我的快递,那么他会一直给我打电话,一直在楼下等我,张三也可以让我来拿第二趟。

  11. 可是李四不同,李四只打电话通知我一次,如果我不下楼来拿,那么他就会直接走,并且如果我下楼来拿, 最好一次将快递包裹全部拿走,因为李四不会在楼下继续等我,所以李四这种工作模式,在不考虑用户体验的情况下,李四的效率毫无疑问是比张三高的,因为李四在有限的时间内向不同的用户打去的电话是远远比张三要多的,那么张三的工作模式,张三很负责,但是张三可能一小时只能送2用户的个快递,因为熟悉张三的用户都知道,张三会等我,所以张三的用户都很拖延。

  12. 但是熟悉李四的用户都知道,李四真的敢走,所以李四一打电话通知,那么用户就会赶快下楼,并且李四的这种工作模式可以强迫用户一次将快递包裹全部取走,所以毫无疑问,李四送快递的效率是高的,李四可以一小时向不同的人打出很多的电话,所以李四可能一小时可以送10个用户的快递。

  13. 所以此时我们就可以类比一下了,epoll的工作模式分为LT模式和ET模式,所以对照一下,其实张三是LT模式,李四是ET模式,那么如何理解呢?当张三收到用户的快递的时候,尽管用户短时间或者长时间不处理,用户想什么时候拿,用户一次拿多少都可以,张三一直会等待用户下楼拿快递,张三负责的快递一直有效,所以张三是LT模式。

  14. 李四收到用户快递的时候,当用户的快递从没有到有快递,李四会通知用户,并且只会打一个电话,如果用户不来拿,那么李四就不管了,当用户的快递从5个快递到10个快递的时候,李四也会通知用户,并且也只会打一个电话,如果用户来拿,但是用户不拿全,李四也不管,李四也直接走,所以快递从无到有,从有到多,快递包裹变化的时候,李四才会通知用户一次。

至此,故事讲完了,epoll 的这两种模式,本质上就是内核给用户程序发 "IO 就绪通知" 的两种策略,而故事里的两个快递员,刚好对应了这两种模式:

水平触发(LT 模式)

张三就是典型的 LT 模式。只要你楼下还有没取走的快递,他就会反复给你打电话催你下来取,直到你把所有快递都拿走为止。

放到 epoll 里就是:只要 socket 的内核接收缓冲区里还有数据没读完,epoll_wait 就会一直返回这个 fd,通知你它 "可读就绪",直到你把数据全部读完。 所以在 LT 模式下,哪怕一次只读一部分数据也没关系,剩下的数据没读完,下一轮 epoll_wait 还是会继续通知处理,编码容错率很高,这也是 epoll 默认的模式,和 select/poll 的行为是一致的。

边缘触发(ET 模式)

李四就是典型的 ET 模式。他只会在快递 "状态发生变化" 的时候给你打一次电话:要么是第一次有快递送来,要么是又新增了快递、包裹数量从 5 个变成 10 个的时候。如果你没下楼取,或者只取了一部分就上楼了,他不会再打电话,直接就走了,下次要等新的快递送来、状态再次变化时,才会再通知你。

放到 epoll 里就是:只有当 socket 的 IO 状态发生变化(比如新数据到达,或者缓冲区从无到有、从少到多)时,epoll_wait 才会返回一次通知,哪怕缓冲区里还有数据没读完,它也不会再通知第二次。 所以 ET 模式下,我们必须一次性把缓冲区里的数据全部读完,否则剩下的数据就再也不会触发通知,会一直留在缓冲区里,导致数据丢失或者逻辑卡死。

LT 模式和 ET 模式在内核层面的理解

epoll 默认采用的是 LT 水平触发模式,也就是我们之前代码里一直使用的模式,这也是为什么当客户端数据就绪后,服务端会一直循环打印 event ready 的核心原因。要理解这一点,我们首先要搞清楚内核里 "通知" 的本质:所谓通知,就是内核把事件就绪的文件描述符节点主动链入 epoll 的就绪队列中,这样用户态调用 epoll_wait 时,就能拿到这个事件。用户一旦从队列中取走这个节点,它就会从就绪队列里消失。

但在 LT 模式下,如果用户知道事件就绪却没有及时处理,或者读取了数据却没有处理干净,**只要内核接收缓冲区里还有数据可读,这个文件描述符就会一直处于 "就绪状态",内核会持续把它重新激活、放回就绪队列,用户下一次调用 epoll_wait 时,依然会收到这个事件通知,直到用户把缓冲区里的数据全部读完为止。**就像我们之前故事里的快递员张三,只要你还有快递没取,他就会反复给你打电话催你下来,不会因为你一次没接就放弃。这就是 LT 模式 "一直通知" 的含义。

而 ET 边缘触发模式的逻辑则完全不同,**它只会在接收缓冲区的数据状态发生变化时,才会通知用户一次。什么叫状态变化?就是数据从无到有,或者从少到多,比如客户端第一次发来数据、或者又追加了新数据。内核只会在这些变化发生的瞬间,把文件描述符节点激活一次,放入就绪队列。**一旦用户取走了这个通知,哪怕缓冲区里还有没读完的数据,只要没有新数据再进来,内核就不会再激活这个节点,也不会再发通知。这就像故事里的快递员李四,他只会在包裹到的时候打一次电话,如果你没下来取,或者只取了一半就走了,他不会再催你,直接就走了,你再也不会收到这次数据的通知。正因为 ET 模式减少了大量重复的无效通知,它的理论效率会比 LT 模式更高,但对编码的要求也更严苛,必须配合非阻塞 IO,一次性把缓冲区里的数据全部读完,否则就会丢失事件。

最后要特别区分**"收到通知" 和 "处理通知"**是两码事:LT 模式下,哪怕我们收到通知却不处理,内核也会反复通知你;而 ET 模式下,通知只发一次,不处理就再也没有机会了,这也是理解两种模式差异的关键。


为什么叫 "水平触发" 和 "边缘触发"?

这两个名字其实是从硬件电路里的示波器触发逻辑借来的。

  • 水平触发(LT, Level Triggered): 就像示波器里的 "电平触发",只要信号处于 "高电平"(也就是缓冲区里有数据、就绪状态为真),就会一直触发通知。放到 epoll 里,只要 socket 接收缓冲区里还有数据,哪怕读了一半没读完,内核也会反复把它放回就绪队列,让 epoll_wait 一直通知你,直到缓冲区被读空为止。这就是 "水平" 的含义:关注的是当前状态的 "电平高低",只要状态为真,就持续触发。
  • 边缘触发(ET, Edge Triggered):对应示波器里的 "边沿触发",只在信号状态发生跳变的瞬间(比如从低电平变高电平,也就是数据从无到有、从少到多)触发一次。放到 epoll 里,只有当缓冲区数据发生变化(比如新数据到达)时,内核才会通知你一次,哪怕你没读完,也不会再通知第二次。这就是 "边缘" 的含义:只关注状态变化的 "跳变沿",变化发生时触发一次,之后不再重复。

什么时候用 LT,什么时候用 ET?

  • LT 模式:适合大多数通用场景,尤其是新手开发、业务逻辑复杂、数据量不稳定的情况。它的优势是容错率高,哪怕一次没读完数据,下一轮 epoll_wait 还是会继续通知,不会丢事件,开发和调试成本低,也是 epoll 的默认模式。
  • ET 模式:适合追求极致性能、高并发海量连接的场景,比如 Nginx 这类高性能服务器。它减少了大量重复的无效通知,内核和用户态的交互更少,理论上效率更高,但对编码要求非常严格。

那 LT 模式也能模拟出 ET 模式的效果呀,比如说用 LT 模式只通知一次,那为什么还要有 ET 呢?

LT 想要模仿 ET 只通知一次的效果,只能依靠业务代码主动处理:收到就绪事件后,立刻循环调用 recv,把内核缓冲区的数据全部读完。缓冲区彻底清空之后,LT 就不会再重复触发事件,表面实现了单次通知的效果。但这套规则全靠程序员自觉,只要缓冲区剩有数据,下一轮依旧会重复通知。

但是即便 LT 可以模拟,我们依旧要使用原生 ET 的原因:

LT 是可以做到,ET 是必须做到

  • 执行约束力天差地别 LT 只是给了你 "做到只触发一次" 的可能性,能不能读完缓冲区、要不要循环读取,完全由开发者自己把控。一旦出现代码疏漏、分支遗漏,缓冲区残留数据,LT 就会无休止重复推送事件,极易造成 CPU 空转打满;而 ET 是内核层面的硬性规则,仅在 IO 状态跳变时下发单次通知,天然倒逼开发者必须一次性读完所有缓冲区数据,不存在侥幸空间,从机制上规避残留数据遗留的隐患。
  • 内核原生机制,效率远高于上层模拟方案 LT 模拟 ET,本质是用户态靠业务逻辑规避重复事件,内核依旧保留着 LT 持续就绪判定的整套逻辑;原生 ET 从内核就绪队列、回调触发链路就做了精简,主动屏蔽无状态变化后的重复推送,系统调用次数、内核与用户态交互开销,都比 LT 人工模拟的方案更低,高并发海量空闲连接场景下性能差距会被持续放大。
  • 工程规范统一性,规避人为失误 不能指望所有开发人员都严格遵守 "LT 收到事件必须读空缓冲区" 的编码规范,团队协作中极易出现疏忽漏洞;而 ET 是接口原生特性,选型即代表必须遵循全量读取的编码范式,形成统一的工程标准,在 Nginx、Redis 这类高性能服务的核心场景里,ET 是经过验证的最优选型,稳定性与执行确定性远优于 LT 手动模拟的折中方案。

还有最后一点 : epoll 是通过内核就绪队列来通知用户程序 IO 事件的,而通知模式分为 LT 水平触发和 ET 边缘触发两种。对于 LT 模式来说,只要底层缓冲区还有未读完的数据,内核就会持续把这个 fd 放回就绪队列,用户下次调用 epoll_wait 依然会收到通知,所以即使一次没读完数据也不会影响后续处理,容错性很高。但 ET 模式完全不同,它只会在数据状态发生变化的瞬间通知用户一次,如果这次没把数据一次性全部读完,哪怕缓冲区里还有剩余数据,内核也不会再发通知,这些数据就会一直留在缓冲区里,再也没有机会被处理,所以 ET 模式从设计上就强制要求程序员必须在收到通知的这一轮,把所有数据全部读完。

**要做到一次性读完数据,只能通过循环调用 recv 来反复读取,**但这里有个关键问题:用户程序本身并不知道缓冲区里的数据什么时候会被读完,只能一直循环读下去。如果在数据已经被读空之后,还继续调用 recv,在阻塞 IO 模式下,进程就会在这里被挂起,单线程服务会直接卡住,无法处理其他任何事件。为了避免这种情况,ET 模式下必须把文件描述符设置为非阻塞,这样当缓冲区数据被读空时,recv 会立刻返回 - 1,并把 errno 设置为 EAGAIN 或 EWOULDBLOCK,用户程序就能借此判断数据已经读完,安全退出循环,不会发生阻塞。

所以结论非常明确:ET 模式下操作任何文件描述符时,fd 必须设置为非阻塞模式,这是它能安全运行的前提。而 LT 模式下没有这种限制,哪怕使用阻塞 IO,也不会出现这种卡死的问题,因为数据没读完的话,下一轮 epoll_wait 依然会收到通知,不需要通过循环读取来一次性处理完。

这就是之前强调的,第一次recv一定有数据,但是下次读就不能保证了,如果没数据还读的话就会阻塞住,所以

LT 和 ET 使用时的注意事项

LT 模式的注意事项:

  • 虽然安全,但要注意避免 "循环触发" 问题。如果处理逻辑有 bug,导致数据没被真正读走,内核会一直通知,epoll_wait 会立刻返回,导致 CPU 被打满。所以哪怕是 LT 模式,也要确保数据处理逻辑是正确的,不要让 fd 一直处于就绪状态却不处理。

ET 模式的注意事项:

  • 必须配合非阻塞 IO使用。因为 ET 模式只通知一次,如果用阻塞 IO 读数据,当缓冲区数据被读完后,recv 会阻塞,导致整个服务卡住。非阻塞 IO 下,recv 读完数据会直接返回 EAGAIN/EWOULDBLOCK,可以借此判断数据已经读空,退出循环。
  • 必须一次性把缓冲区里的数据全部读完。通常要用 while 循环调用 recv,直到返回 - 1 且 errno 为 EAGAIN,确保没有数据残留,否则剩下的数据再也不会触发通知,会导致数据丢失或逻辑卡死。
  • 事件设置时要加上 EPOLLET 标志,否则默认就是 LT 模式。

面试题 : 为什么 ET 模式下文件描述符要设置为非阻塞?

在 ET 边缘触发模式下,文件描述符必须设置为非阻塞模式是由 ET 模式的触发机制和数据读取方式共同决定的。

首先,ET 模式的核心特性是只在 IO 事件的状态发生变化时通知一次,比如数据从无到有、从少到多。这意味着当 epoll_wait 返回通知时,用户程序必须一次性把内核缓冲区里的所有数据全部读完,否则剩下的数据再也不会触发新的通知,会一直留在缓冲区里,造成数据丢失或逻辑卡死。要实现一次性读完数据,就必须用循环调用 recv 的方式反复读取,直到缓冲区被清空为止。

问题就出在这个循环读取的过程中。用户程序本身无法提前知道缓冲区里有多少数据,只能一直循环调用 recv。如果此时文件描述符是阻塞模式,当缓冲区数据被读完后,recv 会阻塞等待下一批数据到来,导致整个单线程进程被挂起,无法处理其他任何事件,服务直接卡死。

而非阻塞模式正好解决了这个问题。当缓冲区数据被读完后,recv 会立刻返回 - 1,并把 errno 设置为 EAGAIN 或 EWOULDBLOCK,用户程序就能借此判断数据已经读完,安全退出循环,不会发生阻塞。所以,ET 模式下文件描述符必须设置为非阻塞模式,这是为了配合它 "一次性读完数据" 的要求,避免在循环读取过程中阻塞整个进程,保证服务的稳定性和正确性。

ET模式的优点:

ET 模式的效率优势,主要体现在两个层面:

第一,减少了无效的重复通知 。LT 模式下,只要内核缓冲区还有数据没读完,epoll_wait 就会反复通知用户,哪怕用户一次处理不完,下一轮循环还是会收到同样的事件。而 ET 模式只会在数据状态发生变化时通知一次,用户必须一次性把数据读完,内核不会再发重复通知,这就大大减少了内核和用户态之间的交互次数,通知效率自然更高。

第二,提升了网络整体的吞吐量 。ET 模式强制用户循环读取,直到把内核接收缓冲区的数据全部取走。从 TCP 协议的角度看,接收方的缓冲区数据被取走后,会在回复 ACK 时通告一个更大的接收窗口大小。发送方收到这个窗口通告后,就能根据滑动窗口机制发送更多数据,而不用频繁等待接收方处理。这样一来,双方的通信不再被接收方的处理速度限制,网络的整体吞吐量就上去了,通信效率也随之提高。

这两点结合起来,让 ET 模式在高并发、大流量场景下,比 LT 模式更能发挥出 epoll 的性能优势。

四,总结

本文详细介绍了Linux的epoll多路复用机制。首先对比了epoll与select/poll的性能优势,包括避免全量遍历、减少数据拷贝等。然后通过完整代码示例展示了epoll服务器的实现流程,包括创建epoll实例、注册监听套接字、事件循环处理等核心步骤。重点分析了epoll的两种工作模式:水平触发(LT)会持续通知未处理事件,而边缘触发(ET)只在状态变化时通知一次,ET模式必须配合非阻塞IO使用。最后总结了三种多路复用机制的优缺点,指出epoll在高并发场景下的性能优势,但也强调其Linux平台专有特性。文章通过快递员的故事形象解释了两种触发模式的区别,并给出了实际应用中的选择建议。

谢谢大家的观看!

相关推荐
JP-Destiny1 小时前
docker报错-无法解析 registry-1.docker.io
运维·docker·容器
想你依然心痛1 小时前
HarmonyOS 6(API 23)智能体驱动的沉浸式AR城市地下管网运维中心
运维·ar·harmonyos·智能体
xiaoye-duck1 小时前
《Linux系统编程》Linux 命名管道 FIFO 详解:突破亲缘限制的跨进程通信实现
linux
文青小兵1 小时前
Linux云计算——docker镜像(三)
linux·docker·云计算
逸Y 仙X1 小时前
文章六:ElasticSearch 集群通信安全权限
java·大数据·服务器·elasticsearch·搜索引擎·全文检索
爱和冰阔落1 小时前
【Linux系统编程】环境变量深度解析——从 fork 继承到 export 内建命令,两张表打通进程上下文
linux·c++·环境变量·系统调用
feng14561 小时前
OpenSREClaw - 一切始于风险洞察报告
运维
零壹AI实验室1 小时前
AI发现潜伏18年的NGINX高危漏洞:CVE-2026-42945完整技术分析
运维·人工智能·nginx
Dlrb12112 小时前
数据结构-内核链表
linux·数据结构·链表·内核链表·inline·容器宏