当前位置: 首页 > news >正文

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

一、为什么需要 epoll?

1.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 实例

#include<sys/epoll.h>intepoll_create(intsize);
  • 作用:创建一个 epoll 实例,返回一个文件描述符(epfd),后续所有 epoll 操作都使用它。
  • 参数size:从 Linux 2.6.8 开始被忽略,但必须 >0(历史遗留)。
  • 返回值:成功返回 epfd(一个非负整数),失败返回 -1。

为什么需要 epfd?
epoll 实例本身也是一个文件描述符,可以被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-Triggered)
EPOLLONESHOT只触发一次,之后需要重新注册

示例:添加监听套接字,监视可读事件。

structepoll_eventevent;event.events=EPOLLIN;// 监视可读event.data.fd=serv_sock;// 存储 fdepoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,&event);

3.3 epoll_wait —— 等待事件发生

intepoll_wait(intepfd,structepoll_event*events,intmaxevents,inttimeout);
  • 作用:等待事件发生,返回就绪的 fd 列表。
  • 参数
    • epfd:epoll 实例文件描述符。
    • events:指向epoll_event数组的指针,用于接收就绪的事件列表。
    • maxevents:最多返回多少个事件(通常为数组长度)。
    • timeout:超时时间(毫秒)。-1表示无限等待,0表示立即返回。
  • 返回值:成功返回就绪的 fd 数量(0 表示超时),失败返回 -1。

注意epoll_wait返回后,events数组中只包含就绪的 fd,无需遍历所有 fd,效率极高。


四、水平触发(LT)与边缘触发(ET)

epoll 支持两种触发模式,这是它与 select 最大的不同之处。

4.1 水平触发(Level-Triggered,LT)

  • 默认模式
  • 行为:只要某个 fd 上有未处理的数据(或满足事件条件),epoll_wait就会一直通知你。
  • 类比:门铃一直响,直到你出来处理。

示例:读缓冲区有 100 字节,你只读了 50 字节,那么下次epoll_wait还会立即返回,告诉你还有数据可读。

优点:编程简单,不容易漏掉数据。
缺点:可能会频繁唤醒,效率略低于边缘触发。

4.2 边缘触发(Edge-Triggered,ET)

  • 需要指定EPOLLET标志
  • 行为:只有当 fd 的状态发生变化时(如从无数据变为有数据),才通知一次。之后即使还有数据未读完,也不会再通知,直到下次状态变化。
  • 类比:门铃只在你到达门口时响一次,不管你有没有把东西拿完。

示例:读缓冲区从 0 字节变为 100 字节时,epoll_wait返回一次。如果你只读了 50 字节,下次epoll_wait不会再返回,直到有新数据到来。

要求

  • 必须循环读取直到返回EAGAINEWOULDBLOCK(表示无更多数据)。
  • 通常需要将套接字设为非阻塞模式,否则最后一次read会阻塞。

优点:唤醒次数少,效率更高。
缺点:编程复杂,必须处理非阻塞 I/O 和循环读取。

4.3 两种模式对比

特性水平触发(LT)边缘触发(ET)
通知次数持续通知直到处理完只通知一次状态变化
编程难度简单较复杂
性能较低(可能多次唤醒)较高
适用场景通用高并发、大量连接
是否需要非阻塞不需要必须

五、实战:epoll 回声服务器(水平触发版)

下面实现一个使用 epoll 的水平触发回声服务器,逻辑与 select 版相似,但效率更高。

5.1 服务器代码(逐行注释)

#include<stdio.h>#include<stdlib.h>#include<string.h>#include<unistd.h>#include<arpa/inet.h>#include<sys/socket.h>#include<sys/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_sock=socket(PF_INET,SOCK_STREAM,0);memset(&serv_adr,0,sizeof(serv_adr));serv_adr.sin_family=AF_INET;serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);serv_adr.sin_port=htons(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 实例 ==========epfd=epoll_create(EPOLL_SIZE);// 参数已忽略,但需 >0// 分配内存用于存放就绪事件(最多 EPOLL_SIZE 个)ep_events=malloc(sizeof(structepoll_event)*EPOLL_SIZE);// ========== 3. 将监听套接字加入 epoll 监视 ==========event.events=EPOLLIN;// 监视可读事件(连接请求、数据到达)event.data.fd=serv_sock;// 存储 fd,后面可以根据这个判断是哪个套接字epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,&event);// ========== 4. 事件循环 ==========while(1){// 等待事件发生,-1 表示无限等待event_cnt=epoll_wait(epfd,ep_events,EPOLL_SIZE,-1);if(event_cnt==-1){puts("epoll_wait() error");break;}// 遍历所有就绪的事件for(i=0;i<event_cnt;i++){// 情况1:监听套接字就绪 → 有新连接if(ep_events[i].data.fd==serv_sock){adr_sz=sizeof(clnt_adr);clnt_sock=accept(serv_sock,(structsockaddr*)&clnt_adr,&adr_sz);// 将新客户端套接字加入 epoll 监视event.events=EPOLLIN;// 监视可读(数据到达)event.data.fd=clnt_sock;epoll_ctl(epfd,EPOLL_CTL_ADD,clnt_sock,&event);printf("connected client: %d\n",clnt_sock);}// 情况2:客户端套接字就绪 → 数据到达或连接关闭else{str_len=read(ep_events[i].data.fd,buf,BUF_SIZE);if(str_len==0){// 客户端正常关闭连接// 从 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,并设置errnoEAGAINEWOULDBLOCK,表示“暂时无数据”。

6.3 设置非阻塞的函数

voidsetnonblockingmode(intfd){intflag=fcntl(fd,F_GETFL,0);// 获取当前文件状态标志fcntl(fd,F_SETFL,flag|O_NONBLOCK);// 添加非阻塞标志}

6.4 边缘触发版回声服务器(逐行注释)

#include<stdio.h>#include<stdlib.h>#include<string.h>#include<unistd.h>#include<fcntl.h>#include<errno.h>#include<arpa/inet.h>#include<sys/socket.h>#include<sys/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_sock=socket(PF_INET,SOCK_STREAM,0);memset(&serv_adr,0,sizeof(serv_adr));serv_adr.sin_family=AF_INET;serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);serv_adr.sin_port=htons(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 实例epfd=epoll_create(EPOLL_SIZE);ep_events=malloc(sizeof(structepoll_event)*EPOLL_SIZE);// ========== 关键点1:监听套接字也要设为非阻塞 ==========setnonblockingmode(serv_sock);// 监听套接字使用水平触发(也可以边缘触发,但一般无需)event.events=EPOLLIN;// 水平触发(默认)event.data.fd=serv_sock;epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,&event);while(1){event_cnt=epoll_wait(epfd,ep_events,EPOLL_SIZE,-1);if(event_cnt==-1){puts("epoll_wait() error");break;}puts("return epoll_wait");// 每次返回都打印,观察触发次数for(i=0;i<event_cnt;i++){if(ep_events[i].data.fd==serv_sock){// 新连接adr_sz=sizeof(clnt_adr);clnt_sock=accept(serv_sock,(structsockaddr*)&clnt_adr,&adr_sz);// ========== 关键点2:客户端套接字设为非阻塞 ==========setnonblockingmode(clnt_sock);// ========== 关键点3:使用边缘触发(EPOLLET) ==========event.events=EPOLLIN|EPOLLET;event.data.fd=clnt_sock;epoll_ctl(epfd,EPOLL_CTL_ADD,clnt_sock,&event);printf("connected client: %d\n",clnt_sock);}else{// 数据到达或连接关闭// ========== 关键点4:循环读取直到 EAGAIN ==========while(1){str_len=read(ep_events[i].data.fd,buf,BUF_SIZE);if(str_len==0){// 对方关闭连接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_len<0){// 出错,检查 errnoif(errno==EAGAIN){// 没有更多数据可读,正常退出循环break;}// 其他错误,退出循环并关闭break;}else{// 收到数据,回声write(ep_events[i].data.fd,buf,str_len);}}}}}close(serv_sock);close(epfd);free(ep_events);return0;}voidsetnonblockingmode(intfd){intflag=fcntl(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 边缘触发代码的关键点总结

  1. 所有可能产生 EAGAIN 的套接字都必须设为非阻塞(监听套接字也最好设为非阻塞,但这里仅示范客户端)。
  2. 注册事件时加上EPOLLET标志
  3. while循环中反复调用read,直到返回-1errno == EAGAIN,才能保证读完所有数据。
  4. 处理完数据后不要忘记处理连接关闭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已经足够优秀;对于超高并发(如游戏服务器、网关),可以使用边缘触发
http://www.cnnetsun.cn/news/1772624.html

相关文章:

  • OpenClaw浏览器控制:Phi-3-mini-128k-instruct自动填写网页表单
  • 从OSDK到云API:解锁大疆无人机二次开发的两种路径
  • IGBT单管并联方案全解析:从均流设计到热管理实战技巧
  • Java+Playwright实战:如何精准点击Canvas画板中的单元格(附完整代码)
  • Windows 10/11上如何用Cursor打造智能开发环境?MCP服务器配置全攻略
  • TensorRT 8.5在VS2022里跑不起来?别急,先检查这5个地方(Win10+CUDA 11.8环境)
  • 从半导体到单片机:计算机底层原理与实现
  • OpenClaw浏览器自动化:Qwen3.5-9B驱动的网页操作与数据采集
  • Adafruit INA237/INA238 Arduino驱动库详解
  • 2026最权威的十大AI辅助论文助手实测分析
  • SecGPT-14B长文本优化:解决OpenClaw安全报告截断问题
  • 用GitHub Copilot 10分钟开发真寻Bot插件:以DeepSeek对话功能为例(附完整猫娘角色Prompt)
  • PotPlayer,Screenbox,免费苹果mac视频播放器推荐
  • 从0开始实现Mysql主从配置实战
  • 5分钟搞懂Xilinx视频处理链路:AXI4-Stream接口与VDMA的协同设计
  • 华为元老许映童下周敲钟:思格新能开启招股:估值超100亿美元 高瓴是基石
  • OpenClaw技能市场挖掘:百川2-13B-4bits适配的5个实用技能
  • 解锁论文写作新姿势:书匠策AI,你的毕业论文“智囊团”已上线!
  • Linux文件特殊权限位SUID/SGID/Sticky详解
  • AV1 码流 RTP 封装
  • 老旧电脑焕新:OpenClaw+Phi-3-vision-128k-instruct打造智能辅助系统
  • 采购人员定位系统前,先问厂商这十个问题
  • 电脑硬件说明
  • M5GFX嵌入式图形库:面向M5Stack的HAL解耦GUI引擎
  • 【多模态大模型——跨越感知与认知的鸿沟】第2章 视觉感知层:编码器架构与表征工程
  • 为什么你的PHP 8.9异步服务仍卡在I/O?3个致命配置错误+2套生产环境验证方案
  • Python 3.14 JIT编译器实测对比:启动快3.8倍、CPU占用降62%——这7个配置参数90%开发者从未启用
  • 可信AI:政务智能化建设中的伦理与安全框架
  • 避坑!这些毕设太好抄了,3000+毕设案例推荐第1042期
  • 做自媒体,我是怎么把“不知道写什么”变成“写不完”的