一、epoll原理
- epoll 是 Linux 内核为处理大批量文件描述符,工作方式为水平触发(Level Triggered,LT)和边缘触发(Edge Triggered,ET),使用户空间程序可缓存 IO 状态,减少 epoll_wait/epoll_pwait 调用,提升应用程序效率。其中,LT 模式只要事件未处理就触发,ET 仅在高低电平变换时触发。
- 优势:
a. 支持一个进程打开大数量目的 socket 描述符;
b. IO 效率不随 FD 数目增加而线性下降;
c. 未使用 mmap 加速内核与用户空间的消息传递。
二、epoll系统调用
使用c库封装的3个epoll系统调用,具体源码如下:
此系统调用对文件描述符epfd引用的epoll实例执行控制操作,它要求操作op对目标文件描述符fd执行。op参数有效值为:EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL.
等待 epoll 文件描述符上的 I/O 事件。epfd 引用的 epoll 实例上的事件,事件所指向的存储区域将包含供调用者使用的事件。
select 监控的句柄列表在用户态,每次调用都需要从用户态将句柄列表拷贝到内核态。epoll 中句柄建立在内核当中,减少内核和用户态拷贝,这也是其高效的原因。
三、用户api
1.epoll_create()
参数:
size
:旧版本 Linux 中曾用于预估监控文件描述符数量,现被忽略(Linux 2.6.8+),但需传入大于 0 的值,通常设为1
。
返回值:
- 成功:返回新创建的 epoll 实例文件描述符(非负整数)。
- 失败:返回
-1
,并通过全局变量errno
设置错误类型(如内存不足)。
使用案例:
int epfd = epoll_create(1);
if (epfd == -1) { perror("epoll_create"); exit(EXIT_FAILURE);
}
// 后续通过 epfd 操作 epoll 实例
二、epoll_ctl()
用于管理 epoll 实例监控的事件,如添加、修改、删除文件描述符的监控事件。
参数:
epfd
:epoll 实例的文件描述符(由epoll_create()
返回)。op
:操作类型,取值:EPOLL_CTL_ADD
:添加监控事件。EPOLL_CTL_MOD
:修改已有监控事件。EPOLL_CTL_DEL
:删除监控事件。
fd
:需监控的文件描述符(如 socket)。event
:指向struct epoll_event
的指针,定义监控事件类型(如EPOLLIN
读事件、EPOLLOUT
写事件)。
返回值:
- 成功:返回
0
。 - 失败:返回
-1
,errno
记录错误(如非法文件描述符)。
使用案例:
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = sockfd; // 假设 sockfd 是待监控的 socket
if (epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event) == -1) { perror("epoll_ctl add"); close(epfd); exit(EXIT_FAILURE);
}
三、epoll_wait()
等待 epoll 实例监控的文件描述符上的事件就绪。
参数:
epfd
:epoll 实例的文件描述符。events
:指向struct epoll_event
数组的指针,用于存储就绪事件。maxevents
:events
数组的最大容量,需大于 0。timeout
:等待超时时间(毫秒):-1
:永久阻塞,直到事件就绪。0
:非阻塞,立即返回。>0
:等待指定毫秒,超时返回。
返回值:
- 成功:返回就绪事件的数量。
- 失败:返回
-1
,errno
记录错误(如中断信号)。 - 超时:返回
0
。
使用案例:
struct epoll_event events[10]; // 最多处理 10 个事件
int nfds = epoll_wait(epfd, events, 10, -1);
if (nfds == -1) { perror("epoll_wait"); close(epfd); exit(EXIT_FAILURE);
}
for (int i = 0; i < nfds; i++) { if (events[i].events & EPOLLIN) { // 处理读事件 }
}
四、解读边缘触发和水平触发
在Linux的epoll机制中,边缘触发(Edge-Triggered, ET)和水平触发(Level-Triggered, LT)是两种不同的事件通知模式,它们决定了epoll_wait如何向应用程序报告文件描述符的就绪状态。以下是两者的详细对比:
1. 水平触发(LT)模式
工作原理:
只要文件描述符处于就绪状态(如读缓冲区有数据或写缓冲区有空闲空间),每次调用
epoll_wait
时都会通知应用程序。例如:若socket接收缓冲区中有数据未读完,
epoll_wait
会持续报告该描述符的读就绪事件,直到数据被完全读取。特点:
编程简单:无需一次性处理所有数据,未处理完的事件会被重复通知。
效率较低:高并发时可能因频繁通知未处理的事件导致更多系统调用。
兼容性:行为类似于select/poll,适合传统编程模型。
代码示例:
// LT模式下读取数据(无需循环读) char buf[1024]; int n = read(fd, buf, sizeof(buf)); // 未读完的数据下次epoll_wait会再次通知
2. 边缘触发(ET)模式
工作原理:
仅在文件描述符状态发生变化时通知一次(如从不可读到可读,或从不可写到可写)。
例如:当socket接收缓冲区新数据到达时触发一次读事件;若未读完数据,后续
epoll_wait
不会重复通知,直到有新数据到来。特点:
高性能:减少重复通知,适合高并发场景。
编程复杂:需确保一次触发后处理所有数据,否则会丢失事件。
必须使用非阻塞I/O:避免因未读完数据导致后续操作阻塞。
代码示例:
// ET模式下必须循环读取,直到EAGAIN char buf[1024]; while (1) {int n = read(fd, buf, sizeof(buf));if (n == -1) {if (errno == EAGAIN || errno == EWOULDBLOCK) {break; // 数据已读完}// 处理其他错误} else if (n == 0) {// 连接关闭break;}// 处理数据 }
3. 核心区别
特性 水平触发(LT) 边缘触发(ET) 通知时机 只要条件满足,重复通知。 仅在状态变化时通知一次。 未处理事件 持续通知,直到事件被处理。 不通知,需应用自行处理。 性能 可能因重复通知效率较低。 减少通知次数,适合高并发。 编程复杂度 简单,适合传统模型。 复杂,需确保处理所有数据。 I/O模式 阻塞或非阻塞均可。 必须使用非阻塞I/O,避免阻塞。
4. 使用场景
LT模式:
适合对编程简单性要求高、事件处理可能分批的场景。
例如:小型服务器、传统网络应用。
ET模式:
适合需要极致性能的高并发场景,如Web服务器(Nginx默认使用ET)。
需结合非阻塞I/O和循环读写,确保完全处理数据。
5. 注意事项(ET模式)
必须使用非阻塞I/O:
避免因未读完数据导致后续操作阻塞。
设置文件描述符为非阻塞:
fcntl(fd, F_SETFL, O_NONBLOCK);
循环读写直到EAGAIN:
读:循环调用
read
直到返回EAGAIN
或EWOULDBLOCK
。写:需监听
EPOLLOUT
事件,并在缓冲区可写时一次性发送数据。单次触发处理全部数据:
若未处理完,可能永久丢失后续事件。
在服务器端使用epoll进行I/O多路转接的步骤如下:
1.创建监听的套接字:
int sockfd=socket(AF_INET,SOCK_STREAM,0);
2.设置端口复用: //可选
int opt=1;
setsockopt(sockfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
3.使本地的ip与端口和监听的套接字进行绑定:
int ret=bind(sockfd,(struct sockaddr*)&serv_addr,sizeof(serv_addr));
4.给监听的套接字设置监听:
listen(sockfd,5);
5.创建epoll实例对象:
int epfd=epoll_create(1);
6.将用于监听的的套接字添加到epoll实例中:
struct epoll_event ev;
ev.events=EPOLLIN;
ev.data.fd=sockfd; //相当于回调,当监听成功,通过这个可以知道是谁
int ret=epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&ev);
7.检测添加到epoll实例中的文件描述符是否已经就绪,并将已经就绪的文件描述符进行处理
int nready=epoll_wait(epfd,events,size,-1);
#1 如果是监听的文件描述符,则和新客户端建立连接,将得到的文件描述符添加到epoll实例当中
struct sockaddr_in client_addr;
memset(&client_addr,0,sizeof(struct sockaddr_in));
socklen_t client_len=sizeof(client_addr);
int clientfd=accept(sockfd,(struct sockaddr*)&client_addr,&client_len);
ev.events=EPOLLIN | EPOLLET //边沿触发
ev.data.fd=clientfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,clientfd,&ev); //把新客户端节点加入红黑树
#2 如果是通信文件的描述符,和对应的客户端进行通信,如果链接已断开,则从epoll树删除
int clientfd=events[i].data.fd;
char buffer[LENGTH];
int len=recv(clientfd,buffer,LENGTH,0);
if(len<0){
close(clientfd)l;
epoll_ctl(epfd,EPOLL_CTL_DEL,clientfd,NULL);
}else if(len==0){
close(clientfd)l;
epoll_ctl(epfd,EPOLL_CTL_DEL,clientfd,NULL);
}else{
printf("Recv: %s, %d byte(s)\n", buffer, len);
}
8.不断while循环重复第七步操作
在边沿触发的模式中由于只有套接字的状态发生变化(如从“缓冲区空”变成“缓冲区不空”)才可以发送事件通知,因此如果当客户端的套接字不全部读完的时候,再有数据发送到服务器也不会引发状态的变化导致永远无法再次读取数据。因此必须在一次通知的处理中将数据全部读完。有两种方法:一种是设置缓冲区足够大,这样一次调用recv即可全部读出,但是往往不允许分配如此大的空间。因此第二中方法就需要循环不断读取,如下:
但是如果正常读取完成recv就会阻塞,这个时候就必须设置非阻塞:
五、案例分析
1.epolltest.c
//#include <sys/types.h>:提供了与套接字编程相关的基本类型和函数声明,像socket、bind、listen等函数,还有sockaddr结构体类型的定义
#include <sys/socket.h>
#include <sys/types.h>
//定义了 Internet 地址族相关的数据结构,例如sockaddr_in,用于存储 IPv4 地址和端口信息
#include <netinet/in.h>
// /包含了用于处理 IP 地址转换的函数,例如inet_pton和inet_ntop,可以在点分十进制字符串和二进制 IP 地址间转换
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/epoll.h>
#include <stdlib.h>
#include <pthread.h>
//提供了 POSIX 操作系统 API 的基本功能,例如close、read、write等函数
#include <unistd.h>
#include <libgen.h>
#include <stdbool.h> // 包含C99的布尔类型头文件//定义了epoll_wait函数一次最多能返回的事件数量
#define MAX_EVENT_NUMBER 1024
//定义了接收数据缓冲区的大小
#define BUFFER_SIZE 5// 设计一个函数:将文件描述符设备成为非阻塞方式
int SetNonBlocking(int fd){int oldoptions=fcntl(fd,F_GETFL);int newoptions=oldoptions|O_NONBLOCK;fcntl(fd,F_SETFL,newoptions);return oldoptions;
}// 设计一个函数:将文件描述符fd上面的EPOLLIN注册到epollfd指示的epoll内核事件列表当中
void addfd(int epollfd,int fd,bool enable_et){struct epoll_event event;event.data.fd=fd;event.events=EPOLLIN;if(enable_et){// /若enable_et为true,则将EPOLLET标志添加到事件类型中,从而启用 ET 模式event.events|=EPOLLET;}epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&event);SetNonBlocking(fd);
}// LT模式的工作流程
void lt(struct epoll_event *events,int number,int epollfd,int listenfd){//存储从套接字接收的数据char cbuffer[BUFFER_SIZE];//使用 for 循环遍历 epoll_wait 返回的 events 数组,number 表示事件的数量for(int i=0;i<number;i++){//从 epoll_event 结构体中获取当前事件对应的文件描述符int sockfd = events->data.fd;//如果当前文件描述符是监听套接字 listenfd,表示有新的客户端连接请求。//使用 accept 函数接受连接,得到新的连接套接字 connfd,并将其添加到 epoll 实例中,同时禁用 ET 模式。if(sockfd == listenfd){struct sockaddr_in client_address;socklen_t client_addrlength = sizeof(client_address);int connfd = accept(listenfd,(struct sockaddr*)&client_address,&client_addrlength);addfd(epollfd,connfd,false); // 对connfd禁用ET模式}else if(events[i].events & EPOLLIN){ //处理可读事件//只要socket读缓存中还有未读出的数据,这段代码就会被触发printf("触发一次事件!\n");memset(cbuffer,'\0',BUFFER_SIZE);int ret=recv(sockfd,cbuffer,BUFFER_SIZE-1,0);if(ret<=0){close(sockfd);continue;}printf("get %d bytes of content : %s\n",ret,cbuffer);send(sockfd, cbuffer, BUFFER_SIZE, 0);}else{printf("触发其它事件!\n");} }
}//ET模式的工作流程
void et(struct epoll_event *events,int number,int epollfd,int listenfd){char cbuffer[BUFFER_SIZE];for(int i = 0;i < number;i++){int sockfd = events[i].data.fd;if(sockfd == listenfd){struct sockaddr_in client_address;memset(&client_address, 0, sizeof(struct sockaddr_in));socklen_t client_addrlength = sizeof(client_address);while(1){int connfd = accept(listenfd,(struct sockaddr*)&client_address,&client_addrlength);if (connfd == -1){if ((errno == EAGAIN) || (errno == EWOULDBLOCK)){// 没有更多连接了break;}perror("accept");break;}addfd(epollfd, connfd, true); // 对connfd启用ET模式}}else if(events[i].events & EPOLLIN){// 只要有新数据到达,这段代码被触发,需要一次性读完所有数据while (1){memset(cbuffer, '\0', BUFFER_SIZE);int ret = recv(sockfd, cbuffer, BUFFER_SIZE - 1, 0);if (ret == -1){if ((errno == EAGAIN) || (errno == EWOULDBLOCK)){// 没有更多数据可读了printf("数据已经接收完毕...\n");break;}else {close(sockfd);epoll_ctl(epollfd, EPOLL_CTL_DEL, sockfd, NULL);exit(1);}}else if (ret == 0){// 客户端关闭连接printf("客户端已经断开连接! \n");close(sockfd);epoll_ctl(epollfd, EPOLL_CTL_DEL, sockfd, NULL);}else{printf("get %d bytes of content : %s\n", ret, cbuffer);send(sockfd, cbuffer, BUFFER_SIZE, 0);}}}else{printf("触发其它事件!\n");}}
}int main(int argc,char *argv[]){if(argc<=2){//basename函数是用来从路径名里提取文件名的,它定义在<libgen.h>头文件中printf("使用方法错误:%s缺少IP地址和端口?\n",basename(argv[0]));return -1;}const char *ip=argv[1]; // 参数IP地址int port=atoi(argv[2]); // 参数端口int ret = 0;struct sockaddr_in address;bzero(&address,sizeof(address));address.sin_family=AF_INET; // AF_INET(地址簇)PF_INET(协议簇)inet_pton(AF_INET,ip,&address.sin_addr);address.sin_port=htons(port);//创建套接字int listenfd=socket(PF_INET,SOCK_STREAM,0);assert(listenfd>=0);//绑定地址ret=bind(listenfd,(struct sockaddr*)&address,sizeof(address));assert(ret!=-1);//监听ret=listen(listenfd,5);assert(ret!=-1);struct epoll_event events[MAX_EVENT_NUMBER];int epollfd=epoll_create(5);assert(epollfd!=-1);addfd(epollfd,listenfd,true);while(1){int ret=epoll_wait(epollfd,events,MAX_EVENT_NUMBER,-1);if(ret == -1) continue;//lt(events,ret,epollfd,listenfd); // 使用LT模式et(events,ret,epollfd,listenfd); // 使用ET模式}close(listenfd);return 0;
}
2.client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>#define BUFFER_SIZE 10int main(int argc, char *argv[]) {if (argc != 3) {printf("使用方法: %s <服务器 IP 地址> <服务器端口号>\n", argv[0]);return -1;}const char *server_ip = argv[1];int server_port = atoi(argv[2]);// 创建套接字int sockfd = socket(PF_INET, SOCK_STREAM, 0);if (sockfd == -1) {perror("socket");return -1;}// 配置服务器地址struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_port = htons(server_port);if (inet_pton(AF_INET, server_ip, &server_addr.sin_addr) <= 0) {perror("inet_pton");close(sockfd);return -1;}// 连接服务器if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {perror("connect");close(sockfd);return -1;}printf("已连接到服务器 %s:%d\n", server_ip, server_port);char send_buffer[BUFFER_SIZE];char recv_buffer[BUFFER_SIZE];while (1) {// 从标准输入读取数据printf("请输入要发送的数据(输入 'quit' 退出): ");fgets(send_buffer, BUFFER_SIZE, stdin);send_buffer[strcspn(send_buffer, "\n")] = 0; // 去除换行符if (strcmp(send_buffer, "quit") == 0) {break;}// 发送数据到服务器ssize_t send_len = send(sockfd, send_buffer, strlen(send_buffer), 0);if (send_len == -1) {perror("send");break;}// 接收服务器的响应ssize_t recv_len = recv(sockfd, recv_buffer, BUFFER_SIZE - 1, 0);if (recv_len == -1) {perror("recv");break;} else if (recv_len == 0) {printf("服务器关闭连接\n");break;}recv_buffer[recv_len] = '\0';printf("收到服务器响应: %s\n", recv_buffer);}// 关闭套接字close(sockfd);return 0;
}
3.编译
gcc epolltest.c -o epolltest
gcc client.c -o client
4.运行
本案例的代码将缓冲区大小仅设为5字节,因此左侧每次接收5个字节,客户端一次发送,服务器多次接收打印。