并发程序设计3:多路IO复用技术(2)
上一节(https://www.cnblogs.com/yuanwebpage/p/12362876.html)记录了多路IO复用的第一种方式select函数,以及其相应的缺点。本节记录多路IO复用的第二种方式epoll(在windows系统下叫IOCP)。
1. epoll相关函数
epoll函数克服了select函数的相关缺点,其优点如下:
(1) 只需向OS注册一次文件描述符集合,不用每次循环传递;
(2) epoll函数会将发生变化的文件描述符单独集中起来,这样每次遍历时只需要遍历发生变化的文件描述符。
(3) 相对于select同时监听的数量有限制,epoll监听数量一般远大于select,这对于多连接的服务器至关重要。
epoll用来集中通知变化的文件描述符结构体如下:
struct epoll_event { __uint32_t events; //用来注册是什么事件需要关注,如输入/输出 epoll_data_t data; } typedef union epoll_data { void* ptr; int fd; //发生变化的文件描述符 __uint32_t u32; __uint64_t u64; } epoll_data_t; //可以看到,常用的为events, fd两个
epoll相关的函数总共有3个:
#include <sys/epoll.h> int epoll_create(int size); //向OS申请创建管理所有文件描述符的epoll例程,返回该例程的文件描述符 size:可能注册的最大监视事件,仅供OS参考 int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event); //成功返回0,失败-1,用于添加/删除/修改某个文件描述符 epfd:epoll_create返回的该epoll例程的描述符 op:具体的操作,如添加/删除,常用以下三种模式 EPOLL_CTL_ADD:将fd的描述符注册到epfd,等价于FD_SET EPOLL_CTL_DEL:将fd的描述符从epfd移出,等价于FD_CLR EPOLL_CTL_MOD:修改fd所指描述符的监听类型
fd: 待添加的的socket
event:struct epoll_event结构体,内有一个event变量,指明需要监听的具体类型 EPOLLiN:输入事件 EPOLLOUT:输出事件 EPOLLET:以边缘触发方式接收事件通知(稍后详述) int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout); //类似于select,调用后监听发生变化的描述符,成功时返回发生事件的个数 epfd:epoll例程描述符 events:动态申请的结构体数组,用于保存,通知发生变化的文件描述符 maxevents:监视的最大事件数目,即events数组的大小 timeout:设置超时,单位为ms。-设置-1为无限等待
使用完了epoll例程后记得调用close()关闭。
有了以上epoll相关函数就很容易将之前两节的回声服务器服务器端改写为epoll形式的IO复用,完整代码如下:
1 #include <stdlib.h> 2 #include <stdio.h> 3 #include <string.h> 4 #include <sys/socket.h> 5 #include <arpa/inet.h> 6 #include <unistd.h> 7 #include <sys/epoll.h> 8 #define EPOLL_SIZE 30 //定义监视事件的数组数量 9 10 void error_handle(const char* msg) 11 { 12 fputs(msg,stderr); 13 fputc('\n',stderr); 14 exit(1); 15 } 16 17 int main(int argc,char* argv[]) 18 { 19 //服务器建立连接 20 int servsock,clntsock; 21 struct sockaddr_in servaddr,clntaddr; 22 char message[50]; 23 socklen_t clntlen; 24 25 if(argc!=2) 26 error_handle("Please input port number"); 27 28 servsock=socket(PF_INET,SOCK_STREAM,0); //1.建立套接字 29 30 memset(&servaddr,0,sizeof(servaddr)); 31 servaddr.sin_family=AF_INET; 32 servaddr.sin_addr.s_addr=htonl(INADDR_ANY); //默认本机IP地址 33 servaddr.sin_port=htons(atoi(argv[1])); 34 35 if(bind(servsock,(struct sockaddr*)&servaddr,sizeof(servaddr))==-1) 36 error_handle("bind error"); //2.建立连接 37 38 if(listen(servsock,10)==-1) //3.监听建立 39 error_handle("listen() error"); 40 41 //到此的代码为socket创建过程,与前两节相同 42 43 //关于epoll的相关函数 44 epoll_event event; 45 event.events=EPOLLIN; //输入监听 46 event.data.fd=servsock; 47 int epfd=epoll_create(20); //创建epoll例程 48 epoll_ctl(epfd,EPOLL_CTL_ADD,servsock,&event); //向epfd添加fd的输入监听事件 49 struct epoll_event* events; 50 events=(epoll_event*)malloc(sizeof(struct epoll_event)*EPOLL_SIZE); //申请存放发生事件的数组 51 while(1) 52 { 53 int event_cnt=epoll_wait(epfd,events,EPOLL_SIZE,-1); //设置无限等待 54 if(event_cnt==-1) 55 printf("epoll_wait() error"); 56 for(int i=0;i<event_cnt;i++) 57 { 58 if(events[i].data.fd==servsock) //说明是新的客户端请求 59 { 60 clntlen=sizeof(clntaddr); 61 clntsock=accept(servsock,(struct sockaddr*)&clntaddr,&clntlen); 62 if(clntsock==-1) //accept错误 63 { 64 close(clntsock); 65 continue; 66 } 67 event.events=EPOLLIN; 68 event.data.fd=clntsock; 69 epoll_ctl(epfd,EPOLL_CTL_ADD,clntsock,&event); //将新申请的连接加入epfd 70 printf("connecting\n"); 71 } 72 else //客户端发来消息 73 { 74 int strlen=read(events[i].data.fd,message,sizeof(message)); 75 if(strlen==0){ //关闭连接请求 76 epoll_ctl(epfd,EPOLL_CTL_DEL,events[i].data.fd,NULL); //从epfd中删除 77 close(events[i].data.fd); 78 } 79 else 80 write(events[i].data.fd,message,strlen); 81 } 82 } 83 } 84 close(epfd); //关闭epoll例程 85 close(servsock); 86 return 0; 87 }
注,代码中用malloc申请的events数组,也可以直接申请struct epoll_event events[EPOLL_SIZE];
2. 条件触发和边缘触发
上面在提到的在epoll_ctl加入新的描述符时,有一个选项:EPOLLET,即以边缘触发方式响应,1中代码的方式是条件触发,那么条件触发和边缘触发有什么区别呢?条件触发只要输入缓冲中有数据,epoll_wait就能检测到并将其注册到通知描述符的events数组中;边缘触发只在接收缓冲进入数据时在events数组中注册一次,此后缓冲区的数据即时没读完,仍不再注册。举个例子,假设输入缓冲中来了20个字节的数据,每次读取4个字节,那么条件触发方式在循环时每次调用epoll_wait()都向events数组注册输入事件,边缘触发只在数据进入时触发一次,此后不再触发。如果按照这种方式工作,边缘触发将使输入缓冲的数据不断累积,造成溢出(PS:select函数其实可以算作条件触发)。
那么怎么检测输入缓冲中是否有数据呢?Linux在<error.h>中声明了全局变量errno,read函数发现输入缓冲中没有数据时返回-1,同时在errno中保存EAGAIN常量。那么怎么设置为边缘触发模式呢?这里要用到fcntl函数:
#include <fcntl.h>
void setnonblock(int fd)
{
int flag=fcntl(fd, F_GETFL, 0); //获取描述符fd的属性
fcntl(fd, F_SETFL, flag|O_NONBLOCK); //添加非阻塞属性
}
为了将条件触发改成边缘触发,在1中完整的代码上进行以下改动
头文件中添加 #include <fcntl.h> #include <error.h> 45行: event.events=EPOLLIN|EPOLLET; 在46行之后添加: setnonblock(servsock); 67行: event.events=EPOLLIN|EPOLLET; 68行之后添加: setnonblock(clntsock); 此后在接收客户端消息的代码如下: else //接收客户端消息 { while(1) //循环读写直到输入缓冲为空 { int strlen=read(events[i].data.fd,message,sizeof(message)); //read函数此时不再阻塞 if(strlen==0){ epoll_ctl(epfd,EPOLL_CTL_DEL,events[i].data.fd,NULL); //从epfd中删除 close(events[i].data.fd); } else if(strlen<0) { if(errno==EAGAIN) break; } else write(events[i].data.fd,message,strlen); } }
关于边缘触发和条件触发的优缺点和应用场景,目前还没有发现比较好的资料。