这一章的目的与I/O复用有关,之前我们 谈到的基于select函数实现的I/O复用技术,由于该技术是一个非常古老的技术,不适用于现代高并发的环境下的使用。因此需要借用新技术来替代select函数的I/O复用。
epoll理解与应用
理解epoll还需对照select函数进行对比,一般实现复用主要有三步:
- 创建保存文件描述符的空间
- 向这个空间注册或注销文件描述符
- 监控空间内的文件描述符的变化
不管是之前的select函数 还是这章要讲的epoll复用技术,都是按照上述三个步骤来展开的。
由于select函数每次都要循环遍历空间内的所有文件描述符来知悉那些描述符有变化,同时每次都要向操作系统传递监视的文件描述符信息,当加入的文件描述符增加到成百上千时,这个时候采用select函数就达到了性能瓶颈。这也就是epoll出现的必要性。epoll主要有以下优点:
- 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句。
- 调用对应于select函数的epoll_wait函数时无需每次传递监视对象的信息。
简而言之,epoll是一种基于事件通知机制,而select复用是采用轮询机制,因此在设计理念上就体现着epoll更加适合实际中的使用情况。
接下来介绍实现epoll时必要的函数与结构体。
-
相关结构体
**select()**函数通过fd_set变量保存文件描述符,但epoll模式下操作系统负责保存监视对象的文件描述符。select函数通过fd_set集合查看发生变化的文件描述符,而epoll是通过epoll_event结构体将发生变化的文件描述符单独集合在一起。
struct epoll_event { __uint32_t event; epoll_data_t data; } typedef union epoll_data { void *ptr; int fd; __uint32_t u32; __uint64_t u64; }epoll_data_t;
-
必要函数(对应于I/O复用的三个过程)
-
创建保存文件描述符空间的函数:epoll_create()
#include<sys/epoll.h> int epoll_create(int size); //函数调用成功,返回epoll文件描述符。失败返回-1
由epoll_create函数申请的空间称为“例程”,例程大小由size决定(不一定,最终还是得操作系统决定),例程被操作系统管理,返回一个文件描述符用以区分不同的例程,当我们需要关闭时,也是采用close进行关闭。
-
向内存空间注册、注销文件描述符:epoll_ctl()
#include<sys/epoll.h> int epoll_ctl(int epfd,int op, int fd, struct epoll_event *event); /* epfd: 例程文件描述符 op: 操作参数,用于区分对监控对象进行何种操作(增加、删除、修改) fd: 需要注册的监视对象文件描述符 event:监视对象的事件类型 */
第二个参数主要是以下几个常量参数:
- EPOLL_CTL_ADD:将文件描述符注册到epoll例程
- EPOLL_CTL_DEL: 将文件描述符从例程中删除,此时,第四个参数应该为NULL。
- EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况。
第四个参数类型是我们上面展示的结构体,但是我们之前说过,这个结构体用来保存已经发生变化的文件描述符,这里为啥需要我们自己填写相关描述符信息(不一定发生变化)。其实该结构体可以用来保存我们希望关注的文件描述符(不管它发不发生变化)。如果不能理解,这里给个小例子:
struct epoll_event event; ..... event.events=EPOLLIN;// 当发生需要读取数据的情况(事件)时 event.data.fd=sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event); .....
上述代码将sock注册到epoll例程中,并且当需要读数据的情况下产生相应事件。以下是events事件类型:
- EPOLLIN:需要读取数据的情况
- EPOLLOUT:输出缓冲为空,可以立即发送数据的情况。
- EPOLLPRI:收到OOB数据的情况。
- EPOLLRDHUP:断开连接或者半关闭的情况,这是在边缘触发方式下非常有用(边缘触发接下来会讲)
- EPOLLERR:发送错误的情况 。
- EPOLLET:以边缘触发的方式得到事件通知。
- EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。
上述类型可以使用位或运算使用多个类型。
如果听得有点晕,我们可以通过以下口语话的例子进行理解。
epoll_ctl(A, EPOLL_CTL_ADD, B, C);
epoll例程A中注册文件描述符B,主要目的是为了监视参数C中的事件。
epoll_ctl(A, EPOLL_CTL_DEL, B, NULL);
从例程A中删除文件描述符B
-
监控空间内文件描述符的变化:epoll_wait()
#include<sys/epoll.h> int epoll_wait(int epfd,struct epoll_event *events , int maxevents,int timeout); //epfd: 表示事件发送监视范围的epoll例程的文件描述符。 //events: 保存发送事件的文件描述符集合的结构体地址值。这个地址需要我们在程序中动态申请。 //maxevents:第二个参数可以保存的最大事件数。 //timeout: 以1/1000秒为单位的等待时间,当为-1时表示一直等待 。 //函数返回发生事件的文件描述符数量,同时在events将会指向保存发生事件的文件描述符集合的缓冲区域。
使用epoll改写回声服务端
//epoll_serve.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/epoll.h> #define BUF_SIZE 100 //define BUF_SIZE 4 #define EPOLL_SIZE 50 void error_handling(char *message); int main(int argc, char const *argv[]) { int serv_sock,clnt_sock; struct sockaddr_in serv_adr,clnt_adr; socklen_t adr_sz; char buf[BUF_SIZE]; int str_len,i; struct epoll_event *ep_events; struct epoll_event event; int epfd,event_cnt; if(argc!=2) { printf("Usage: %s <port>\n",argv[0]); exit(1); } 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,(struct sockaddr*)&serv_adr,sizeof(serv_adr)))==-1)error_handling("bind() error"); if((listen(serv_sock,5))==-1)error_handling("listen() error"); epfd=epoll_create(EPOLL_SIZE); /** * @brief 动态申请内存 * @note * @param epoll_event: * @retval */ ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE); event.events= EPOLLIN; event.data.fd=serv_sock; epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,&event); while(1) { /** * @brief epoll_wait等待监听事件的发生,将发生变化的文件描述符保存到ep_events指向的空间中 * @note * @retval */ 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++) { /** * @brief 遍历发生变化的文件描述符数组,并进行一一处理 * @note * @retval */ //处理请求连接的事件 if(ep_events[i].data.fd==serv_sock) { adr_sz=sizeof(clnt_adr); clnt_sock=accept(serv_sock,(struct sockaddr*)&clnt_adr,&adr_sz); event.events=EPOLLIN; event.data.fd=clnt_sock; epoll_ctl(epfd,EPOLL_CTL_ADD,clnt_sock,&event); } //处理已经连接好的客户端发过来的信息事件 else { 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); } else{ write(ep_events[i].data.fd,buf,str_len); } } } } close(serv_sock); close(epfd); return 0; } void error_handling(char *message) { fputs(message,stderr); fputc('\n',stderr); exit(1); }
之前写的任意客户端都可以与这个epoll服务端进行通信。故不再贴出客户端代码。
-
条件触发和边缘触发
所谓触发,就是操作系统发通知,通知该事件(指的是注册到发生变化的文件描述符)并进行注册。
条件触发
指的是如果当前状态发生变化就发送通知,这个变化就是缓冲区数据增加或减少。每次变化都会重新注册,可想而知,每次状态变化都会进行一次注册事件。
还是通过代码来说明。
将epoll_serv.c代码中那些被注释掉的代码重新放在程序中就OK
运行结果
*****客户端********
Connected.....
Input message(Q to quit):ZENGWEI123456786444455
Message from server:ZENGWEI123456786444455
*****服务端********
return epoll_wait!
conneted client: 5
return epoll_wait!
return epoll_wait!
return epoll_wait!
return epoll_wait!
return epoll_wait!
return epoll_wait!
结果分析
之所以在程序中减少一次读取的字节数量,防止服务器一次性读完(这就不是条件触发了,这是边缘触发了) 。每当收到客户端的数据时都会注册新的事件并多次调用epoll_wait函数。
边缘触发
指的是处于临界状态,一旦突破就触发,当缓冲区从无数据到突然有了数据,那么就会触发;亦或原来拥有数据,突然被读走变成空,那么也会触发。也就是说这种情况只注册一次。
为了完成边缘触发,我们需要知道两件非常重要的知识:
-
在Linux 的套接字相关的函数一般是通过返回-1来通知发生了错误,但为了确认到底发生了什么错误,Linux设置了一个全局变量errno, 该常量是一个int类型,不同的错误返回不同的错误值,例如:
read函数发现输入缓冲中没有数据可读时返回-1,同时在errno保存EAGAIN常量。
在边缘触发的过程当中,我们接收数据只进行一次注册,一旦发生输入相关事件,就应该读取输入缓冲区的全部内容,因此需要errno来验证输入缓冲区是否为空。
-
为了完成非阻塞I/O,我们需要更改套接字 特性
更改套接字类型的方法是使用fcntl函数
#include<fcntl.h> int fcntl(int fildes, int cmd, ....); //fildes: 需要更改属性的文件描述符 //cmd: 更改选项
fcntl函数功能有点丰富,需要花一点时间去琢磨。
例如我们将文件(套接字)改为非阻塞模式,那么我们需要进行以下语句:
int flag=fcntl(fd,F_GETFL,0); //先获取之前设置的属性 fcntl(fd,F_SET,flag | O_NONBLOCK); //在获取完之前属性之后,在此基础上添加非阻塞属性
在边缘触发的方式下,默认以阻塞方式工作的read()与write()函数可能会导致服务器长时间等待,因此采用非阻塞方式工作的read()与write()函数。
使用边缘触发的回声服务器端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <errno.h>
#include <fcntl.h>
#define BUF_SIZE 4 //将read一次读取的数量从100改为4
#define EPOLL_SIZE 50
void setnoblockingmode(int fd);//设置文件属性
void error_handling(char *message);
int main(int argc, char const *argv[])
{
int serv_sock,clnt_sock;
struct sockaddr_in serv_adr,clnt_adr;
socklen_t adr_sz;
char buf[BUF_SIZE];
int str_len,i;
struct epoll_event *ep_events;
struct epoll_event event;
int epfd,event_cnt;
if(argc!=2)
{
printf("Usage: %s <port>\n",argv[0]);
exit(1);
}
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,(struct sockaddr*)&serv_adr,sizeof(serv_adr)))==-1)error_handling("bind() error");
if((listen(serv_sock,5))==-1)error_handling("listen() error");
epfd=epoll_create(EPOLL_SIZE);
/**
* @brief 动态申请内存
* @note
* @param epoll_event:
* @retval
*/
ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
event.events= EPOLLIN;
event.data.fd=serv_sock;
epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,&event);
while(1)
{
/**
* @brief epoll_wait等待监听事件的发生,将发生变化的文件描述符保存到ep_events指向的空间中
* @note
* @retval
*/
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++)
{
/**
* @brief 遍历发生变化的文件描述符数组,并进行一一处理
* @note
* @retval
*/
//处理请求连接的事件
if(ep_events[i].data.fd==serv_sock)
{
adr_sz=sizeof(clnt_adr);
clnt_sock=accept(serv_sock,(struct sockaddr*)&clnt_adr,&adr_sz);
setnoblockingmode(clnt_sock); //更改套接字属性
event.events=EPOLLIN | EPOLLET;
event.data.fd=clnt_sock;
epoll_ctl(epfd,EPOLL_CTL_ADD,clnt_sock,&event);
printf("conneted client: %d\n",clnt_sock);
}
//处理已经连接好的客户端发过来的信息事件
else
{
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;
}
else if(str_len<0)
{
if(errno==EAGAIN)break;
}
else{
write(ep_events[i].data.fd,buf,str_len);
}
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
void error_handling(char *message)
{
fputs(message,stderr);
fputc('\n',stderr);
exit(1);
}
void setnoblockingmode(int fd)
{
int flag=fcntl(fd,F_GETFL,0);
fcntl(fd,F_SETFL,flag|O_NONBLOCK);
}
边缘触发优点
一句话:
可以分离接收数据和处理数据的时间点
试想这样一个场景:
- 服务器分别从客户端A,B,C接收数据
- 服务端按照A,B,C的顺序重新组合收到的数据
- 组合的数据将发送给任意主机D
要完成以上步骤需要保证以下几点:
- 客户端A,B,C按照顺序连接到服务器并按照顺序一次发给服务器
- 需要从服务器接收数据的客户端应该在客户端A,B,C之前就连接服务端并等待
但是实际情况并不能保证以上两点。而以下场景反而更加符合实际:
- 客户端B,C正在向服务端发送数据,但A并没有连接到服务器
- 客户端A,B,C乱序发送数据
- 服务端接收到数据,但是接收数据的客户端此时并没有连接到服务器
因此基于以上对场景的分析,采用边缘触发的优点就显而易见:输入缓冲收到数据(注册相应的事件),服务端能决定读取数据并处理数据(比如发送给另外一个客户端)的时间点就有着非常大的灵活性。
难道条件触发不能实现以上场景需求吗?
能,但是每次来数据都要注册一遍事件,当客户端成百上千给服务端发送数据,这感觉就非常酸爽了!