Linux 高级 IO 深度解析:从 IO 本质到 epoll全面讲解

文章目录

  • [Linux 高级 IO 深度解析:从 IO 本质到 epoll 的工程实践](#Linux 高级 IO 深度解析:从 IO 本质到 epoll 的工程实践)
    • [一、重新认识 IO](#一、重新认识 IO)
    • [二、同步 IO vs 异步 IO](#二、同步 IO vs 异步 IO)
    • [三、阻塞 IO](#三、阻塞 IO)
    • [四、非阻塞 IO](#四、非阻塞 IO)
    • [五、IO 多路复用](#五、IO 多路复用)
    • 六、select:多路复用的初代实现
    • [七、poll:select 的轻量升级](#七、poll:select 的轻量升级)
    • [八、epoll:Linux 高并发的核心](#八、epoll:Linux 高并发的核心)
      • [epoll 的三个核心接口](#epoll 的三个核心接口)
      • [epoll 与 select/poll 的核心性能对比](#epoll 与 select/poll 的核心性能对比)

Linux 高级 IO 深度解析:从 IO 本质到 epoll 的工程实践

一、重新认识 IO

网络通信的本质是进程间通信,而进程间通信的本质是 IO。

学习高级IO的第一步,是先搞懂IO到底在做什么 ------所有IO模型的设计,都是为了解决IO核心过程中的效率问题,脱离这个核心,一切模型都是空中楼阁。其中,IO的核心包含两步:等待 + 拷贝

以Linux中最常见的**read()**系统调用为例,无论读取的是网卡、磁盘、键盘还是管道,内核底层都会完成这两个不可分割的步骤,且这两个步骤的耗时占比天差地别:

(1)等待数据就绪(Wait) :这是IO最耗时的阶段 ,占比通常在99%以上。内核需要等待外部数据到达并写入内核缓冲区 ,比如网卡等待网络数据包从对端传输过来、磁盘等待磁头寻址并将数据读入内核页缓存、管道等待其他进程写入数据。此阶段进程无法做任何有效工作,只能处于"等待"状态。

(2)数据拷贝(Copy) :当内核缓冲区有数据后,内核会将数据从内核态缓冲区 拷贝到用户态缓冲区,这是一次纯内存操作,耗时极短。

一句话总结IO的本质

IO = 99% 的等待 + 1% 的拷贝

而**什么是"高效 IO"?**很多人误以为"高效IO"是让数据拷贝更快,这是典型的误区------内存拷贝的速度由硬件和内核机制决定,程序员几乎无法优化。

真正的高效IO,核心只有一个 :在单位时间内,让进程尽量少做"无意义的等待",把CPU资源用在有效计算 上。CPU的最大浪费,就是线程什么都不干,只在原地等待IO就绪。所有Linux高级IO模型的设计目标,都是围绕减少等待的无效性展开的。

二、同步 IO vs 异步 IO

在开始拆解具体IO模型前,我们需要先明确同步IO和异步IO的核心定义------这是划分IO模型的根本标准,也是避免概念混淆的关键。

划分的核心依据:进程是否参与IO的"等待"或"拷贝"阶段

其中,同步 IO(工程主流,本文核心):只要进程参与了IO的任意一个阶段(等待 或 拷贝),就是同步IO。

同步IO中,数据拷贝阶段必须由进程主动发起系统调用(如read/write)完成,这是同步IO的核心特征。Linux下所有工程中常用的IO模型都属于同步IO:

阻塞IO(BIO)、非阻塞IO(NIO)、信号驱动IO(SIGIO)、IO多路复用(select/poll/epoll)。

异步 IO(小众用法):IO的"等待"和"拷贝"阶段全部由内核完成,进程全程不参与。

异步IO的执行流程:

  1. 进程发起异步IO调用(如aio_read),传入数据缓冲区地址和回调方式,直接返回并继续执行;

  2. 内核自行完成"等待数据就绪"和"将数据拷贝到用户缓冲区"的全部操作;

  3. 内核完成后,通过信号回调通知进程"IO已完成,数据已就绪"。

为什么Linux下异步IO几乎不用?Linux下提供了 libaio 等异步IO接口,但工程中极少使用,核心原因有三:

  1. 使用成本极高:接口设计复杂,且与现有网络编程框架兼容性差;

  2. 功能不完善:对文件、网卡的支持存在差异,部分场景下实现为"伪异步"(内核仍会阻塞);

  3. 性价比低:epoll + 非阻塞IO的组合,能以极低的开发成本达到接近异步IO的性能,完全满足高并发场景需求。

Linux网络编程的高并发方案,是IO多路复用 + 非阻塞IO

三、阻塞 IO

阻塞IO(Block IO,BIO)是最基础、最直观的IO模型,也是所有IO模型的起点。它的设计符合人类的直观思维,但也是性能最差的IO模型。

其中,阻塞 IO 的行为模型:以read(fd, buf, size)为例,当进程调用该系统调用后,若数据未就绪,内核会执行以下操作:

  1. 将当前进程的状态设置为睡眠状态(TASK_INTERRUPTIBLE);

  2. 将进程从CPU的就绪队列中移除,调度其他就绪进程执行;

  3. 当数据就绪后,内核唤醒该进程,将其重新加入就绪队列;

  4. 进程被CPU调度后,内核完成数据拷贝,read()调用返回。

核心特征一次IO调用,阻塞整个线程,直到IO完成。

阻塞 IO 的优缺点

优点

  1. 编程极其简单:无需处理复杂的状态判断,逻辑线性,不易出错;

  2. 内核管理高效:进程睡眠时不占用CPU资源,内核调度开销低;

  3. 适合简单场景:单连接、低并发场景下,开发成本远低于高级IO。

致命缺点

  1. 高并发下线程资源耗尽:一个连接需要一个线程处理,若有1000个并发连接,就需要1000个线程;

  2. 线程上下文切换开销大:大量睡眠线程被唤醒时,会导致频繁的CPU上下文切换,大幅降低系统性能;

  3. 线程资源浪费:大部分线程处于睡眠等待状态,占用内存(线程栈)等系统资源,却不做任何有效工作。

阻塞 IO 的适用场景仅适合单连接、低并发、简单程序 ,比如本地小工具的文件读取、单客户端的简单服务端程序、无需高并发的命令行程序。绝对不适合:高并发网络服务器(如Web服务器、网关、消息队列)。

四、非阻塞 IO

阻塞IO的核心问题是线程被IO阻塞 ,而非阻塞IO(Non-Block IO,NIO)的设计初衷,就是打破这种阻塞,让线程不会因为IO而停下

其中,非阻塞 IO 的开启方式 :Linux中,文件描述符(fd)默认是阻塞模式,通过fcntl系统调用可以将fd设置为非阻塞模式:

c 复制代码
#include <fcntl.h>
#include <unistd.h>

// 获取fd当前的标志位
int flags = fcntl(fd, F_GETFL, 0);
// 设置非阻塞标志位 O_NONBLOCK
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

关键 :非阻塞模式是针对fd的属性,而非系统调用------一旦设置,该fd上的所有IO操作(read/write/accept/connect)都会变为非阻塞。

非阻塞 IO 的核心行为 :当fd被设置为非阻塞模式后,调用read()/write()等IO操作时,内核不会让进程睡眠,而是立即返回,结果分两种情况:

  1. 数据就绪:正常完成IO操作,返回实际读取/写入的字节数;

  2. 数据未就绪 :立即返回**-1**,并将全局变量errno设置为EAGAINEWOULDBLOCK(两个宏值相同,只是命名不同)。

注意 :errno = EAGAIN/EWOULDBLOCK不是错误 ,而是内核给进程的"提示":现在数据还没就绪,你别等,先去做别的事,稍后再试

非阻塞 IO 的核心价值 :非阻塞IO的设计,本质是将IO的"等待阶段"从内核接管,交还给程序员------内核不再为进程做等待,而是让进程自己决定"什么时候再尝试IO"。

基于此,进程可以实现**"忙等不阻塞"**的逻辑:

c 复制代码
#include <errno.h>
#include <unistd.h>

char buf[1024];
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        // 数据读取成功,处理数据
        handle_data(buf, n);
        break;
    } else if (n == 0) {
        // 对端关闭连接
        break;
    } else {
        // 读取失败,判断错误类型
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // 数据未就绪,去做别的事
            do_other_work();
            // 稍等片刻再重试,避免CPU空转
            usleep(1000);
            continue;
        }
        else if(errno == EINTR)
        {
            //遇到了中断,IO被打断了,可以继续读取
			continue;
        }
        else
        {
            // 真正的错误,如fd无效、权限不足等
       		perror("read error");
       		break;
        }
    }
}

非阻塞 IO 的致命问题:不能单独使用:非阻塞IO解决了"线程被IO阻塞"的问题,但引入了新的更严重的问题:

  1. CPU空转:如果进程一直循环重试IO,会导致CPU一直处于满负载状态,做无意义的轮询;

  2. fd数量扩展差 :当需要处理多个fd时,进程需要轮询所有fd,fd数量越多,轮询的开销越大,性能呈线性下降;

  3. 重试时机难把握:重试太频繁会导致CPU空转,重试太稀疏会导致IO响应延迟。

核心结论非阻塞IO不能单独使用 ,它必须和IO多路复用配合------由IO多路复用帮进程"等待多个fd的就绪状态",进程只在fd就绪时才发起非阻塞IO操作,从根本上避免CPU空转。

五、IO 多路复用

IO多路复用(IO Multiplexing)是Linux高级IO的核心 ,也是解决"多连接高并发"的关键技术。通过多路复用 ➕非阻塞IO,完美结合了阻塞IO和非阻塞IO的优点,规避了两者的缺点。

其中,多路复用的核心定义:让一个线程,同时等待多个文件描述符(fd)的就绪状态,当其中任意一个或多个fd就绪时,通知进程,进程再对就绪的fd发起IO操作(想要高效,一般都是非阻塞IO)。

核心设计思想(最关键) :IO多路复用的设计,精准抓住了IO的本质------将IO的"等待阶段"和"拷贝阶段"彻底分离

  1. 等待阶段 :由IO多路复用接口(select/poll/epoll)统一处理,一个线程就能等待成千上万个fd,避免了多线程的资源浪费;

  2. 拷贝阶段 :进程只对就绪的fd发起IO操作,无任何等待,效率拉满。

用一句话概括多路复用的核心价值:用一个线程,解决成千上万个fd的IO等待问题,从根本上解决高并发下的线程资源耗尽问题。

多路复用的执行流程:IO多路复用 + 非阻塞IO的标准执行流程,适用于所有高并发网络编程:

  1. 将所有需要处理的fd设置为非阻塞模式

  2. 将这些fd注册到IO多路复用器(select/poll/epoll)中,指定关注的事件(读/写/异常);

  3. 调用多路复用接口(如epoll_wait),阻塞等待注册的fd就绪;

  4. 当有fd就绪时,多路复用接口返回,返回就绪的fd列表和对应事件;

  5. 遍历就绪的fd列表,发起非阻塞IO操作(read/write/accept),处理数据;

  6. 处理完成后,再次回到步骤3,循环等待下一批fd就绪。

**为什么多路复用是"同步IO"?**很多人会误以为多路复用是异步IO,这是典型的概念错误------多路复用属于同步IO,原因如下:

  1. 多路复用器只负责IO的等待阶段,不参与任何数据拷贝;

  2. 数据拷贝阶段必须由进程主动发起系统调用完成,进程需要参与拷贝阶段,而异步IO是拷贝也有内核完成。

这完全符合同步IO的定义:进程参与了IO的任意一个阶段。

六、select:多路复用的初代实现

select是Linux中最早的IO多路复用接口,定义在<sys/select.h>中,它的出现首次解决了"一个线程处理多个fd"的问题,但由于设计上的缺陷,性能存在明显瓶颈。

其中,select 的核心接口:select的核心系统调用如下:

c 复制代码
#include <sys/select.h>

// 阻塞等待fd就绪,返回就绪的fd数量
int select(
    int nfds,               // 关注的最大fd编号 + 1
    fd_set *readfds,        // 关注读事件的fd集合
    fd_set *writefds,       // 关注写事件的fd集合
    fd_set *exceptfds,      // 关注异常事件的fd集合
    struct timeval *timeout // 超时时间,NULL表示永久阻塞
);

// 操作fd_set的宏
FD_ZERO(fd_set *set);      // 清空fd_set的所有位
FD_SET(int fd, fd_set *set);   // 将fd加入fd_set
FD_CLR(int fd, fd_set *set);   // 将fd从fd_set中移除
FD_ISSET(int fd, fd_set *set); // 判断fd是否在fd_set中(是否就绪)

select 的核心工作原理

  1. fd_set 本质是位图 :fd_set是一个固定大小的整型数组,每个对应一个fd编号,位的值为1表示"关注该fd的对应事件",0表示"不关注";

  2. 用户态到内核态的拷贝 :调用select时,进程需要将readfds/writefds/exceptfds三个位图从用户态拷贝到内核态

  3. 内核线性遍历fd :内核会从0到nfds-1线性遍历所有被关注的fd,检查其是否就绪;

  4. 就绪fd标记与返回 :若某个fd就绪,内核将其在对应位图中的位保持为1,否则置0;遍历完成后,内核将位图从内核态拷贝回用户态,并返回就绪的fd数量;

  5. 用户态遍历就绪fd:进程需要遍历所有被关注的fd,通过FD_ISSET宏判断哪个fd就绪,再进行后续处理。

select 的致命设计缺陷(性能瓶颈) :select的性能瓶颈并非实现问题,而是设计层面的缺陷,这些缺陷导致其无法支撑高并发场景(如fd数量超过1000):

  1. fd数量存在硬限制 :fd_set的大小是固定的 (默认对应1024个fd),这是由内核宏FD_SETSIZE定义的,虽然可以通过重新编译内核修改该值,但会带来系统兼容性问题,工程中不可行

  2. 每次调用都要拷贝整个fd_set :select的三个fd_set是用户态数据结构,每次调用select都需要将其完整拷贝到内核态,fd数量越多,拷贝的开销越大;IO完成后,又需要将位图拷贝回用户态,双重拷贝带来额外开销;

  3. 内核每次都要线性遍历所有fd :内核没有维护fd的就绪状态,每次调用select都需要从0到nfds-1遍历所有被关注的fd,判断其是否就绪------即使只有1个fd就绪,也要遍历所有关注的fd,fd数量越多,遍历开销越大,时间复杂度为O(N);

  4. fd_set是输入输出型参数 :select的fd_set既是输入参数 (告诉内核关注哪些fd),也是输出参数 (告诉进程哪些fd就绪)。内核在返回时会修改fd_set,因此每次调用select前,都需要重新初始化并设置fd_set,增加了开发成本和额外的CPU开销。

select 的适用场景 :select仅适合fd数量较少(如小于100)的低并发场景,如今已几乎被poll和epoll取代,仅在一些老旧程序或跨平台程序中存在(select是POSIX标准接口,跨平台性最好)。

实际代码样例:

cpp 复制代码
//使用select必须搭配辅助数组来管理文件描述符
int _rfds_arr[gnum];
/**
 * @brief IO多路复用主循环函数
 * @details 基于select实现,持续监听监听套接字和已连接套接字的读事件,
 *          处理新连接建立和已有连接的数据接收,包含超时机制和异常处理
 */
void Loop()
{
    // 初始化select监控的文件描述符集大小:监听套接字fd + 1(select要求nfds为最大fd+1)
    int nfds = _listen_socket->Sockfd() + 1;
    // 读事件文件描述符集合(rfds:read file descriptors)
    fd_set rfds;
    // select超时时间结构体
    timeval timeout;

    // 无限循环,持续处理IO事件
    while (true)
    {
        // 1. 清空读事件文件描述符集合(每次循环必须重置,因为select会修改集合)
        FD_ZERO(&rfds);

        // 2. 遍历所有需要监控的套接字fd数组,将有效fd加入读事件集合
        for (int i = 0; i < gnum; i++)
        {
            // 跳过默认无效的fd(gdefault应为无效fd标识,如-1)
            if (_rfds_arr[i] == gdefault)
                continue;
            
            // 将当前有效fd加入读事件监控集合
            FD_SET(_rfds_arr[i], &rfds);

            // 更新select需要监控的最大fd值(确保nfds始终是最大fd+1)
            if (_rfds_arr[i] >= nfds)
                nfds = _rfds_arr[i] + 1;
        }

        // 调试用:打印当前监控的fd集合状态(自定义函数)
        PrintRfds();

        // 3. 设置select超时时间:2秒(tv_sec秒 + tv_usec微秒)
        //    每次循环重置超时时间,因为select调用后会修改timeout的值(部分系统)
        timeout.tv_sec = 2;
        timeout.tv_usec = 0;

        // 4. 调用select等待读事件发生
        // 参数说明:
        // nfds:监控的最大fd+1;&rfds:读事件集合;后两个nullptr:不监控写/异常事件;&timeout:超时时间
        // 返回值n:>0表示有n个fd触发事件;=0表示超时;<0表示出错
        int n = select(nfds, &rfds, nullptr, nullptr, &timeout);

        // 5. 处理select返回结果
        if (n > 0)  // 有fd触发读事件
        {
            // 记录触发事件的fd数量,用于循环中递减计数,提前退出循环
            int num = n;
            // 遍历所有监控的fd,找到触发事件的fd并处理
            for (int i = 0; num && i < gnum; i++)
            {
                // 跳过无效fd
                if (_rfds_arr[i] == gdefault)
                    continue;

                // 检查当前fd是否在触发事件的读集合中
                if (FD_ISSET(_rfds_arr[i], &rfds))
                {
                    // 每处理一个事件,计数减1
                    num--;

                    // 区分事件类型:监听套接字触发事件 → 新连接到来
                    if (_rfds_arr[i] == _listen_socket->Sockfd())
                    {
                        // 打印日志:获取新连接,输出事件数和超时时间
                        LOG(INFO, "get %d newlink, timeout %d : %d\n", n, timeout.tv_sec, timeout.tv_usec);
                        // 处理新连接(自定义函数:接受连接、创建新fd、加入监控集合等)
                        HeadlerAccept();
                    }
                    else  // 已连接套接字触发事件 → 有数据可读
                    {
                        // 处理数据接收(自定义函数:读取数据、处理业务逻辑等)
                        HeadlerRecv(i);
                    }
                }
            }
        }
        else if (n == 0)  // select超时,无事件触发
        {
            // 打印超时日志,继续下一轮循环
            LOG(INFO, "select no condition\n");
            continue;
        }
        else  // select调用失败(返回-1)
        {
            // 打印致命错误日志
            LOG(FATAL, "select is error\n");
            // 退出程序,SELECT_REEOR应为自定义错误码(注意原代码拼写错误:REEOR → ERROR)
            exit(SELECT_REEOR);
        }
    }
}

七、poll:select 的轻量升级

poll是Linux为了解决select的部分缺陷而设计的多路复用接口,定义在<poll.h>中,它是select的轻量升级版,解决了select的部分问题,但核心性能瓶颈仍未解决。

其中,poll 的核心接口:poll的核心系统调用如下:

c 复制代码
#include <poll.h>

// 描述单个fd的事件和就绪状态
struct pollfd {
    int   fd;         // 要关注的文件描述符,-1表示忽略
    short events;     // 要关注的事件(输入参数),如POLLIN(读)、POLLOUT(写)
    short revents;    // 实际发生的事件(输出参数),由内核设置
};

// 阻塞等待fd就绪,返回就绪的pollfd数量
int poll(
    struct pollfd *fds,   // pollfd数组,存放所有要关注的fd和事件
    nfds_t nfds,          // pollfd数组的长度
    int timeout           // 超时时间,单位ms,-1表示永久阻塞,0表示非阻塞
);

poll支持的事件类型与select对应,核心常用事件:POLLIN(fd可读,对应select的readfds)、POLLOUT(fd可写,对应select的writefds)、POLLERR(fd发生异常,对应select的exceptfds)。

poll 对 select 的核心改进 :poll针对select的设计缺陷,做了三个关键改进,解决了select的部分痛点:

  1. 彻底取消fd数量的硬限制 :poll使用动态的pollfd数组替代select的固定大小fd_set,pollfd数组的长度由用户进程决定,内核没有硬限制,理论上可以支持任意数量的fd,解决了select的fd数量瓶颈;

  2. 分离输入输出参数 :poll的events是输入参数 (告诉内核关注的事件),revents是输出参数 (告诉进程实际发生的事件),两者相互独立。内核只会修改revents,不会修改events,因此pollfd数组无需每次重新初始化,只需在fd状态变化时修改即可,减少了开发成本和CPU开销;

  3. 单个fd的独立管理:pollfd数组中的每个元素对应一个fd,若某个fd暂时不需要关注,只需将其fd字段设为-1即可,无需从数组中删除,简化了fd的管理。

poll 未解决的核心问题 :poll只是select的局部改进 ,并没有解决select的核心性能瓶颈,这也是其无法支撑超高并发的原因:

  1. 每次调用仍需拷贝整个pollfd数组:pollfd数组是用户态数据结构,每次调用poll都需要将整个数组从用户态拷贝到内核态,fd数量越多,拷贝开销越大;

  2. 内核仍需线性遍历所有fd:内核同样没有维护fd的就绪状态,每次调用poll都需要遍历整个pollfd数组,判断每个fd是否就绪,时间复杂度仍为O(N),fd数量越多,遍历开销越大;

  3. 用户态仍需遍历所有pollfd:poll返回后,进程需要遍历整个pollfd数组,通过revents判断哪个fd就绪,无就绪fd的遍历开销仍存在。

poll 与 select 的性能对比:低并发场景(fd<1000):poll和select的性能几乎无差异,poll的开发体验更优;中高并发场景(fd>1000):poll的性能略优于select(无需重新初始化参数),但随着fd数量增加,性能仍会线性下降,核心瓶颈与select一致。

poll 的适用场景 :poll适合fd数量中等(如100~1000)的中低并发场景,其跨平台性仅次于select(支持Linux、Unix、BSD等),比epoll的跨平台性好。

实际举例:

cpp 复制代码
//使用poll也必须搭配辅助数组来管理文件描述符以及事件
pollfd _poll_arr[gnum];

void Loop()
{
    // poll超时时间(单位:毫秒),设置为0表示非阻塞模式(调用后立即返回)
    int timeout = 0;

    // 无限循环,持续处理IO事件
    while (true)
    {
        // 调试用:打印当前poll监控的fd数组状态(自定义函数)
        PrintArr();

        // 调用poll等待IO事件发生
        // 参数说明:
        // _poll_arr:pollfd结构体数组(存放待监控的fd和事件);gnum:数组中监控的fd数量;
        // timeout:超时时间(毫秒),0=立即返回,-1=永久阻塞,>0=指定超时时间
        // 返回值n:>0表示有n个fd触发事件;=0表示超时;<0表示出错
        int n = poll(_poll_arr, gnum, timeout);

        // 处理poll返回结果
        if (n > 0)  // 有fd触发IO事件
        {
            // 记录触发事件的fd数量,用于循环中递减计数,提前退出循环
            int num = n;
            // 遍历所有监控的fd,找到触发事件的fd并处理
            for (int i = 0; num && i < gnum; i++)
            {
                // 检查当前fd是否触发POLLIN事件(读就绪,包括新连接/数据可读)
                if (_poll_arr[i].revents & POLLIN)
                {
                    // 每处理一个事件,计数减1
                    num--;

                    // 区分事件类型:监听套接字触发事件 → 新连接到来
                    if (_poll_arr[i].fd == _listen_socket->Sockfd())
                    {
                        // 打印日志:获取新连接,输出触发的事件总数
                        LOG(INFO, "get %d newlink\n", n);
                        // 处理新连接(自定义函数:接受连接、创建新fd、加入poll监控等)
                        HeadlerAccept();
                    }
                    else  // 已连接套接字触发事件 → 有数据可读
                    {
                        // 打印日志:当前fd读就绪
                        LOG(INFO, "%d is read ready\n", _poll_arr[i].fd);
                        // 处理数据接收(自定义函数:读取数据、处理业务逻辑等)
                        HeadlerRecv(i);
                    }
                }
            }
        }
        else if (n == 0)  // poll超时,无事件触发
        {
            // 打印超时日志,继续下一轮循环
            LOG(INFO, "poll no condition\n");
            continue;
        }
        else  // poll调用失败(返回-1)
        {
            // 打印致命错误日志
            LOG(FATAL, "poll is error\n");
            // 退出程序,POLL_REEOR应为自定义错误码(注意原代码拼写错误:REEOR → ERROR)
            exit(POLL_REEOR);
        }
    }
}

八、epoll:Linux 高并发的核心

epoll是Linux 2.6内核引入的高性能IO多路复用接口 ,定义在<sys/epoll.h>中,它是为了解决select和poll的核心性能瓶颈 而设计的,也是目前Linux下高并发网络编程的标配(如Nginx、Redis、MySQL等中间件均基于epoll实现)。

epoll的设计与select/poll有本质区别 ,它不再是"每次调用都全量处理fd",而是在内核中维护fd的状态信息 ,实现了就绪fd的主动通知,从根本上将时间复杂度从O(N)降低到O(1)。

epoll 的核心设计思想:epoll的核心设计围绕**"内核态维护fd状态,就绪fd主动通知"**展开,解决了select/poll的三大核心问题:

  1. fd注册一次,长期有效 :将fd的注册等待分离,fd只需向epoll注册一次,后续无需重复传递,避免了频繁的用户态-内核态拷贝;

  2. 内核维护fd的就绪状态:epoll在内核中维护了两个核心数据结构,实时跟踪fd的就绪状态,无需每次调用都遍历所有fd;

  3. 只返回就绪的fd :epoll等待完成后,直接向进程返回就绪的fd列表,进程无需遍历所有fd,直接处理就绪fd即可。

epoll 内核的两大核心数据结构 :epoll的高性能,依赖于内核中为每个epoll实例维护的两个核心数据结构,和底层的回调机制,这两个结构和回调机制是epoll设计的精髓,这3部分组成一个epoll模型:

  1. 红黑树(rb_tree)

作用是管理所有被注册的fd,存储fd的文件描述符和关注的事件(如EPOLLIN、EPOLLOUT);

特性是红黑树是一种平衡二叉搜索树,插入、删除、查找的时间复杂度均为O(logN),适合高效管理大量fd;

触发时机是当进程通过epoll_ctl添加/删除/修改fd时,内核操作该红黑树。

  1. 就绪队列(ready list)

作用是存储所有就绪的fd,当fd的事件就绪时(如网卡收到数据),内核将该该红黑树节点,加入就绪队列(此时即在红黑树中,也在就绪队列中);

就绪队列是一个双向链表,支持快速的插入和删除操作,时间复杂度为O(1);

触发时机是当fd的IO事件就绪时,由内核的中断处理程序 触发,将fd加入就绪队列(主动通知)。

  1. 回调函数

作用是**自动完成 "事件就绪" 到 "fd 入队" **:当 fd 对应的底层硬件(如网卡)完成数据准备(IO 事件就绪)时,通过回调函数替代信号通知的方式,直接将该 fd 从红黑树关联到就绪队列,是 epoll "主动通知" 机制的核心;

实现机制是回调函数被预先注册到操作系统的中断处理流程中,无需向进程发送额外信号,而是在硬件触发中断、操作系统处理该中断的过程中,由内核自动调用该回调函数,完成就绪 fd 向就绪队列的插入操作;

触发时机是当 fd 对应的硬件设备(如网卡)产生 IO 就绪中断时,操作系统执行中断处理程序的过程中,自动触发回调函数,将该 fd 插入就绪队列。

epoll 的三个核心接口

epoll将操作拆分为创建实例、管理fd、等待就绪三个独立的系统调用,接口设计清晰,职责分离,这也是其开发体验优于select/poll的原因。

其中,epoll_create:创建epoll实例

c 复制代码
#include <sys/epoll.h>

// 创建epoll实例,返回epoll的文件描述符(epfd)
// size参数在Linux 2.6.8后被忽略,只需传入一个大于0的整数即可
int epoll_create(int size);
int epoll_create1(int flags); // 升级版,flags为EPOLL_CLOEXEC表示进程执行exec时关闭epfd

核心作用:在内核中创建一个epoll实例,为其分配内存,初始化红黑树就绪队列;返回一个专有的文件描述符epfd,后续所有epoll操作都通过该fd完成。

epoll_ctl:管理注册的fd(增/删/改)

c 复制代码
#include <sys/epoll.h>

// 操作epoll实例中的fd,成功返回0,失败返回-1
int epoll_ctl(
    int epfd,                      // epoll实例的fd(由epoll_create返回)
    int op,                        // 操作类型:EPOLL_CTL_ADD/DEL/MOD
    int fd,                        // 要操作的目标fd
    struct epoll_event *event     // 要关注的事件和fd的关联信息
);

// 描述fd的事件和关联数据
struct epoll_event {
    uint32_t events;  // 要关注的事件,如EPOLLIN(读)、EPOLLOUT(写)、EPOLLET(边缘触发)
    epoll_data_t data;// 关联数据,可存储fd、指针等,方便进程识别fd
};

// 关联数据的联合体,按需使用
typedef union epoll_data {
    void        *ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;

核心操作类型:EPOLL_CTL_ADD(将fd注册到epoll实例的红黑树中,指定关注的事件)、EPOLL_CTL_DEL(将fd从epoll实例的红黑树中删除,不再关注其事件)、EPOLL_CTL_MOD(修改已注册fd的关注事件)。

关键特性:fd只需注册一次,后续无需重复操作,除非需要修改事件或删除fd。

工程避坑:注册读事件时,建议同时注册EPOLLRDHUP事件(表示对端关闭连接);若未处理该事件,对端正常调用close关闭连接时,进程无法及时检测,会导致fd泄漏,占用系统资源,此时需在触发EPOLLRDHUP事件时,立即关闭fd并从epoll中删除。

epoll_wait:阻塞等待fd就绪

c 复制代码
#include <sys/epoll.h>

// 阻塞等待epoll实例中的fd就绪,返回就绪的fd数量
int epoll_wait(
    int epfd,                          // epoll实例的fd
    struct epoll_event *events,        // 输出参数,存放就绪的fd和事件
    int maxevents,                     // 最多接收的就绪fd数量,不能超过epoll_create的size
    int timeout                        // 超时时间,单位ms,-1表示永久阻塞,0表示非阻塞
);

核心工作原理:

  1. 进程调用epoll_wait后,若就绪队列为空,进程会睡眠,不占用CPU资源;

  2. 当有fd就绪时,内核将就绪的fd从就绪队列中取出,复制到events数组中;

  3. 内核唤醒进程,epoll_wait返回就绪的fd数量,进程直接遍历events数组即可处理就绪fd;

  4. 若超时时间到仍无fd就绪,返回0。

核心优势:epoll_wait只返回就绪的fd,进程无需遍历所有注册的fd,时间复杂度为O(1)。

工程避坑

① maxevents设置过小会导致大量就绪fd积压,当一次有多个fd就绪时,epoll_wait只能返回maxevents个fd,剩余就绪fd会被积压,导致响应延迟;建议设置为实际注册fd数量的1.2~2倍,或根据系统资源调大(如1024、4096)。

② 若epoll_wait设置了超时时间,返回0(超时)时未做处理,会导致进程无限循环、CPU空转;建议超时返回时,执行轻量操作(如清理超时连接)后再继续循环。

实际代码样例:

cpp 复制代码
// 最大监控的文件描述符(fd)数量,用于控制数组容量
const static int gnum = 128;
// 无效fd的默认标识值(用于标记数组中未使用的fd位置)
const static int gdefault = -1;
// epoll_wait接收就绪事件的数组容量(单次最多接收64个就绪事件)
const static int gbuffersize = 64;

//  区别于管理fd的辅助数组(如存储所有注册fd的数组),
//该数组仅用于接收epoll_wait返回的"就绪fd事件",是内核向用户层传递就绪事件的载体,而非管理所有注册fd的辅助数据
struct epoll_event _epoll_arr[gbuffersize];


//epoll初始化函数,将监听套接字(listen socket)注册到epoll实例中, 仅关注该套接字的读事件(EPOLLIN),为后续IO事件监听做准备
void Init()
{
    // 定义epoll事件结构体,用于描述要注册的fd和关注的事件
    struct epoll_event event;
    // 绑定事件对应的fd:监听套接字的文件描述符
    event.data.fd = _listen_socket->Sockfd();
    // 设置关注的事件类型:EPOLLIN(读事件,对应新连接到来)
    event.events = EPOLLIN;

    /**
     * 调用epoll_ctl向epoll实例中添加监听套接字
     * 参数说明:
     * _epoll_fd:epoll实例的文件描述符;
     * EPOLL_CTL_ADD:操作类型(添加fd到epoll监控);
     * _listen_socket->Sockfd():要监控的监听套接字fd;
     * &event:要监控的事件类型(读事件)
     */
    epoll_ctl(_epoll_fd, EPOLL_CTL_ADD, _listen_socket->Sockfd(), &event);
}

// -------------------------- 主循环函数 --------------------------
// IO多路复用主循环(epoll版本):持续监听IO事件,处理新连接和数据接收
void Loop()
{
    // epoll_wait超时时间(单位:毫秒),设置为1000ms即1秒
    int timeout = 1000;

    // 无限循环,持续处理IO事件
    while (true)
    {
        // 睡眠1秒(业务层频率控制,非epoll必需逻辑,可根据需求移除)
        sleep(1);

        // 调用epoll_wait等待IO事件发生
        // 参数说明:
        // _epoll_fd:epoll实例的文件描述符;_epoll_arr:接收触发事件的数组;
        // gbuffersize:事件数组最大容量;timeout:超时时间(毫秒)
        // 返回值n:>0表示有n个fd触发事件;=0表示超时;<0表示出错
        int n = epoll_wait(_epoll_fd, _epoll_arr, gbuffersize, timeout);

        // 处理epoll_wait返回结果
        if (n > 0)  // 有fd触发IO事件
        {
            // 遍历所有触发的事件,逐个处理
            for (int i = 0; i < n; i++)
            {
                // 区分事件类型:监听套接字触发事件 → 新连接到来
                if (_epoll_arr[i].data.fd == _listen_socket->Sockfd())
                {
                    // 打印日志:获取新连接,输出事件数和超时时间
                    LOG(INFO, "get %d newlink, timeout %d\n", n, timeout);
                    // 处理新连接(自定义函数:接受连接、创建新fd、加入epoll监控等)
                    HeadlerAccept();
                }
                else  // 已连接套接字触发事件 → 有数据可读/其他事件
                {
                    // 处理数据接收(自定义函数:读取数据、处理业务逻辑等)
                    HeadlerRecv(i);
                }
            }
        }
        else if (n == 0)  // epoll_wait超时,无事件触发
        {
            // 打印超时日志,继续下一轮循环
            LOG(INFO, "epoll no condition\n");
            continue;
        }
        else  // epoll_wait调用失败(返回-1)
        {
            // 打印致命错误日志
            LOG(FATAL, "epoll is error\n");
            // 退出程序,EPOLL_WAIT_REEOR应为自定义错误码(注意原代码拼写错误:REEOR → ERROR)
            exit(EPOLL_WAIT_REEOR);
        }
    }
}

epoll 与 select/poll 的核心性能对比

特性 select poll epoll
fd数量限制 有(默认1024)
时间复杂度 O(N) O(N) O(1)
用户态-内核态拷贝 每次调用全量拷贝 每次调用全量拷贝 仅注册时拷贝一次
内核fd遍历方式 线性遍历 线性遍历 就绪队列直接返回
参数类型 输入输出型 输入输出分离 输入输出分离
fd注册方式 每次调用重新设置 无需重新初始化 一次注册,长期有效
高并发性能 差(fd>1000急剧下降) 中(fd>1000性能下降) 优(支持10w+fd)

epoll 的核心高性能原因 :总结来说,epoll的高性能源于三大设计优化

  1. 减少拷贝:fd仅在注册(epoll_ctl)时从用户态拷贝到内核态一次,后续无需重复拷贝,远优于select/poll的每次调用都拷贝;

  2. 避免遍历:内核通过就绪队列维护就绪fd,无需线性遍历所有注册的fd,epoll_wait直接返回就绪fd,时间复杂度O(1);

  3. 主动通知 :fd就绪由内核中断处理程序主动触发,将fd加入就绪队列,而非进程轮询,从根本上提升了响应效率。

相关推荐
FJW0208142 小时前
使用HAProxy实现动静分离
linux·服务器
烟花落o2 小时前
贪吃蛇及相关知识点讲解
c语言·前端·游戏开发·贪吃蛇·编程学习
近津薪荼2 小时前
优选算法——滑动窗口1(单调性)
c++·学习·算法
爱装代码的小瓶子2 小时前
【C++与Linux基础】进程如何打开磁盘文件:从open()到文件描述符的奇妙旅程(更多源码讲解)
linux·开发语言·c++
diediedei2 小时前
嵌入式C++驱动开发
开发语言·c++·算法
RisunJan2 小时前
Linux命令-logout(安全结束当前登录会话)
linux·运维·安全
80530单词突击赢2 小时前
C++容器对比:map与unordered_map全解析
c++
田野追逐星光2 小时前
STL中容器list -- 讲解超详细
开发语言·c++·list
2301_815357702 小时前
如何将SSM项目通过tomcat部署到Linux云服务器上?
linux·服务器·tomcat