linux学习进展 I/O复用函数——epoll详解(ET,IT模式)
在上一篇笔记中,我们学习了poll函数,它解决了select函数最大文件描述符数量限制的问题,但和select一样,仍存在两个核心痛点:一是每次调用都需要将所有监视的文件描述符(fd)从用户态拷贝到内核态,拷贝开销随fd数量增加而增大;二是返回后需要遍历所有fd才能找到就绪的fd,轮询效率低下。本节课我们将学习Linux系统中最高效、最常用的I/O复用函数——epoll,它彻底解决了select和poll的性能瓶颈,同时支持两种核心触发模式(LT水平触发、ET边缘触发),是高并发网络编程(如Nginx、Redis底层)的核心技术,也是Linux学习中必须掌握的重点内容。
注:笔记标题中“IT模式”为笔误,正确表述为“LT模式”(Level-Triggered,水平触发),ET模式为Edge-Triggered(边缘触发),二者是epoll的两种核心工作模式,也是本节课的重点。
一、epoll的核心优势与设计原理
1.1 核心优势(对比select/poll)
epoll是Linux内核从2.5.44版本开始引入的高性能I/O复用机制,专为解决select和poll在高并发场景下的性能缺陷而设计,其核心优势体现在三个方面,也是它能支撑数万甚至数十万并发连接的关键原因:
-
无fd数量限制:select默认限制1024个fd(受FD_SETSIZE限制),poll无硬限制但效率随fd数量下降,而epoll无硬限制,仅受系统内存影响,可轻松支持百万级fd监听。
-
事件驱动,无需轮询:select和poll是“轮询模型”,每次调用需内核遍历所有监听fd;epoll是“事件驱动模型”,内核维护就绪fd链表,仅返回就绪的fd,无需遍历所有fd,时间复杂度从O(n)优化为O(1)。
-
减少用户态与内核态拷贝:select和poll每次调用需拷贝所有监听fd,开销巨大;epoll通过mmap(内存映射)让内核态与用户态共享一块内存,仅拷贝就绪fd的信息,拷贝开销几乎可以忽略不计。
1.2 底层设计原理
epoll的高效得益于内核内部的两个核心数据结构,二者协同工作实现高效事件管理:
-
红黑树:用于存储所有注册的监听fd和对应的事件,红黑树的增删改查效率为O(logn),确保在海量fd场景下,注册、修改、删除监听事件的操作依然高效。
-
就绪链表:用于存储已经就绪的fd,当某个fd的事件就绪时,内核会将其从红黑树中取出,加入就绪链表;epoll_wait调用时,只需从就绪链表中读取数据,无需遍历红黑树,实现“按需返回”。
简单来说,epoll的工作流程是:通过epoll_ctl将fd注册到红黑树中 → 内核监听fd事件,就绪fd加入就绪链表 → 调用epoll_wait从就绪链表中获取就绪fd,交给用户进程处理。
二、epoll的核心函数(3个核心接口)
epoll的使用依赖3个核心函数,三者构成“创建实例→注册事件→等待就绪”的完整闭环,使用前需包含头文件 #include <sys/epoll.h>,下面逐一详解每个函数的原型、参数和使用场景。
2.1 epoll_create:创建epoll实例
函数原型
int epoll_create(int size);
参数与返回值
-
size:早期版本用于提示内核预期监听的fd数量,Linux 2.6.8之后该参数已废弃,仅需传入一个大于0的整数(如1)即可,内核会根据实际情况动态调整。
-
返回值:成功返回epoll实例的文件描述符(epfd),后续操作均通过该fd进行;失败返回-1,并设置errno(如ENOMEM,内存不足)。
注意:epoll实例本身会占用一个fd,使用完毕后必须调用close()关闭,否则会导致fd泄漏。
2.2 epoll_ctl:注册/修改/删除监听事件
epoll_ctl是epoll的核心控制函数,用于向epoll实例中注册、修改或删除某个fd的监听事件,替代了select/poll中每次调用都需重新传递fd集合的繁琐操作。
函数原型
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数详解
返回值
成功返回0;失败返回-1,并设置errno(如EBADF:epfd或fd无效;EEXIST:添加已注册的fd)。
2.3 epoll_wait:等待事件就绪
epoll_wait用于等待epoll实例中注册的fd就绪,类似于select的等待逻辑,但效率更高,仅返回就绪的fd。
函数原型
-
epfd:epoll_create返回的epoll实例fd,指定操作的目标实例。
-
op:操作类型,用3个宏定义表示,只能取其中一个:
-
EPOLL_CTL_ADD:向epoll实例中添加一个新的fd及对应的监听事件;
-
EPOLL_CTL_MOD:修改已注册fd的监听事件(如从监听读事件改为写事件);
-
EPOLL_CTL_DEL:从epoll实例中删除一个fd的监听(删除后,epoll不再监视该fd)。
-
-
fd:需要操作的目标文件描述符(如socket、文件fd等)。
-
event:指向struct epoll_event结构体的指针,用于指定fd的监听事件和用户数据,是epoll实现灵活触发的核心,结构体定义如下:
struct epoll_event { uint32_t events; // 监听的事件(位图,通过位或组合) epoll_data_t data; // 用户数据(用于存储fd或自定义数据) }; // epoll_data_t是一个联合体,可根据需求选择存储类型 typedef union epoll_data { void *ptr; // 指向自定义数据的指针(如结构体) int fd; // 存储目标fd(最常用) uint32_t u32; // 32位无符号整数 uint64_t u64; // 64位无符号整数 } epoll_data_t;常用events事件标志(重点记忆)
events是位图,可通过位或运算(|)组合多个事件,常用标志如下,其中EPOLLET是ET模式的关键标志,EPOLLONESHOT用于解决线程安全问题:
-
EPOLLIN:普通或优先级带数据可读(对应读事件,最常用);
-
EPOLLOUT:普通数据可写(对应写事件,常用);
-
EPOLLET:设置为边缘触发(ET)模式(默认是水平触发LT模式);
-
EPOLLONESHOT:事件只通知一次,需重新注册才能再次监听(解决多线程处理同一fd的线程安全问题);
-
EPOLLERR:fd发生错误(无需用户设置,内核自动检测并通知);
-
EPOLLHUP:fd挂起(如对方关闭连接,无需用户设置,内核自动检测)。
-
注意:EPOLLERR和EPOLLHUP无需在events中设置,内核会自动检测并将其加入就绪事件,用户只需在epoll_wait返回后检查即可。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数详解
-
epfd:epoll实例fd,指定要等待的实例。
-
events:用户预先分配的struct epoll_event数组,用于存储内核返回的就绪事件(即就绪的fd及其事件),数组大小由maxevents指定。
-
maxevents:指定events数组的最大长度(即最多能接收的就绪事件数量),必须大于0,且不能超过epoll_create时的size(废弃后可灵活设置,通常设为10~100,根据需求调整)。
-
timeout:超时时间(单位:毫秒),与poll的timeout逻辑一致,分三种情况:
-
timeout > 0:等待timeout毫秒,若期间有fd就绪,立即返回;超时无就绪fd,返回0;
-
timeout = 0:不阻塞,立即返回,无论是否有fd就绪(非阻塞轮询);
-
timeout = -1:无限阻塞,直到至少有一个fd就绪或被信号中断,才返回。
-
返回值
-
返回值 > 0:成功,返回就绪事件的数量(即events数组中有效元素的个数);
-
返回值 = 0:超时,指定时间内无fd就绪;
-
返回值 = -1:失败,设置errno(如EINTR:被信号中断,可重试;EBADF:epfd无效)。
三、epoll的两种触发模式(LT vs ET)—— 核心重点
epoll的核心特性之一是支持两种触发模式,这两种模式的本质区别的是:当fd就绪后,内核是否会重复通知进程处理。理解两种模式的差异,是正确使用epoll的关键,也是面试高频考点。
3.1 水平触发(LT,Level-Triggered)—— 默认模式
模式定义
LT模式是epoll的默认模式,其行为与select、poll完全一致:只要fd处于就绪状态(如读缓冲区有数据),每次调用epoll_wait,内核都会重复通知进程该fd就绪,直到进程将数据处理完毕。
核心特点
-
安全、简单:无需担心数据遗漏,即使进程第一次未处理完就绪数据,下次epoll_wait仍会通知,编程难度低;
-
效率适中:存在冗余通知(多次通知同一就绪fd),但在低并发或数据处理不及时的场景下,更稳妥;
-
支持阻塞/非阻塞fd:可搭配阻塞fd使用,无需额外处理,适合通用服务器场景。
举个例子(读事件)
-
客户端向服务器发送100字节数据,服务器fd的读缓冲区有数据,进入就绪状态;
-
epoll_wait返回,通知进程该fd可读,进程读取了50字节,剩余50字节未读取;
-
进程再次调用epoll_wait,内核检测到该fd的读缓冲区仍有数据(就绪状态),再次通知进程可读;
-
重复步骤3,直到进程将100字节数据全部读取完毕,内核不再重复通知(除非有新数据到来)。
3.2 边缘触发(ET,Edge-Triggered)—— 高性能模式
模式定义
ET模式是epoll的高性能模式,其行为更严格:仅当fd的状态从“非就绪”变为“就绪”时,内核才会通知进程一次,后续即使fd仍处于就绪状态(如还有未读取的数据),内核也不会再通知。
核心特点(重点注意事项)
-
高效、无冗余通知:仅通知一次状态变化,减少系统调用次数,CPU开销极小,适合高并发场景(如Nginx);
-
必须使用非阻塞fd:若使用阻塞fd,当一次读取未读完数据,进程会阻塞在read/write调用上,无法处理其他就绪fd,导致死锁;
-
必须一次性处理完所有就绪数据:需循环调用read/write,直到返回EAGAIN(或EWOULDBLOCK),表示当前无更多数据可读写,否则会导致数据遗漏;
-
编程难度高:需严格处理数据读写逻辑,规避数据遗漏和阻塞问题,是工业级高并发服务器的首选模式。
举个例子(读事件)
-
客户端向服务器发送100字节数据,服务器fd的读缓冲区从“空”(非就绪)变为“有数据”(就绪),内核通知进程一次;
-
epoll_wait返回,进程必须循环读取数据:第一次读取50字节,继续读取,直到读取完100字节,此时read返回EAGAIN,说明无更多数据;
-
若进程仅读取50字节就停止,未处理剩余50字节,内核不会再通知该fd可读,剩余50字节会一直留在缓冲区,导致数据遗漏;
-
只有当客户端再次发送新数据(fd状态再次从非就绪变为就绪),内核才会再次通知进程。
3.3 LT与ET模式对比(表格总结)
|
对比项 |
水平触发(LT) |
边缘触发(ET) |
|---|---|---|
|
触发时机 |
fd就绪时,重复通知,直到数据处理完毕 |
仅在fd从非就绪→就绪时,通知一次 |
|
fd类型要求 |
支持阻塞、非阻塞fd |
必须使用非阻塞fd |
|
数据处理要求 |
可分多次处理,无需一次性读完/写完 |
必须循环读写,直到返回EAGAIN |
|
通知频率 |
冗余通知,频率高 |
无冗余通知,频率低 |
|
编程难度 |
低,易上手,不易出错 |
高,需处理阻塞和数据遗漏问题 |
|
适用场景 |
低并发、通用场景,如简单服务器 |
高并发、高性能场景,如Nginx、Redis |
四、实战案例:epoll两种模式的实现(LT vs ET)
下面通过一个TCP服务器案例,分别实现epoll的LT模式和ET模式,对比两种模式的代码差异和运行效果,帮助大家快速掌握用法。案例核心功能:监听客户端连接,读取客户端发送的数据并回显。
4.1 通用准备(公共代码)
两种模式共用以下代码(创建监听socket、设置端口复用、绑定端口、监听),重点差异在epoll的事件注册和数据处理逻辑:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <string.h>
#include <fcntl.h>
#include <errno.h>
#define MAX_EVENTS 100 // epoll_wait接收的最大就绪事件数
#define BUF_SIZE 1024 // 缓冲区大小
#define PORT 8888 // 监听端口
// 设置fd为非阻塞(ET模式必须)
int set_nonblocking(int fd) {
int old_flag = fcntl(fd, F_GETFL); // 获取当前fd的状态标志
int new_flag = old_flag | O_NONBLOCK; // 新增非阻塞标志
fcntl(fd, F_SETFL, new_flag); // 设置新的状态标志
return old_flag; // 返回旧标志,便于后续恢复(可选)
}
// 向epoll实例中添加fd及监听事件(可设置LT/ET模式)
void add_epoll_fd(int epfd, int fd, int is_et) {
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN; // 监听读事件
if (is_et) {
ev.events |= EPOLLET; // 若为ET模式,添加EPOLLET标志
}
// 注册fd到epoll实例
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
// ET模式必须设置非阻塞
if (is_et) {
set_nonblocking(fd);
}
}
4.2 LT模式实现(默认模式)
int main() {
// 1. 创建监听socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) { perror("socket error"); exit(1); }
// 2. 设置端口复用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. 绑定地址和端口
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind error"); exit(1);
}
// 4. 开始监听
if (listen(listen_fd, 5) == -1) { perror("listen error"); exit(1); }
printf("LT模式服务器启动,监听端口%d...\n", PORT);
// 5. 创建epoll实例
int epfd = epoll_create(1);
if (epfd == -1) { perror("epoll_create error"); exit(1); }
// 6. 向epoll实例添加监听socket(LT模式,is_et=0)
add_epoll_fd(epfd, listen_fd, 0);
struct epoll_event events[MAX_EVENTS]; // 存储就绪事件
while (1) {
// 7. 等待事件就绪(无限阻塞)
int ret = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (ret == -1) { perror("epoll_wait error"); continue; }
if (ret == 0) { continue; } // 超时(此处不会发生)
// 8. 遍历就绪事件,处理
for (int i = 0; i < ret; i++) {
int fd = events[i].data.fd;
// 处理客户端连接请求(监听socket就绪)
if (fd == listen_fd) {
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd == -1) { perror("accept error"); continue; }
printf("客户端连接:%s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
// 添加客户端fd到epoll(LT模式)
add_epoll_fd(epfd, client_fd, 0);
}
// 处理客户端数据(客户端socket就绪)
else if (events[i].events & EPOLLIN) {
char buf[BUF_SIZE] = {0};
// LT模式可不用循环,分多次读取(此处为了对比,仅读一次)
int read_len = read(fd, buf, BUF_SIZE - 1);
if (read_len == -1) { perror("read error"); close(fd); continue; }
if (read_len == 0) { // 客户端关闭连接
printf("客户端断开连接\n");
close(fd);
continue;
}
printf("LT模式:收到数据:%s(长度:%d)\n", buf, read_len);
// 数据回显
write(fd, buf, read_len);
}
}
}
// 关闭资源(实际不会执行到这里)
close(listen_fd);
close(epfd);
return 0;
}
4.3 ET模式实现(高性能模式)
int main() {
// 1-4步:创建监听socket、端口复用、绑定、监听(与LT模式完全一致)
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) { perror("socket error"); exit(1); }
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind error"); exit(1);
}
if (listen(listen_fd, 5) == -1) { perror("listen error"); exit(1); }
printf("ET模式服务器启动,监听端口%d...\n", PORT);
// 5. 创建epoll实例
int epfd = epoll_create(1);
if (epfd == -1) { perror("epoll_create error"); exit(1); }
// 6. 添加监听socket(ET模式,is_et=1),监听socket建议用LT模式,此处为演示
add_epoll_fd(epfd, listen_fd, 1);
struct epoll_event events[MAX_EVENTS];
while (1) {
int ret = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (ret == -1) { perror("epoll_wait error"); continue; }
if (ret == 0) { continue; }
for (int i = 0; i < ret; i++) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// 处理连接(ET模式下,需循环accept,避免漏接连接)
while (1) {
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd == -1) {
// 当accept返回EAGAIN,说明无更多连接可接收
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
perror("accept error");
break;
}
printf("客户端连接:%s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
// 添加客户端fd(ET模式,设置非阻塞)
add_epoll_fd(epfd, client_fd, 1);
}
}
else if (events[i].events & EPOLLIN) {
char buf[BUF_SIZE] = {0};
int total_read = 0;
// ET模式必须循环读取,直到返回EAGAIN
while (1) {
int read_len = read(fd, buf + total_read, BUF_SIZE - 1 - total_read);
if (read_len == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 读取完毕,退出循环
printf("ET模式:读取完毕,总长度:%d,数据:%s\n", total_read, buf);
// 数据回显(同样需循环写,避免数据未写完)
int total_write = 0;
while (total_write < total_read) {
int write_len = write(fd, buf + total_write, total_read - total_write);
if (write_len == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
continue; // 缓冲区满,重试
}
perror("write error");
close(fd);
goto end;
}
total_write += write_len;
}
break;
}
perror("read error");
close(fd);
goto end;
}
if (read_len == 0) {
printf("客户端断开连接\n");
close(fd);
goto end;
}
total_read += read_len;
}
end:;
}
}
}
close(listen_fd);
close(epfd);
return 0;
}
案例差异总结
-
ET模式需在add_epoll_fd时添加EPOLLET标志,并设置fd为非阻塞;
-
ET模式处理读/写事件时,必须循环调用read/write,直到返回EAGAIN;
-
ET模式处理监听socket的连接时,需循环accept,避免漏接并发连接;
-
LT模式无需循环读写,编程更简单,但存在冗余通知。
五、epoll的注意事项(避坑重点)
-
关闭epoll实例和fd:epoll实例(epfd)和注册的fd都需手动关闭,否则会导致fd泄漏,最终耗尽系统fd资源。
-
ET模式的必做操作:必须使用非阻塞fd,必须循环读写直到返回EAGAIN,否则会导致数据遗漏;监听fd建议用LT模式,避免漏接连接。
-
EPOLLONESHOT的使用:多线程处理epoll就绪事件时,需给fd注册EPOLLONESHOT事件,确保同一时间只有一个线程处理该fd,避免线程安全问题(处理完毕后需重新注册事件)。
-
信号中断处理:epoll_wait被信号中断(返回-1,errno=EINTR)时,无需退出程序,可重新调用epoll_wait继续等待。
-
epoll的局限性:epoll是Linux特有函数,不具备跨平台性(Windows不支持);若fd数量少且就绪频率低,epoll的性能优势不明显,此时select/poll更轻便。
六、select/poll/epoll 三者对比(终极总结)
|
对比项 |
select |
poll |
epoll |
|---|---|---|---|
|
fd数量限制 |
有(默认1024,可修改但低效) |
无硬限制(受内存影响,效率随fd下降) |
无硬限制(支持百万级fd,效率稳定) |
|
触发模式 |
仅LT模式 |
仅LT模式 |
支持LT、ET模式 |
|
内核遍历方式 |
全量轮询(O(n)) |
全量轮询(O(n)) |
事件驱动(O(1),仅遍历就绪fd) |
|
用户态/内核态拷贝 |
每次拷贝所有监听fd |
每次拷贝所有监听fd |
mmap共享内存,仅拷贝就绪fd |
|
跨平台性 |
支持(POSIX标准) |
支持(POSIX标准) |
仅Linux特有 |
|
适用场景 |
低并发、跨平台场景 |
稍高并发、跨平台场景 |
高并发、高性能、Linux专用场景 |
七、学习小结
1. epoll是Linux最高效的I/O复用函数,通过红黑树和就绪链表的底层设计,解决了select/poll的轮询效率低、拷贝开销大、fd数量限制的问题,是高并发网络编程的核心。
2. epoll的3个核心函数:epoll_create(创建实例)、epoll_ctl(注册/修改/删除事件)、epoll_wait(等待就绪事件),需熟练掌握其参数和返回值。
3. 两种触发模式是重点:LT模式默认、简单安全,适合通用场景;ET模式高效、无冗余通知,适合高并发场景,但必须使用非阻塞fd并循环读写,避免数据遗漏。
4. 实际开发中,Linux高并发服务器(如Web服务器)的标配方案是:epoll + ET模式 + 非阻塞fd + EPOLLONESHOT,既能保证高性能,又能解决线程安全问题。
至此,我们已经学完了Linux中三种核心I/O复用函数(select、poll、epoll),后续笔记将结合实际项目,讲解epoll在高并发场景中的进阶用法,以及I/O复用与多线程、多进程的结合使用。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)