select/poll/epoll

  • select/poll/epoll 共性:都是Linux IO 多路复用接口 ,核心能力是「一个线程监听多个 socket fd 的 IO 事件」,解决 BIO 单线程只能处理一个连接的痛点,支撑高并发;三者都基于「非阻塞 IO」工作。
  • select/poll/epoll 核心差异:底层实现、效率、fd 数量限制、内核态与用户态交互方式 完全不同,epoll是后两者的终极升级版,是 Linux 高并发服务器的唯一生产级选择,select/poll 仅存在于理论 / 旧项目中;
  • epoll LT/ET 核心区别:是否对「已就绪但未处理完」的 IO 事件重复通知。LT 是水平触发(默认),重复通知;ET 是边缘触发,仅通知一次;ET 的效率是 LT 的数倍,无冗余开销;
  • 高并发服务器选型:必用 epoll + ET 边缘触发 + 非阻塞 IO(O_NONBLOCK) + EPOLLONESHOT 组合,这是工业级标配(muduo/nginx/Redis/Netty);
  • ET 的核心要求:编程门槛高,必须保证「一次性处理完 fd 的所有就绪数据」,否则会丢数据,但只要规避坑点,ET 的性能碾压 LT。

一、select /poll/epoll 的核心区别

三者都是Linux 内核提供的 IO 多路复用系统调用,核心目标完全一致:

一个线程 ,可以同时监听多个 socket 文件描述符(fd) 的 IO 事件(连接、读就绪、写就绪),当任意一个 fd 的事件就绪时,系统调用返回,线程再处理对应的 fd 事件。

三者的核心共性(缺一不可):

  1. 都必须搭配非阻塞 IO使用,否则会退化为阻塞 IO,失去多路复用的意义;
  2. 核心逻辑都是「阻塞等待事件就绪 → 处理就绪事件 → 再次阻塞等待」;
  3. 都能实现「一个线程处理海量连接」,是高并发服务器的基础;
  4. 对就绪事件的判断都是「谁就绪,处理谁」,无就绪事件时,线程阻塞在系统调用,几乎不占用 CPU。

三者的性能分水岭 :当监听的fd数量少、就绪fd少时,三者性能差距不大;当监听的fd数量达到万级/十万级(高并发)、就绪fd频繁触发时,select/poll 的效率断崖式下跌,epoll 的效率几乎不受影响,这是三者最核心的差异。

区别 1:底层实现与事件查询方式不同(效率的根本差异)
  • select :基于位图数组 实现,内核维护一个位图,每一位对应一个 fd,监听的 fd 数量受限于位图的大小;select 的事件查询是「全量轮询 」------ 每次调用select(),内核会遍历所有监听的 fd,判断是否有事件就绪,直到遍历完所有 fd 才返回。
  • poll :基于链表 实现,内核维护一个pollfd结构体链表,无 fd 数量的硬限制;但 poll 的事件查询也是「全量轮询 」------ 和 select 完全一样,每次调用poll(),内核会遍历所有监听的 fd 链表,判断事件是否就绪,遍历完成后返回。
  • epoll :基于红黑树 + 就绪事件链表 实现,是事件驱动模型 ,无全量轮询!内核维护两个核心结构:① 红黑树:存储所有注册的监听 fd (增删改查效率 O (logN));② 就绪链表:存储已经就绪的 fd 。epoll 的事件查询是「按需返回 」------ 内核只需要把就绪链表中的 fd 拷贝到用户态即可,无需遍历所有监听的 fd,就绪 fd 越少,效率越高。

核心结论:select/poll 是「轮询模型 」,epoll 是「事件驱动模型」,这是本质差异!高并发下,轮询的开销是致命的,事件驱动是唯一解。

区别 2:监听的文件描述符(fd)数量限制不同
  • select有硬上限 ,Linux 默认是1024(通过FD_SETSIZE宏定义),表示 select 最多只能监听 1024 个 fd;虽然可以修改内核参数扩容,但会导致轮询的开销更大,性能更差,生产环境不可行
  • poll无硬上限 ,因为基于链表存储 fd,理论上可以监听无限个 fd;但 fd 越多,链表越长,内核轮询的开销越大,fd 达到万级后效率暴跌
  • epoll无硬上限,红黑树的存储能力可以支撑百万级的 fd 监听;且 fd 数量的增加,不会导致 epoll 的效率显著下降,因为 epoll 不轮询。
区别 3:内核态 → 用户态的数据拷贝方式不同(性能关键差异)

IO 多路复用的核心开销之一,就是「内核将就绪的 fd 信息,拷贝到用户态供线程处理」,三者的拷贝方式天差地别:

  • select/poll每次返回都要拷贝「所有监听的 fd」 ,且是冗余拷贝------ 内核无法知道哪些 fd 就绪,只能把所有监听的 fd 状态全部拷贝到用户态,用户态再自己遍历判断哪些 fd 就绪,拷贝开销随 fd 数量增加而线性增长。
  • epoll仅拷贝「就绪的 fd」 ,且是零拷贝优化(mmap 共享内存) ------epoll 通过mmap让内核态和用户态共享一块内存,就绪 fd 的信息直接写入这块内存,无需内核到用户态的拷贝,拷贝开销几乎为 0,且只拷贝有用的就绪 fd,效率极致。
区别 4:效率与时间复杂度不同
  • select/poll :时间复杂度为 O(N),N 是监听的总 fd 数量;fd 越多,轮询 + 拷贝的开销越大,效率越低,高并发下完全不可用。
  • epoll :时间复杂度为 O(1),只和「就绪的 fd 数量」相关,和「总监听的 fd 数量」无关;即使监听 100 万个 fd,只要只有 100 个就绪,epoll 只处理这 100 个,效率几乎不变。
区别 5:触发方式支持不同
  • select/poll仅支持水平触发(LT),不支持边缘触发(ET),功能单一;
  • epoll同时支持水平触发(LT,默认) + 边缘触发(ET),ET 是高性能的核心,也是生产级的唯一选择。
区别 6:系统调用的使用方式不同
  • select/poll :是「一次性注册 」------ 每次调用select/poll,都需要重新传入所有要监听的 fd 集合,内核每次都要重新解析、重新注册,额外开销大;
  • epoll :是「永久注册 」------ 通过epoll_ctl注册 / 修改 / 删除 fd 的监听事件,注册一次后,fd 会永久保存在内核的红黑树中,后续只需调用epoll_wait等待事件即可,无需重复注册,开销极小。

select:位图、1024 上限、全量轮询、O (N)、仅 LT、低效;

poll:链表、无上限、全量轮询、O (N)、仅 LT、低效;

epoll:红黑树 + 就绪链表、无上限、事件驱动、O (1)、LT+ET、mmap 零拷贝、高性能生产级唯一选择

二、epoll 的两种触发模式:水平触发(LT) & 边缘触发(ET)

当一个 fd 的 IO 事件就绪后(比如读就绪有数据、写就绪可写),如果线程没有一次性处理完该事件的所有数据,epoll 是否会对这个 fd 的「同一种就绪事件」进行重复通知?

  • LT 水平触发(默认模式)会重复通知 → fd 只要处于「就绪状态」,每次调用epoll_wait()都会返回这个 fd 的就绪事件,直到线程把该 fd 的就绪事件完全处理完毕
  • ET 边缘触发仅通知一次 → fd 的状态从「未就绪 → 就绪」时,epoll 只会通知一次 该 fd 的就绪事件,后续即使 fd 依然处于就绪状态(比如还有数据没读完),epoll_wait()不会再返回该事件,直到 fd 的状态再次发生变化(比如又有新数据到来,状态从就绪→未就绪→就绪)。

形象比喻(助记):

  • LT:像水龙头漏水,只要水龙头没关紧(fd 就绪),就一直提醒你漏水(重复通知),直到你关紧水龙头(处理完所有数据);
  • ET:像门铃,按一次响一次(状态变化通知一次),即使你没开门(没处理完数据),门铃也不会再响,直到下次有人再按(fd 状态再次变化)。

epoll 的 ET 边缘触发,必须搭配「非阻塞 IO(O_NONBLOCK)」使用!

  • LT 模式:可以搭配阻塞 IO,也可以搭配非阻塞 IO(推荐非阻塞);
  • ET 模式:强制必须搭配非阻塞 IO ,这是铁律!如果 ET 模式下用阻塞 IO,线程会在read/write时卡死,直接导致服务器瘫痪。
区别 1:事件通知的次数不同(本质差异)
  • LT :就绪事件的「持续性通知 」。比如:客户端给 conn fd 发送了 1024 字节数据,fd 变为读就绪,epoll 返回读事件;线程只读取了 512 字节,还剩 512 字节未读,此时 fd 依然是读就绪状态 → 下一次调用 epoll_wait (),会再次返回这个 fd 的读事件,直到线程把 1024 字节全部读完,fd 变为未就绪状态。
  • ET :就绪事件的「一次性通知 」。同样的场景:客户端发送 1024 字节,fd 从无数据→有数据(状态变化),epoll 返回一次读事件;线程只读取了 512 字节,还剩 512 字节未读,此时 fd 依然就绪 → 后续的 epoll_wait () 不会再返回该 fd 的读事件 ,线程永远不会知道还有数据没读,这些数据会被永久丢弃!只有当客户端再次发送新数据,fd 的状态再次变化,epoll 才会再次通知读事件。
区别 2:编程实现的复杂度不同
  • LT :编程简单,门槛极低,不容易出错。因为 epoll 会重复通知就绪事件,线程可以「分批处理」fd 的数据,不用一次性读完 / 写完,即使漏处理了数据,epoll 下次还会通知,不会丢数据;这也是 LT 作为 epoll 默认模式的原因。
  • ET :编程复杂,门槛高,极易踩坑丢数据。因为 epoll 只通知一次,线程必须在「本次通知中,一次性处理完该 fd 的所有就绪数据」(读事件要读到无数据,写事件要写到数据发完),否则会导致数据丢失;但这也是 ET 高性能的核心 ------ 无冗余通知,无无效的 epoll_wait 返回。
区别 3:性能与开销不同(核心选型依据,高并发的关键)

这是生产级服务器选择 ET 的唯一原因,也是 ET 碾压 LT 的核心优势,优先级最高!

  • LT :存在「大量冗余的事件通知和 epoll_wait 返回 」。比如一个 fd 有持续的数据流,LT 会在每次 epoll_wait 都返回该 fd 的读事件,即使线程已经在处理这个 fd 的数据;这些冗余的通知会导致:① epoll_wait 的返回次数增多,内核态与用户态的交互开销增大;② 线程需要重复判断同一个 fd 的状态,CPU 开销增大;高并发下,这些开销会被无限放大,成为性能瓶颈
  • ET无任何冗余通知,开销极致最小化 。每个 fd 的就绪事件,epoll 只通知一次,线程处理完后,epoll 不会再返回该事件,直到 fd 状态再次变化;epoll_wait 的返回次数极少,CPU 的时间全部用在「有效处理数据」上,高并发下,ET 的性能是 LT 的数倍甚至十倍以上
区别 4:对数据处理的要求不同
  • LT:对数据处理无强制要求,「能处理多少就处理多少」,分批处理也可以,容错性高;
  • ET :对数据处理有强制要求 ,必须「一次性处理完 fd 的所有就绪数据」,这是 ET 的核心规则,也是避免丢数据的唯一方式。

LT:水平触发、默认模式、重复通知、编程简单、无丢数据风险、高并发下冗余开销大、效率低;

ET:边缘触发、非默认、仅通知一次、编程复杂、需一次性处理完数据、无冗余开销、效率极致、高并发唯一选择。

三、高并发服务器项目里,用的是哪一种?为什么?

生产级高并发服务器,必用 → epoll + ET 边缘触发 + 非阻塞 IO + EPOLLONESHOT

这是所有工业级高性能服务器的标配组合 (muduo、nginx、Redis、Netty),没有任何例外,绝对不会用 LT 水平触发,更不会用 select/poll

原因 1:ET 模式无冗余事件通知,CPU 开销极致最小化(高并发的命脉)
  • 如果用 LT:epoll 会对每个就绪的 fd 重复通知,epoll_wait 的返回次数会暴增,内核需要频繁拷贝就绪 fd、线程需要频繁判断 fd 状态,CPU 会被这些冗余操作打满,服务器的并发能力会被严重限制;
  • 如果用 ET:每个 fd 的就绪事件只通知一次,epoll_wait 的返回次数极少,CPU 的时间全部用在「处理客户端数据、执行业务逻辑」这些有效工作上,CPU 利用率接近 100%,这是支撑百万级并发的核心保障。
原因 2:ET 模式完美契合「非阻塞 IO」,彻底杜绝线程阻塞

服务器中,所有 fd 都被设置为O_NONBLOCK非阻塞模式,而 ET 和非阻塞 IO 是「天作之合」:

  • 非阻塞 IO 保证:调用read()/write()时,无数据可读 / 无空间可写,会立即返回EAGAIN/EWOULDBLOCK错误,不会阻塞线程;
  • ET 模式要求:必须一次性处理完 fd 的所有数据,而非阻塞 IO 正好可以实现这一点 ------ 线程循环调用read()直到返回EAGAIN,就代表数据已经读完;循环调用write()直到返回EAGAIN,就代表数据已经写完。二者结合,既保证了数据处理的完整性,又保证了线程不会阻塞,完美适配 Reactor 模型的「事件驱动、异步非阻塞」核心思想。
原因 3:ET 模式搭配「EPOLLONESHOT」,彻底解决线程安全问题,契合 One Thread One Loop 架构
  • epoll 的EPOLLONESHOT事件标志:作用是「一个 fd 的就绪事件,只会被通知给一个线程」,且通知一次后,该 fd 的事件监听会被临时禁用,直到线程处理完数据后,手动重新启用该 fd 的监听;
  • 搭配 ET 模式使用:同一个客户端的 conn fd,永远只会被分配到「同一个从 Reactor 工作线程」,该线程处理完 fd 的所有数据后,再重新注册事件;这完美契合「One Thread One Loop」的核心规则 ------一个 fd 的所有操作,都在同一个线程中完成,无任何线程竞争、无锁、无竞态条件,线程安全得到极致保障。
原因 4:ET 模式的效率优势,是线性的、可扩展的

ET 的性能不受「监听 fd 数量」和「就绪 fd 数量」的显著影响,服务器的并发量从万级提升到百万级,ET 的效率几乎不变;而 LT 的效率会随并发量的提升而断崖式下跌,无法支撑高并发场景。

四、ET 边缘触发模式下,必须注意的问题

注意1:ET 模式下,所有的 socket fd 必须设置为「非阻塞 IO(O_NONBLOCK)」

如果 ET 模式下 fd 是阻塞的:线程调用read()读取数据,当数据读完后,read()会阻塞等待新数据到来;但 ET 模式下,epoll 不会再通知该 fd 的读事件,线程会永久阻塞在这个 read () 调用上,该线程对应的所有客户端都会被卡死,最终导致线程池耗尽,服务器瘫痪。

对所有的 fd(listen fd + 客户端 conn fd),在创建后立即设置非阻塞属性:

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

// 设置fd为非阻塞模式
void setNonBlocking(int fd) {
    int flags = fcntl(fd, F_GETFL);
    flags |= O_NONBLOCK;
    fcntl(fd, F_SETFL, flags);
}
注意2:读事件处理:必须「循环 read () 直到返回 EAGAIN/EWOULDBLOCK」

ET 模式下,epoll 对读事件只通知一次,若线程只读取了部分数据,剩余数据会留在 fd 的内核缓冲区中,epoll 不会再通知,这些数据会被永久丢弃,客户端会出现「发送的数据服务端收不全」的问题。

处理读事件时,循环调用非阻塞的 read () ,直到 read () 返回 -1errno == EAGAIN || errno == EWOULDBLOCK,表示「fd 的内核缓冲区已经没有数据可读,本次读事件处理完毕」:

cpp 复制代码
void handleRead(int fd) {
    char buf[4096];
    while (true) {
        ssize_t n = read(fd, buf, sizeof(buf));
        if (n > 0) {
            // 读到数据,处理业务逻辑(解析协议、存入缓冲区等)
            processData(buf, n);
        } else if (n == 0) {
            // n=0 表示客户端主动关闭连接,关闭fd,释放资源
            close(fd);
            break;
        } else { // n == -1
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 核心:无数据可读,本次读事件处理完毕,退出循环
                break;
            } else {
                // 其他错误(如连接重置),关闭fd,释放资源
                close(fd);
                break;
            }
        }
    }
}
注意3:写事件处理:必须「循环 write () 直到数据写完 或 返回 EAGAIN」

客户端的 conn fd 的内核发送缓冲区是有大小限制的,当要发送的数据量超过缓冲区大小,write()只能写入部分数据;若线程只写了一次就退出,剩余数据会留在用户态缓冲区中,epoll 不会再通知写事件,这些数据会被丢弃,客户端会出现「服务端返回的响应不完整」的问题。

处理写事件时,循环调用非阻塞的 write () ,直到所有数据都写完,或者 write () 返回 -1errno == EAGAIN,表示「发送缓冲区已满,下次写就绪时再继续发送」:

cpp 复制代码
void handleWrite(int fd, const char* data, size_t len) {
    size_t nLeft = len;
    const char* p = data;
    while (nLeft > 0) {
        ssize_t n = write(fd, p, nLeft);
        if (n > 0) {
            p += n;
            nLeft -= n;
        } else if (n == -1) {
            if (errno == EAGAIN) {
                // 发送缓冲区已满,退出循环,下次写事件再继续发送
                break;
            } else {
                // 其他错误,关闭fd
                close(fd);
                break;
            }
        }
    }
    // nLeft == 0 表示数据全部发送完毕
}
注意4:必须为 fd 注册「EPOLLERR | EPOLLHUP」异常事件,且必须处理

这是 ET 模式下最容易被忽略的致命坑 ,很多开发者只注册EPOLLIN(读)和EPOLLOUT(写)事件,忽略了异常事件,导致 fd 出现错误时,线程无法感知,最终内存泄漏 / 连接卡死。

当客户端的连接出现异常(如网络断开、连接重置、客户端崩溃),内核会触发 fd 的EPOLLERR(错误)或EPOLLHUP(挂起)事件,若未注册这些事件,epoll 不会通知,线程永远不知道 fd 已经失效,会一直持有该 fd,造成内存泄漏;若注册了但未处理,fd 的错误会扩散,导致其他连接异常。

调用epoll_ctl注册 fd 事件时,必须加上 EPOLLERR | EPOLLHUP,且在事件处理中,优先处理异常事件:

cpp 复制代码
// 注册fd的读事件+异常事件(生产级标准写法)
epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLET | EPOLLERR | EPOLLHUP; // 必加后两个
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev);

// 事件处理时的判断
void handleEvent(int fd, uint32_t events) {
    if (events & (EPOLLERR | EPOLLHUP)) {
        // 优先处理异常事件,关闭fd,释放资源
        close(fd);
        return;
    }
    if (events & EPOLLIN) {
        handleRead(fd);
    }
    if (events & EPOLLOUT) {
        handleWrite(fd, data, len);
    }
}

补充:EPOLLERR | EPOLLHUPepoll 的默认事件 ,即使不手动注册,epoll 也会返回这些事件,但强烈建议手动注册,代码可读性更高,无任何副作用。

注意5:必须搭配「EPOLLONESHOT」使用,解决线程安全问题

epoll 的 ET 模式下,若一个 fd 的就绪事件被多个线程同时处理(比如线程池的多个线程同时处理同一个 conn fd 的读事件),会导致「数据乱序、重复处理、内存错乱」等线程安全问题。

注册 fd 事件时,加上 EPOLLONESHOT 标志,该标志的作用是:

  1. 一个 fd 的就绪事件,只会被一个线程处理,其他线程不会收到该 fd 的事件通知;
  2. 线程处理完该 fd 的事件后,必须手动调用epoll_ctl重新注册该 fd 的事件,否则后续的就绪事件不会被通知。
cpp 复制代码
// 生产级终极注册写法:ET + EPOLLONESHOT + EPOLLERR + EPOLLHUP
ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT | EPOLLERR | EPOLLHUP;
注意6:避免频繁的 epoll_ctl 修改事件,减少内核开销

ET 模式下,若 fd 的写事件频繁触发,会导致线程频繁调用epoll_ctl添加 / 删除写事件,增加内核开销。

写事件的注册遵循「按需注册 」原则:只有当有数据要发送时,才为 fd 注册EPOLLOUT写事件;数据发送完毕后,立即删除写事件,避免 epoll 重复返回写就绪事件。

注意7:listen fd 建议用 LT 模式,conn fd 用 ET 模式

这是生产级的经典优化,无性能损失,且能避免 listen fd 的连接丢失问题:

  • listen fd:负责接收客户端连接,用 LT 模式即可,因为accept()的调用效率极高,即使 epoll 重复通知连接事件,开销也可以忽略不计;且 LT 模式能保证「不会漏掉任何连接请求」,容错性更高。
  • conn fd:负责处理客户端的读写事件,必须用 ET 模式,这是高性能的核心。
注意8:ET 模式下,不要在循环中频繁调用 epoll_wait ()

ET 模式的核心是「事件驱动」,线程应阻塞在epoll_wait()等待事件就绪,而不是主动轮询;频繁轮询会导致 CPU 利用率飙升,失去 ET 的性能优势。

相关推荐
小蜗的房子2 小时前
Oracle 19c RAC重建AWR步骤详解
linux·运维·数据库·sql·oracle·操作系统·oracle rac
n***33352 小时前
C++跨平台开发:挑战、策略与未来
开发语言·c++
D_evil__2 小时前
【Effective Modern C++】第一章 类型推导:1.理解模板类型推导
c++
久绊A2 小时前
RAID10 单盘失效降级处理实操
linux·运维·服务器
小白学大数据2 小时前
随机间隔在 Python 爬虫中的应用实践
开发语言·c++·爬虫·python
小尧嵌入式2 小时前
【基础学习七十】ffmpeg命令
c++·stm32·嵌入式硬件·ffmpeg
xlp666hub2 小时前
Linux 设备模型学习笔记(2)之 kobject
linux·面试
松涛和鸣2 小时前
54、DS18B20单线数字温度采集
linux·服务器·c语言·开发语言·数据库
Vallelonga2 小时前
ELF 文件和 Linux 内核镜像文件
linux·经验分享