我们已经介绍过了select、poll和epoll这3种I/O复用系统调用函数的使用和原理,且基于I/O复用实现单进程(单线程)下的服务器 连接多客户端。
3种I/O复用系统调用的异同点
这3种I/O复用系统调用的相同点:它们都能同时监听多个文件描述符 ,它们将等待由timeout参数指定的超时时间,直到一个或者多个文件描述符上有事件发生时返回,返回值是就绪的文件描述符的数量,返回 0 表示没有事件发生。
这3种I/O复用系统调用的不同点:主要是从事件集合、最大支持文件描述符数、工作模式和具体实现等四个方面进一步比较它们的异同,以明确在实际应用中应该选择使用哪个(或哪些)。
主要区别看如下图,就很清楚了
以上3种系统调用函数都是通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类別的参数来获取内核处理的结果。如下所示:
- select 的参数类型 fd_set 没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此 select 需要提供3个这种类型的参数来分别传入和输出可读、可写及异常等事件。这一方面使得 select 不能处理更多类型的事件,另一方面由于内核对 fd_set 集合的在线修改,应用程序下次调用 select 前不得不重置这3个 fd_set 集合。
- poll 的参数类型 pollfd 则多少“聪明” 一些,它把文件描述符和事件都定义其中,任何事件都被统一处理,从而使得编程接口简洁得多,并且内核每次修改的是 pollfd 结构体的 revents 成员,而 events 成员保持不变,因此下次调用poll时应用程序无须重置 pollfd 类型的事件集参数。 (这点就非常重要)
注:但是由于每次 select 和 poll 调用都返回整个用户注册的事件集合(其中包括就绪的和未就绪的),所以应用程序索引就绪文件描述符的时间复杂度为O (n)。(这也是二者相对于epoll的缺陷所在)
- 而epoll 则采用与 select 和 poll 完全不同的方式来管理用户注册的事件。它在内核中维护一个事件表,并提供了一个独立的系统调用 epoll_ctl 来控制往其中添加、删除、修改事件。这样,每次 epoll_wait 调用都直接从该内核事件表中取得用户注册的事件,而无须反复从用户空间读入这些事件,epoll_wait 系统调用的 events 参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度达到O (1)。从实现原理上来说,select和poll采用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是O (n)。epoll_wait 则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列,内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此epoll_wait 无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度是O (1)。
注:poll 和 epoll_wait 分别用 nfds 和 maxevents 参数指定最多监听多少个文件描述符和事件。这两个数值都能达到系统允许打开的最大文件描述符数即65535(cat/proc/sys/fs/filc-max)。而 select 允许监听的最大文件描述符数量通常有限制。虽然用户可以修改这个限制,但这可能导致不可预期的后果。
此外,select和poll都只能工作在相对低效的LT模式,而epoll则可以工作在ET高效模式(当然也是可以工作在相对低效的LT模式的)。并且epoll还支持EPOLLONESHOT事件,该事件能进一步减少可读、可写和异常等事件被触发的次数。但是,当活动连接比校多的时候,epoll_wait 的效率未必比 select 和 poll 高,因为此时回调函数被触发得过于频繁,所以epoll_wait 适用于连接数量多,但活动连接较少的情况。 (这也就说明了,并非epoll就绝对碾压select和poll,使用时 就需要挑选最合适该问题场景的I/O复用系统调用函数)
EPOLLONESHOT事件
这部分呢 本来不想再做了,前几天 面试的时候,又有面试官提及到 所以这个就稍微探讨一下。也作为高性能服务器编程之I/O复用的一个收尾部分吧!
之前我们也说过,epoll 可以工作在ET高效模式(当然也是可以工作在相对低效的LT模式的)。但是在ET模式下,一个socket上的某个事件还是可能被多次触发,这样就会在并发程序中 引起一个问题。
问题如下:一个线程(进程)在读取完某个socket上的数据后 开始处理这些数据,而在处理数据的过程中 该socket上又有新数据可读(RPOLLIN事件被再次触发),此时另外一个线程(进程)就会被唤醒去读取这些新的数据,于是就出现了两个线程(进程)同时操作一个socket的局面。
而我们所期望的是:一个socket连接在任一时刻 都只被一个线程(进程)处理,而这一点就需要使用到epoll的EPOLLONESHOT事件。
因为对于 注册了EPOLLONESHOT事件的文描,OS最多触发其上注册的一个可读、可写或异常事件,且只触发一次。(除非我们使用epoll_ctl函数重置 该文描上注册的EPOLLONESHOT事件)这样的话,当一个线程在处理某个socket时,其他线程是不可能有机会来操作该socket的。 但与此同时,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕之后,该线程就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被再次触发,进而让其他工作线程有机会继续处理这个socket。
对之前的代码稍加修改,源代码如下:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>
#include<sys/epoll.h>
#include<errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <sys/types.h>
#include <fcntl.h>
#include <pthread.h>
#define MAX 100
int create_socket();
struct fds{//文件描述符结构体,用作传递给子线程的参数
int epollfd;
int sockfd;
};
void setnonblock(int fd)//设置非阻塞
{
int old_fcntl = fcntl(fd,F_GETFL);
int new_fcntl = old_fcntl | O_NONBLOCK;
if ( fcntl(fd,F_SETFL,new_fcntl) == -1 )
{
perror("fcntl error");
}
}
void epoll_add(int epfd,int fd,int oneshot)//为文件描述符添加事件
{
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLET;
if(oneshot==1)//采用EPOLLONETSHOT事件
{
ev.events|=EPOLLONESHOT;
}
if ( epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev) == -1 )
{
perror("epoll_ctl add error");
}
setnonblock(fd);
}
void epoll_del(int epfd, int fd)
{
if ( epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL) == -1 )
{
perror("epoll_ctll del error");
}
}
void reset_oneshot(int epfd,int fd)//重置事件
{
struct epoll_event ev;
ev.data.fd=fd;
ev.events=EPOLLIN|EPOLLET|EPOLLONESHOT;
epoll_ctl(epfd,EPOLL_CTL_MOD,fd,&ev);
}
//工作者线程(子线程)接收socket上的数据并重置事件
void* workerfun(void* arg)
{
int sockfd=((struct fds*)arg)->sockfd;
int epollfd=((struct fds*)arg)->epollfd;//事件表描述符从arg参数(结构体fds)得来
printf("start new thread to receive data on fd=%d\n:",sockfd);
while(1)
{
char buff[128] = {0};
int num=recv(sockfd,buff,127,0);//接收数据
if(num==0){//关闭连接
epoll_del(epollfd,sockfd);
close(sockfd);
printf("one client over\n");
break;
}
else if(num<0)
{
if(errno==EAGAIN){//并非网络出错,而是可以再次注册事件
reset_oneshot(epollfd,sockfd);
printf("reset epollfd\n");
break;
}
}
else
{
printf("buff=%s\n",buff);
sleep(5);//采用睡眠是为了在5s内若有新数据到来则该线程继续处理,否则线程退出
}
}
send(sockfd,"ok",2,0);
//cout<<"thread exit on fd:"<<sockfd;
//_exit(0);//这个会终止整个进程!!
}
int main()
{
int sockfd = create_socket();
assert( sockfd != -1 );
//创建内核事件表
int epfd = epoll_create(MAX);
assert( epfd != -1 );
//不能将监听端口sockfd设置为EPOLLONESHOT否则会丢失客户连接
epoll_add(epfd,sockfd,0);
struct epoll_event evs[10];
while( 1 )
{
int n = epoll_wait(epfd,evs,10,5000);
if ( n == -1 )
{
perror("epoll wait error\n");
continue;
}
else if ( n == 0 )
{
printf("time out\n");
continue;
}
else
{
int i = 0;
for( ;i < n; i++)
{
if ( evs[i].events & EPOLLIN )
{
if ( evs[i].data.fd == sockfd )//监听socket
{
struct sockaddr_in caddr;
int len = sizeof(caddr);
int c = accept(sockfd,
(struct sockaddr*)&caddr,&len);
if ( c < 0 )
{
continue;
}
printf("accept c =%d\n",c);
epoll_add(epfd,c,1);//向内核事件表中添加连接描述副
}
else//客户端有数据发送的事件发生
{
pthread_t thread;
struct fds worker;
worker.epollfd=epfd;
worker.sockfd=evs[i].data.fd;
//调用工作者线程处理数据
pthread_create(&thread,NULL,workerfun,(void*)&worker);
}
}
//if ( evs[i].events & EPOLLOUT)
}
}
}
}
int create_socket()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if ( sockfd == -1 )
{
return -1;
}
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
if ( res == -1 )
{
return -1;
}
if( listen(sockfd,5) == -1 )
{
return -1;
}
return sockfd;
}
效果展示如下: