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/pollepoll是Linux内核从2.5.44版本开始引入的高性能I/O复用机制专为解决select和poll在高并发场景下的性能缺陷而设计其核心优势体现在三个方面也是它能支撑数万甚至数十万并发连接的关键原因无fd数量限制select默认限制1024个fd受FD_SETSIZE限制poll无硬限制但效率随fd数量下降而epoll无硬限制仅受系统内存影响可轻松支持百万级fd监听。事件驱动无需轮询select和poll是“轮询模型”每次调用需内核遍历所有监听fdepoll是“事件驱动模型”内核维护就绪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如EBADFepfd或fd无效EEXIST添加已注册的fd。2.3 epoll_wait等待事件就绪epoll_wait用于等待epoll实例中注册的fd就绪类似于select的等待逻辑但效率更高仅返回就绪的fd。函数原型epfdepoll_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的线程安全问题EPOLLERRfd发生错误无需用户设置内核自动检测并通知EPOLLHUPfd挂起如对方关闭连接无需用户设置内核自动检测。注意EPOLLERR和EPOLLHUP无需在events中设置内核会自动检测并将其加入就绪事件用户只需在epoll_wait返回后检查即可。int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);参数详解epfdepoll实例fd指定要等待的实例。events用户预先分配的struct epoll_event数组用于存储内核返回的就绪事件即就绪的fd及其事件数组大小由maxevents指定。maxevents指定events数组的最大长度即最多能接收的就绪事件数量必须大于0且不能超过epoll_create时的size废弃后可灵活设置通常设为10~100根据需求调整。timeout超时时间单位毫秒与poll的timeout逻辑一致分三种情况timeout 0等待timeout毫秒若期间有fd就绪立即返回超时无就绪fd返回0timeout 0不阻塞立即返回无论是否有fd就绪非阻塞轮询timeout -1无限阻塞直到至少有一个fd就绪或被信号中断才返回。返回值返回值 0成功返回就绪事件的数量即events数组中有效元素的个数返回值 0超时指定时间内无fd就绪返回值 -1失败设置errno如EINTR被信号中断可重试EBADFepfd无效。三、epoll的两种触发模式LT vs ET—— 核心重点epoll的核心特性之一是支持两种触发模式这两种模式的本质区别的是当fd就绪后内核是否会重复通知进程处理。理解两种模式的差异是正确使用epoll的关键也是面试高频考点。3.1 水平触发LTLevel-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 边缘触发ETEdge-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实例添加监听socketLT模式is_et0 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到epollLT模式 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. 添加监听socketET模式is_et1监听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)); // 添加客户端fdET模式设置非阻塞 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直到返回EAGAINET模式处理监听socket的连接时需循环accept避免漏接并发连接LT模式无需循环读写编程更简单但存在冗余通知。五、epoll的注意事项避坑重点关闭epoll实例和fdepoll实例epfd和注册的fd都需手动关闭否则会导致fd泄漏最终耗尽系统fd资源。ET模式的必做操作必须使用非阻塞fd必须循环读写直到返回EAGAIN否则会导致数据遗漏监听fd建议用LT模式避免漏接连接。EPOLLONESHOT的使用多线程处理epoll就绪事件时需给fd注册EPOLLONESHOT事件确保同一时间只有一个线程处理该fd避免线程安全问题处理完毕后需重新注册事件。信号中断处理epoll_wait被信号中断返回-1errnoEINTR时无需退出程序可重新调用epoll_wait继续等待。epoll的局限性epoll是Linux特有函数不具备跨平台性Windows不支持若fd数量少且就绪频率低epoll的性能优势不明显此时select/poll更轻便。六、select/poll/epoll 三者对比终极总结对比项selectpollepollfd数量限制有默认1024可修改但低效无硬限制受内存影响效率随fd下降无硬限制支持百万级fd效率稳定触发模式仅LT模式仅LT模式支持LT、ET模式内核遍历方式全量轮询O(n)全量轮询O(n)事件驱动O(1)仅遍历就绪fd用户态/内核态拷贝每次拷贝所有监听fd每次拷贝所有监听fdmmap共享内存仅拷贝就绪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复用与多线程、多进程的结合使用。