一、select/poll/epoll总结回顾
1.select/poll vs epoll 核心区别(面试必背)
| 维度 | select/poll | epoll |
|---|---|---|
| 描述符拷贝次数 | 每次循环都要把所有描述符 + 事件拷贝到内核 | 每个描述符仅拷贝一次(添加到内核红黑树后无需重复传) |
| 内核事件检测 | 轮询所有描述符,时间复杂度 O (n) | 注册回调函数,有事件时主动触发,时间复杂度 O (1) |
| 用户事件处理 | 需遍历所有描述符找就绪的,O (n) | 直接返回就绪描述符列表,O (1) |
| 描述符数量限制 | select 默认上限 1024,poll 无硬限但效率低 | 无实际限制(仅受系统资源约束) |
2.epoll 的 3 个核心函数(面试必答)
epoll_create:在内核中创建红黑树结构(用于存储所有监听的描述符),返回 epoll 句柄;epoll_ctl:对内核红黑树进行添加 / 删除 / 修改描述符的操作(这一步完成描述符的 "一次拷贝");epoll_wait:阻塞等待事件,直接返回就绪的描述符列表(无需遍历)。
3.epoll 的两种触发模式(面试高频考点)
-
LT(水平触发,默认):
- 只要描述符有未处理的就绪数据,
epoll_wait就会重复通知; - 优点:编程简单,不易丢数据;
- 缺点:可能重复触发,效率略低。
- 只要描述符有未处理的就绪数据,
-
ET(边缘触发):
- 仅在描述符 "从无数据→有数据" 的状态变化瞬间通知一次;
- 优点:触发次数少,效率更高;
- 缺点:需一次性读完所有数据(否则会丢数据),编程复杂度高。
4.面试总结话术(精简版)
select/poll 是早期 IO 复用方案,核心问题是 "重复拷贝 + 遍历低效",只适合低并发;epoll 通过 "内核红黑树存描述符 + 回调函数检测事件 + 直接返回就绪列表",实现了 "一次拷贝 + O (1) 事件处理",是高并发场景的首选,同时支持 LT/ET 两种触发模式,可根据需求选择。
二、epoll-LT(水平触发)模式核心知识点
1.epoll LT 水平触发(默认模式) 核心定义 + 特性【面试必背】
- LT(Level Trigger)水平触发是 epoll 的默认触发模式 ,无需额外配置(
ev.events = EPOLLIN就是 LT)。 - 核心核心规则:只要文件描述符的内核接收缓冲区中有数据可以读取,epoll 就会报告读事件发生;数据就绪,如果没有处理完就继续提醒,直到把缓冲区的数据彻底读完为止。
- 触发本质:检测的是「缓冲区的数据状态」,只要是有数据的就绪状态,就持续触发,不会停止。
2.LT 模式下 epoll_wait 返回次数 & 原因
✅ 1. 场景 1:代码中 recv(c, buff, 1, 0) (一次只读 1 字节),客户端发hello(5 字节)

- epoll_wait 返回次数:5 次
- 原因:缓冲区有 5 个字节未读,每次只读走 1 个,缓冲区始终有剩余数据,LT 持续触发读事件,每读 1 个字节就触发 1 次 epoll_wait,直到 5 个字节全部读完,共返回 5 次 → 对应打印 5 次 recv+5 次 ok。
3. 通用结论(LT 模式)
epoll_wait的返回次数 = 内核缓冲区中「未读完数据的次数」,有多少批次未读数据、就触发多少次,直到缓冲区读空。
4.LT 模式 优缺点(面试对比题必背)
✅ 优点:
- 编程简单,无数据丢失风险,新手友好;
- 是 epoll 默认模式,兼容性最好;
- 不用处理非阻塞 IO,代码逻辑简洁。
❌ 缺点:
- 触发次数多,epoll_wait 频繁返回,CPU 占用略高;
- 易出现「重复响应」的现象(多打印 OK),但属于特性不是 bug。
三、ET模式(边缘触发)
1.ET模式的开启

cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h> // fcntl函数头文件
#include <errno.h> // 错误码头文件
#define MAXFD 10 // 最大监听文件描述符数量
/**************************************************************************
函数名:socket_init
功能:TCP服务器初始化(创建套接字+绑定+监听)
返回值:成功返回监听套接字描述符,失败返回-1
**************************************************************************/
int socket_init()
{
// 创建TCP套接字(IPv4+字节流)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1)
{
perror("socket err");
return -1;
}
// 初始化服务器地址结构体
struct sockaddr_in saddr;
memset(&saddr, 0, sizeof(saddr));
saddr.sin_family = AF_INET; // IPv4协议
saddr.sin_port = htons(6000); // 绑定端口6000(主机序转网络序)
saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 本地回环地址
// 绑定套接字与地址
int res = bind(sockfd, (struct sockaddr*)&saddr, sizeof(saddr));
if (res == -1)
{
perror("bind err");
close(sockfd);
return -1;
}
// 开启监听(半连接队列长度5)
if (listen(sockfd, 5) == -1)
{
perror("listen err");
close(sockfd);
return -1;
}
return sockfd;
}
/**************************************************************************
函数名:setnonblock
功能:将文件描述符设置为非阻塞模式
参数:fd - 要设置的文件描述符
**************************************************************************/
void setnonblock(int fd)
{
// 获取fd当前的状态标志
int oldfl = fcntl(fd, F_GETFL);
// 添加非阻塞标志(O_NONBLOCK)
int newfl = oldfl | O_NONBLOCK;
// 设置新的状态标志
if (fcntl(fd, F_SETFL, newfl) == -1)
{
printf("fcntl err\n");
}
}
/**************************************************************************
函数名:epoll_add
功能:将文件描述符添加到epoll内核事件表(红黑树),并开启ET+非阻塞
参数:epfd - epoll句柄;fd - 要添加的描述符
**************************************************************************/
void epoll_add(int epfd, int fd)
{
struct epoll_event ev;
ev.data.fd = fd; // 关联要监听的描述符
ev.events = EPOLLIN | EPOLLET; // 监听读事件 + 开启ET边缘触发
setnonblock(fd); // 将fd设置为非阻塞(ET模式必须)
// 调用epoll_ctl添加fd到epoll内核事件表
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1)
{
printf("epoll ctl add err\n");
}
}
/**************************************************************************
函数名:epoll_del
功能:从epoll内核事件表中删除指定描述符
参数:epfd - epoll句柄;fd - 要删除的描述符
**************************************************************************/
void epoll_del(int epfd, int fd)
{
// 调用epoll_ctl删除指定fd
if (epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL) == -1)
{
printf("epoll ctl del err\n");
}
}
/**************************************************************************
函数名:accept_client
功能:处理新客户端连接(accept+添加到epoll)
参数:sockfd - 监听套接字;epfd - epoll句柄
**************************************************************************/
void accept_client(int sockfd, int epfd)
{
// 接收新连接(忽略客户端地址)
int c = accept(sockfd, NULL, NULL);
if (c < 0)
{
return;
}
printf("accept c=%d\n", c);
epoll_add(epfd, c); // 将新客户端fd添加到epoll(自动开启ET+非阻塞)
}
/**************************************************************************
函数名:recv_data
功能:处理客户端数据(ET模式+非阻塞IO,读空缓冲区)
参数:c - 客户端描述符;epfd - epoll句柄
**************************************************************************/
void recv_data(int c, int epfd)
{
// while循环读空缓冲区(ET模式必须)
while (1)
{
char buff[128] = {0};
// 非阻塞读数据(一次读1字节)
int n = recv(c, buff, 1, 0);
// 处理recv返回值
if (n == -1)
{
// 情况1:缓冲区无数据(EAGAIN/EWOULDBLOCK是"暂时无数据"的错误码)
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
send(c, "ok", 2, 0); // 数据读空后回复ok
}
// 情况2:真的读失败
else
{
printf("recv err\n");
}
break; // 跳出循环(ET模式仅触发一次,读完即止)
}
// 客户端断开连接
else if (n == 0)
{
epoll_del(epfd, c); // 从epoll中删除
close(c); // 关闭连接
printf("close...\n");
break;
}
// 成功读取数据
else
{
printf("%s\n", buff); // 打印读取到的数据
}
}
}
/**************************************************************************
主函数:基于epoll-ET模式的高并发TCP服务器
**************************************************************************/
int main()
{
// 初始化服务器,获取监听套接字
int sockfd = socket_init();
if (sockfd == -1)
{
exit(1);
}
// 创建epoll内核事件表(红黑树)
int epfd = epoll_create(MAXFD);
if (epfd == -1)
{
perror("epoll_create err");
close(sockfd);
exit(1);
}
// 将监听套接字添加到epoll(开启ET+非阻塞)
epoll_add(epfd, sockfd);
// 定义epoll_wait的就绪事件数组
struct epoll_event evs[MAXFD];
while (1)
{
// 调用epoll_wait等待事件,超时时间5000ms(5秒)
int n = epoll_wait(epfd, evs, MAXFD, 5000);
if (n == -1) // epoll_wait调用失败
{
printf("epoll err\n");
continue;
}
else if (n == 0) // 超时(5秒内无事件)
{
printf("time out\n");
continue;
}
// 遍历所有就绪事件
for (int i = 0; i < n; i++)
{
// 判断事件类型为读事件
if (evs[i].events & EPOLLIN)
{
// 监听套接字就绪 → 处理新连接
if (evs[i].data.fd == sockfd)
{
accept_client(sockfd, epfd);
}
// 客户端套接字就绪 → 处理数据
else
{
recv_data(evs[i].data.fd, epfd);
}
}
}
}
// 释放资源(实际while(1)不会执行到这里)
close(epfd);
close(sockfd);
exit(0);
}
这是epoll-ET 模式 + 非阻塞 IO 的高并发服务器标准实现,解决了 ET 模式下 "数据丢失" 的问题,核心设计是面试高频考点:
1. 关键改造点(针对 ET 模式的必做操作)
- 开启 ET 触发 :
ev.events = EPOLLIN | EPOLLET(ET 模式是高并发核心); - 设置非阻塞 IO :
setnonblock(fd)(ET 模式必须,避免 while 循环阻塞); - while 循环读空缓冲区 :
recv_data中用while(1)读数据,直到缓冲区为空(ET 模式仅触发一次,必须读空)。
2. 核心函数解析(面试必答)
(1)setnonblock函数
- 作用:将文件描述符从阻塞模式 改为非阻塞模式;
- 原理:通过
fcntl函数获取 / 设置 fd 的状态标志,添加O_NONBLOCK标志; - 必要性:ET 模式下,
recv若无数据会返回-1,若为阻塞模式会一直卡着,非阻塞模式则会返回错误码EAGAIN/EWOULDBLOCK(表示 "暂时无数据")。
(2)epoll_add函数
- 同时完成 3 件事:
- 关联描述符到 epoll 事件结构体;
- 开启 ET 边缘触发;
- 设置 fd 为非阻塞;
- 这是 ET 模式服务器的 "标准配置",三者缺一不可。
(3)recv_data函数(ET 模式核心)
while(1)循环:强制读空内核缓冲区,避免数据积压 / 丢失;recv返回-1的处理:errno == EAGAIN/EWOULDBLOCK:缓冲区已空,跳出循环并回复 "ok";- 其他错误:真的读失败,打印错误;
recv返回0:客户端断开,删除 fd 并关闭连接;recv返回>0:打印读取到的数据。
✅ 面试复习笔记(ET 模式核心考点)
1. ET 模式的 "三个必须"(面试必背)
- 必须开启
EPOLLET标志; - 必须将 fd 设置为非阻塞;
- 必须用
while循环读空缓冲区。
2. ET 模式的优势(面试对比题)
- 触发次数极少:客户端发一次数据,
epoll_wait仅返回 1 次; - CPU 占用极低:避免 LT 模式的重复触发,适合万级以上高并发场景(如 Nginx)。
3. 常见面试题
-
Q:ET 模式为什么要设置非阻塞? A:ET 模式下,
recv若无数据会返回-1,若为阻塞模式会一直阻塞在recv;非阻塞模式下会返回EAGAIN/EWOULDBLOCK,可以正常跳出循环。 -
**Q:ET 模式为什么要用 while 循环读数据?**A:ET 模式仅触发一次读事件,若不读空缓冲区,剩余数据会积压在缓冲区,直到新数据到来才会被读取,导致数据丢失。
4. 运行效果

运行结果解析
从日志能看到:
- 新连接处理 :客户端连接后,服务器打印
accept c=5(成功接收连接); - 数据读取(ET 模式特性) :
- 客户端输入
hello,服务器一次性读空数据,打印h/e/l/l/o(recv一次读1个字符的效果); - 客户端后续输入
abc/aaa/bb/cc,服务器每次都读空数据并回复ok;
- 客户端输入
- 客户端断开 :输入
end后,服务器打印close...(成功处理断开); - 超时逻辑 :无数据时,每 5 秒打印
time out(epoll_wait超时正常)。
为什么这是 "正确的 ET 模式效果"
- 仅触发一次
epoll_wait:客户端每次发数据,服务器只触发 1 次epoll_wait,所有读操作都在这次触发的while循环里完成(比如hello的 5 个字符,在一次epoll_wait里读了 5 次); - 无重复触发 :对比之前 LT 模式的 "多次
epoll_wait+ 多次ok",现在 ET 模式每次数据仅回复 1 次ok,效率极高; - 无数据丢失 :
while循环+非阻塞IO保证了每次数据都被读空,客户端发的hello/abc等数据都完整打印。
四、总结
一、select/poll vs epoll 核心区别(用 "快递员" 类比)
| 维度 | select/poll(低效快递员) | epoll(高效快递员) |
|---|---|---|
| 描述符拷贝 | 每次送快递(循环),都要把所有包裹(描述符)重新搬上车 | 包裹(描述符)只搬上车一次,之后直接从车上取 |
| 内核找包裹 | 挨个翻车厢找要送的包裹(轮询,O (n)) | 包裹到了主动喊你(回调,O (1)) |
| 用户找包裹 | 自己把所有包裹翻一遍,找要送的(遍历,O (n)) | 直接给你 "待送包裹清单"(就绪描述符,O (1)) |
二、epoll 的 3 个核心函数(用 "快递站" 类比)
epoll_create:开一个快递站(内核事件表,用红黑树存包裹);epoll_ctl:往快递站里加 / 删 / 改包裹(描述符);epoll_wait:找快递站要 "待送包裹清单"(就绪描述符)。
三、epoll 的 2 种触发模式(用 "提醒吃饭" 类比)
- LT(水平触发):喊你吃饭,你没吃完就一直喊,直到吃完(事件就绪没处理完,持续提醒);
- ET(边缘触发):只喊你一次吃饭,吃不吃完都不喊了(事件就绪只提醒一次)。
