- 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 事件。
三者的核心共性(缺一不可):
- 都必须搭配非阻塞 IO使用,否则会退化为阻塞 IO,失去多路复用的意义;
- 核心逻辑都是「阻塞等待事件就绪 → 处理就绪事件 → 再次阻塞等待」;
- 都能实现「一个线程处理海量连接」,是高并发服务器的基础;
- 对就绪事件的判断都是「谁就绪,处理谁」,无就绪事件时,线程阻塞在系统调用,几乎不占用 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 () 返回 -1 且 errno == 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 () 返回 -1 且 errno == 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 | EPOLLHUP是epoll 的默认事件 ,即使不手动注册,epoll 也会返回这些事件,但强烈建议手动注册,代码可读性更高,无任何副作用。
注意5:必须搭配「EPOLLONESHOT」使用,解决线程安全问题
epoll 的 ET 模式下,若一个 fd 的就绪事件被多个线程同时处理(比如线程池的多个线程同时处理同一个 conn fd 的读事件),会导致「数据乱序、重复处理、内存错乱」等线程安全问题。
注册 fd 事件时,加上 EPOLLONESHOT 标志,该标志的作用是:
- 一个 fd 的就绪事件,只会被一个线程处理,其他线程不会收到该 fd 的事件通知;
- 线程处理完该 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 的性能优势。