【Linux开发】I/O 复用:epoll 模型

张开发
2026/4/21 18:31:27 15 分钟阅读

分享文章

【Linux开发】I/O 复用:epoll 模型
一、为什么需要 epoll1.1 select 的缺点在之前的教程中我们学习了select模型。它可以同时监视多个套接字但存在几个致命问题问题说明影响描述符上限默认 1024虽可修改但效率下降无法支持大量连接每次都要传递集合select调用时需要把fd_set从用户态拷贝到内核态频繁拷贝开销大O(n) 扫描返回后应用程序需要遍历所有 fd 找出就绪的连接越多越慢集合被修改select会修改传入的集合每次都需要重新设置编程麻烦简单来说select 每次都要告诉内核“你要监视哪些 fd”内核也要逐一检查所有 fd效率低下。1.2 epoll 的优势epoll 是 Linux 特有的 I/O 复用机制Windows 没有对应的是 IOCP它解决了 select 的痛点无描述符上限受系统内存限制。一次注册多次使用只需把要监视的 fd 告诉内核一次后续只通知变化的部分。事件驱动内核只返回真正发生事件的 fd无需遍历所有 fd。支持水平触发和边缘触发后文详细讲解。二、epoll 核心数据结构epoll 在内核中维护一个红黑树存储所有被监视的 fd和一个就绪链表存储就绪的 fd。当 I/O 事件发生时内核把对应的 fd 放入就绪链表应用程序通过epoll_wait获取就绪链表的内容。三、epoll 三大函数3.1 epoll_create —— 创建 epoll 实例#includesys/epoll.hintepoll_create(intsize);作用创建一个 epoll 实例返回一个文件描述符epfd后续所有 epoll 操作都使用它。参数size从 Linux 2.6.8 开始被忽略但必须 0历史遗留。返回值成功返回 epfd一个非负整数失败返回 -1。为什么需要 epfdepoll 实例本身也是一个文件描述符可以被close关闭。它相当于一个“管理者”记录所有监视的 fd。3.2 epoll_ctl —— 控制 epoll 监视列表intepoll_ctl(intepfd,intop,intfd,structepoll_event*event);作用向 epoll 实例中添加、修改或删除要监视的文件描述符。参数epfdepoll_create返回的 epoll 文件描述符。op操作类型取以下值EPOLL_CTL_ADD添加 fd 到监视列表。EPOLL_CTL_MOD修改 fd 上监视的事件。EPOLL_CTL_DEL从监视列表中删除 fd。fd要操作的文件描述符如套接字。eventstruct epoll_event结构体指针指定监视的事件类型和用户数据。struct epoll_event结构体structepoll_event{uint32_tevents;// 事件类型EPOLLIN、EPOLLOUT 等epoll_data_tdata;// 用户数据通常放 fd};typedefunionepoll_data{void*ptr;intfd;uint32_tu32;uint64_tu64;}epoll_data_t;常用事件类型宏含义EPOLLIN有数据可读包括连接请求、对方关闭EPOLLOUT可写输出缓冲区空EPOLLRDHUP对方关闭连接或半关闭EPOLLERR发生错误EPOLLET边缘触发模式Edge-TriggeredEPOLLONESHOT只触发一次之后需要重新注册示例添加监听套接字监视可读事件。structepoll_eventevent;event.eventsEPOLLIN;// 监视可读event.data.fdserv_sock;// 存储 fdepoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,event);3.3 epoll_wait —— 等待事件发生intepoll_wait(intepfd,structepoll_event*events,intmaxevents,inttimeout);作用等待事件发生返回就绪的 fd 列表。参数epfdepoll 实例文件描述符。events指向epoll_event数组的指针用于接收就绪的事件列表。maxevents最多返回多少个事件通常为数组长度。timeout超时时间毫秒。-1表示无限等待0表示立即返回。返回值成功返回就绪的 fd 数量0 表示超时失败返回 -1。注意epoll_wait返回后events数组中只包含就绪的 fd无需遍历所有 fd效率极高。四、水平触发LT与边缘触发ETepoll 支持两种触发模式这是它与 select 最大的不同之处。4.1 水平触发Level-TriggeredLT默认模式。行为只要某个 fd 上有未处理的数据或满足事件条件epoll_wait就会一直通知你。类比门铃一直响直到你出来处理。示例读缓冲区有 100 字节你只读了 50 字节那么下次epoll_wait还会立即返回告诉你还有数据可读。优点编程简单不容易漏掉数据。缺点可能会频繁唤醒效率略低于边缘触发。4.2 边缘触发Edge-TriggeredET需要指定EPOLLET标志。行为只有当 fd 的状态发生变化时如从无数据变为有数据才通知一次。之后即使还有数据未读完也不会再通知直到下次状态变化。类比门铃只在你到达门口时响一次不管你有没有把东西拿完。示例读缓冲区从 0 字节变为 100 字节时epoll_wait返回一次。如果你只读了 50 字节下次epoll_wait不会再返回直到有新数据到来。要求必须循环读取直到返回EAGAIN或EWOULDBLOCK表示无更多数据。通常需要将套接字设为非阻塞模式否则最后一次read会阻塞。优点唤醒次数少效率更高。缺点编程复杂必须处理非阻塞 I/O 和循环读取。4.3 两种模式对比特性水平触发LT边缘触发ET通知次数持续通知直到处理完只通知一次状态变化编程难度简单较复杂性能较低可能多次唤醒较高适用场景通用高并发、大量连接是否需要非阻塞不需要必须五、实战epoll 回声服务器水平触发版下面实现一个使用 epoll 的水平触发回声服务器逻辑与 select 版相似但效率更高。5.1 服务器代码逐行注释#includestdio.h#includestdlib.h#includestring.h#includeunistd.h#includearpa/inet.h#includesys/socket.h#includesys/epoll.h#defineBUF_SIZE100// 消息缓冲区大小#defineEPOLL_SIZE50// epoll 可监视的最大 fd 数量实际无上限只是数组大小voiderror_handling(char*buf);intmain(intargc,char*argv[]){intserv_sock,clnt_sock;structsockaddr_inserv_adr,clnt_adr;socklen_tadr_sz;intstr_len,i;charbuf[BUF_SIZE];// epoll 相关变量structepoll_event*ep_events;// 用于接收就绪事件的数组structepoll_eventevent;// 用于注册事件的结构体intepfd,event_cnt;// epfd: epoll 实例句柄, event_cnt: 就绪事件数量if(argc!2){printf(Usage : %s port\n,argv[0]);exit(1);}// 1. 创建 TCP 套接字 serv_socksocket(PF_INET,SOCK_STREAM,0);memset(serv_adr,0,sizeof(serv_adr));serv_adr.sin_familyAF_INET;serv_adr.sin_addr.s_addrhtonl(INADDR_ANY);serv_adr.sin_porthtons(atoi(argv[1]));// 绑定地址if(bind(serv_sock,(structsockaddr*)serv_adr,sizeof(serv_adr))-1)error_handling(bind() error);// 监听if(listen(serv_sock,5)-1)error_handling(listen() error);// 2. 创建 epoll 实例 epfdepoll_create(EPOLL_SIZE);// 参数已忽略但需 0// 分配内存用于存放就绪事件最多 EPOLL_SIZE 个ep_eventsmalloc(sizeof(structepoll_event)*EPOLL_SIZE);// 3. 将监听套接字加入 epoll 监视 event.eventsEPOLLIN;// 监视可读事件连接请求、数据到达event.data.fdserv_sock;// 存储 fd后面可以根据这个判断是哪个套接字epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,event);// 4. 事件循环 while(1){// 等待事件发生-1 表示无限等待event_cntepoll_wait(epfd,ep_events,EPOLL_SIZE,-1);if(event_cnt-1){puts(epoll_wait() error);break;}// 遍历所有就绪的事件for(i0;ievent_cnt;i){// 情况1监听套接字就绪 → 有新连接if(ep_events[i].data.fdserv_sock){adr_szsizeof(clnt_adr);clnt_sockaccept(serv_sock,(structsockaddr*)clnt_adr,adr_sz);// 将新客户端套接字加入 epoll 监视event.eventsEPOLLIN;// 监视可读数据到达event.data.fdclnt_sock;epoll_ctl(epfd,EPOLL_CTL_ADD,clnt_sock,event);printf(connected client: %d\n,clnt_sock);}// 情况2客户端套接字就绪 → 数据到达或连接关闭else{str_lenread(ep_events[i].data.fd,buf,BUF_SIZE);if(str_len0){// 客户端正常关闭连接// 从 epoll 监视列表中删除该 fdepoll_ctl(epfd,EPOLL_CTL_DEL,ep_events[i].data.fd,NULL);close(ep_events[i].data.fd);printf(closed client: %d\n,ep_events[i].data.fd);}else{// 收到数据原样返回回声write(ep_events[i].data.fd,buf,str_len);}}}}close(serv_sock);close(epfd);free(ep_events);return0;}voiderror_handling(char*buf){fputs(buf,stderr);fputc(\n,stderr);exit(1);}5.2 客户端代码客户端代码与 select 版本完全相同这里不再重复。六、边缘触发ET模式详解与代码6.1 为什么边缘触发更高效边缘触发模式下内核只在状态变化时通知一次这大大减少了内核唤醒应用程序的次数。为了确保不遗漏数据应用程序必须循环读取直到缓冲区为空返回EAGAIN。6.2 必须使用非阻塞 I/O在边缘触发模式下如果最后一次read没有数据可读它会阻塞因为套接字默认是阻塞的。因此需要将套接字设为非阻塞模式这样read返回 -1并设置errno为EAGAIN或EWOULDBLOCK表示“暂时无数据”。6.3 设置非阻塞的函数voidsetnonblockingmode(intfd){intflagfcntl(fd,F_GETFL,0);// 获取当前文件状态标志fcntl(fd,F_SETFL,flag|O_NONBLOCK);// 添加非阻塞标志}6.4 边缘触发版回声服务器逐行注释#includestdio.h#includestdlib.h#includestring.h#includeunistd.h#includefcntl.h#includeerrno.h#includearpa/inet.h#includesys/socket.h#includesys/epoll.h#defineBUF_SIZE4// 故意设置小缓冲区演示边缘触发#defineEPOLL_SIZE50voidsetnonblockingmode(intfd);voiderror_handling(char*buf);intmain(intargc,char*argv[]){intserv_sock,clnt_sock;structsockaddr_inserv_adr,clnt_adr;socklen_tadr_sz;intstr_len,i;charbuf[BUF_SIZE];structepoll_event*ep_events;structepoll_eventevent;intepfd,event_cnt;if(argc!2){printf(Usage : %s port\n,argv[0]);exit(1);}// 创建 TCP 套接字并绑定、监听同水平触发serv_socksocket(PF_INET,SOCK_STREAM,0);memset(serv_adr,0,sizeof(serv_adr));serv_adr.sin_familyAF_INET;serv_adr.sin_addr.s_addrhtonl(INADDR_ANY);serv_adr.sin_porthtons(atoi(argv[1]));if(bind(serv_sock,(structsockaddr*)serv_adr,sizeof(serv_adr))-1)error_handling(bind() error);if(listen(serv_sock,5)-1)error_handling(listen() error);// 创建 epoll 实例epfdepoll_create(EPOLL_SIZE);ep_eventsmalloc(sizeof(structepoll_event)*EPOLL_SIZE);// 关键点1监听套接字也要设为非阻塞 setnonblockingmode(serv_sock);// 监听套接字使用水平触发也可以边缘触发但一般无需event.eventsEPOLLIN;// 水平触发默认event.data.fdserv_sock;epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,event);while(1){event_cntepoll_wait(epfd,ep_events,EPOLL_SIZE,-1);if(event_cnt-1){puts(epoll_wait() error);break;}puts(return epoll_wait);// 每次返回都打印观察触发次数for(i0;ievent_cnt;i){if(ep_events[i].data.fdserv_sock){// 新连接adr_szsizeof(clnt_adr);clnt_sockaccept(serv_sock,(structsockaddr*)clnt_adr,adr_sz);// 关键点2客户端套接字设为非阻塞 setnonblockingmode(clnt_sock);// 关键点3使用边缘触发EPOLLET event.eventsEPOLLIN|EPOLLET;event.data.fdclnt_sock;epoll_ctl(epfd,EPOLL_CTL_ADD,clnt_sock,event);printf(connected client: %d\n,clnt_sock);}else{// 数据到达或连接关闭// 关键点4循环读取直到 EAGAIN while(1){str_lenread(ep_events[i].data.fd,buf,BUF_SIZE);if(str_len0){// 对方关闭连接epoll_ctl(epfd,EPOLL_CTL_DEL,ep_events[i].data.fd,NULL);close(ep_events[i].data.fd);printf(closed client: %d\n,ep_events[i].data.fd);break;// 退出 while 循环}elseif(str_len0){// 出错检查 errnoif(errnoEAGAIN){// 没有更多数据可读正常退出循环break;}// 其他错误退出循环并关闭break;}else{// 收到数据回声write(ep_events[i].data.fd,buf,str_len);}}}}}close(serv_sock);close(epfd);free(ep_events);return0;}voidsetnonblockingmode(intfd){intflagfcntl(fd,F_GETFL,0);fcntl(fd,F_SETFL,flag|O_NONBLOCK);}voiderror_handling(char*buf){fputs(buf,stderr);fputc(\n,stderr);exit(1);}6.5 边缘触发代码的关键点总结所有可能产生 EAGAIN 的套接字都必须设为非阻塞监听套接字也最好设为非阻塞但这里仅示范客户端。注册事件时加上EPOLLET标志。在while循环中反复调用read直到返回-1且errno EAGAIN才能保证读完所有数据。处理完数据后不要忘记处理连接关闭str_len 0。6.6 运行对比水平触发客户端发送 5 次消息服务器端epoll_wait会返回 5 次每次返回后立即处理可能因为缓冲区小而返回多次但总的唤醒次数与数据包数相近。边缘触发客户端发送 5 次消息如果缓冲区较小如 4 字节服务器可能一次epoll_wait返回后在while循环中读完所有数据后续不会再次触发。因此epoll_wait的返回次数可能少于 5 次效率更高。七、epoll 与 select 的性能对比对比项selectepoll监视 fd 数量通常 1024无限制受内存限制每次调用复制集合是否只在注册时复制就绪通知方式修改集合需遍历直接返回就绪列表时间复杂度O(n)O(1)仅就绪 fd触发模式仅水平触发水平触发 边缘触发适用场景少量连接大量连接数千、数万八、总结epoll是 Linux 下高性能 I/O 复用的首选方案。三大核心函数epoll_create创建实例、epoll_ctl添加/删除监视、epoll_wait等待事件。水平触发LT简单适合大多数场景。边缘触发ET效率更高但必须配合非阻塞 I/O 和循环读取编程复杂。实战中对于一般的网络服务器水平触发 非阻塞 I/O已经足够优秀对于超高并发如游戏服务器、网关可以使用边缘触发。

更多文章