1.多进程服务器的缺点
为了构建并发服务器,只要有客户端连接请求就会创建新进程。
但创建新进程需要付出极大的代价,这需要大量的运算和内存空间。
而且由于每个进程都具有独立的内存空间,所以相互间的数据交换也要求采用相对复杂的方法。
(IPC属于相对复杂的通信方法)。多进程服务器模型如下图:
引入复用技术,可以减少进程数。而且,无论连接多少客户端,提供服务的进程只有一个。
如下图:
2.调用select函数
1.select函数的功能
使用select函数可以将多个文件描述符集中到一起统一监视,监视的项目如下:
1.是否存在套接字接收数据
2.是否存在套接字传输数据
3.那些套接字发生了异常
以上监视项称为事件(event)。发生监视项对于情况,称发生了事件。
2.select函数的调用过程
调用过程如图:
(1)设置文件描述符
利用select函数可以同时监视多个文件描述符。监视文件描述符即监视其绑定的套接字。
首先需要将要监视的文件描述符集中到一起。集中时需要按照监视项分组,分成接收、传输、异常3类。
这三组都使用fd_set数组变量保存,fd_set数组是由二进制位数组元素组成的数组,可将其视为一个文件描述符的集合。
在<sys/select.h>总定义了一个常量FD_SETSIZE,默认为1024,也就是说在这个集合内默认最多有1024个文件描述符。
fd_set数据结构如下图:
在select函数调用之前,该数组的位序i显示的是第i个文件描述符是否要被监控的情况(i从0开始)。如果想要该文件描述符在下面的程序中被监视,则将其所在fd_set数组中的数组元素置为1。反之,置为0。置1的操作可以看成向fd_set注册监视对象。
上图,文件描述符1和文件描述符3将要在下面的程序被监视。
在select函数调用之后,当数组之前为1的位仍然为1,那么说明该位的文件描述符发生了该fd_set组所表示的事件。
使用宏来完成在fd_set变量中注册或更改值的操作:
前三个宏的使用结果:
(2)设置监视范围及超时
1.select函数
int select(int maxfd,fd_set* readset,fd_set* writeset,
fd_set* exceptset,const struct timeval* timeout);
//成功时返回大于0的值,失败时返回-1
(1)maxfd
监视对象文件描述符的数量,注意看参数maxfd很容易以为是最大的文件描述符,其实是最大的文件描述符+1。
文件描述符从0开始。
(2)readset
将所有关注“是否存在待读取数据”的文件描述符注册到fd_set型变量,并传递其地址值。
(3)writeset
将所有关注“是否存在待传输数据”的文件描述符注册到fd_set型变量,并传递其地址值。
(4)exceptset
将所有关注“是否发生异常”的文件描述符注册到fd_set型变量,并传递其地址值。
(5)timeout
调用select函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息。
(6)返回值
发生错误时返回-1,超时返回时返回0。因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符个数。
2.文件描述符的监视范围
文件描述符的监视范围与select函数的第一个参数有关。因为每次新建文件描述符时,其值都是已有最大的文件描述符的值+1。所以只需将最新建的文件描述符加1传递到select函数即可。
加1是因为文件描述符从0开始。
3.select函数的超时时间
select函数的超时时间与select函数的最后一个参数有关,其timeval结构体定义如下:
select函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。
指定超时时间就是为了防止这种情况的发生。
将秒数和毫秒数分别填入上面结构的tv_sec和tv_usec的成员,然后将该该结构体的地址值传递到select函数的最后一个参数。此时,即使文件描述符未发生变化,只要过了指定的时间,也可以从函数中返回。但这是函数会返回0。
如果不想设置超时,传递NULL即可。
(3)调用select函数后查看结果
select函数调用后,向其传递的fd_set变量发生变化。除了发生变化的文件描述符对应位,其他所有原来为1的位均变为0,因此,值仍为1的位置上的文件描述符发生了变化。
代码示例:
//select.c
#include<stdio.h>
#include<unistd.h>
#include<sys/time.h>
#include<sys/select.h>
#define BUF_SIZE 50
char p[BUF_SIZE];
int main(int argc,char *argv[])
{
fd_set reads;
FD_ZERO(&reads);
//将文件描述符0置1,文件描述符0是标准输入
FD_SET(0,&reads);
struct timeval timeOut;
while(1)
{
fd_set temp=reads;
timeOut.tv_sec=5;
timeOut.tv_usec=0;
int result=select(1,&temp,0,0,&timeOut);
if(result==-1)
{
puts("select() error!");
break;
}
else if(result==0)
{
puts("time-Out!\n");
}
else
{
if(FD_ISSET(0,&temp))
{
int readLen=read(0,p,BUF_SIZE-1);
p[readLen]=0;
printf("message from console: %s\n",p);
}
}
}
return 0;
}
两次运行结果:
3.I/O复用服务器端
//select_server.c
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/time.h>
#include<sys/select.h>
#define BUF_SIZE 100
char p[BUF_SIZE];
void printMess(char* mess)
{
fputs(mess,stderr);
fputc('\n',stderr);
exit(1);
}
int main(int argc,char *argv[])
{
if(argc!=3)
printMess("argc error!");
int serverSock=socket(PF_INET,SOCK_STREAM,IPPROTO_TCP);
if(serverSock==-1)
printMess("socket() error!");
struct sockaddr_in serverAddr;
memset(&serverAddr,0,sizeof(serverAddr));
serverAddr.sin_family=AF_INET;
serverAddr.sin_port=htons(atoi(argv[1]));
serverAddr.sin_addr.s_addr=inet_addr(argv[2]);
if(bind(serverSock,(struct sockaddr*)&serverAddr,sizeof(serverAddr))==-1)
printMess("bind() error!");
if(listen(serverSock,5)==-1)
printMess("listen() error!");
fd_set reads;
FD_ZERO(&reads);
FD_SET(serverSock,&reads);
int maxLen=serverSock+1;
struct timeval timeOut;
struct sockaddr_in clientAddr;
socklen_t clientAddrLen=sizeof(clientAddr);
while(1)
{
timeOut.tv_sec=5;
timeOut.tv_usec=0;
fd_set temp=reads;
int result=select(maxLen,&temp,0,0,&timeOut);
if(result==-1)
break;
else if(result==0)
continue;
else
{
int i;
for(i=0;i<maxLen;i++)
{
if(FD_ISSET(i,&temp))
{
if(i==serverSock)
{
int clientSock=accept(serverSock,(struct sockaddr*)&clientAddr,&clientAddrLen);
if(clientSock==-1)
printMess("accept() error!");
printf("Client %d connected.....\n",clientSock);
FD_SET(clientSock,&reads);
if(maxLen-1<clientSock)
maxLen=clientSock+1;
}
else
{
int readLen=read(i,p,BUF_SIZE-1);
p[readLen]=0;
if(readLen==0)
{
FD_CLR(i,&reads);
close(i);
printf("Client %d disconnected.....\n",i);
}
else
{
write(i,p,strlen(p));
}
}
}
}
}
}
close(serverSock);
return 0;
}
客户端4结果:
客户端5结果:
服务端:
那个q^H是我没注意在服务端上打出来,对程序无影响,忽略就行。
4.优于select的epoll
利用select复用方法,无论怎么优化程序性能也无法同时接入上百个客户端。
1.基于select的I/O复用技术速度慢的原因
(1)调用select函数后常见的针对所有文件描述符的循环语句
调用select函数后,并不是把发生变化的文件描述符单独集中到一起,而是通过观察作为监视对象的fd_set变量的变化,找出发生变化的文件描述符,因此无法避免针对所有监视对象的循环语句。
(2)每次调用select函数时都需要向该函数传递监视对象信息
作为监视对象的fd_set变量会发生变化,所以调用select函数前应复制并保存原有信息,并在每次调用select函数时传递新的监视对象信息。
相比于循环语句,更大的障碍是每次传递监视对象信息。
因为每次调用select函数时向操作系统传递监视对象信息,应用程序向操作系统传递数据将对程序造成很大负担,而且无法通过优化代码解决。
select函数与文件描述符有关,更准确地说,是监视套接字变化的函数。而套接字是由操作系统管理的,所以select函数绝对需要借助于操作系统才能完成功能。select函数的这一缺点可以通过如下方式弥补:
仅向操作系统传递1次监视对象,监视范围或内容发生变化时只通知发生变化的事项。
这样就无需每次调用select函数时都向操作系统传递监视对象信息,但前提是操作系统支持这种处理方式(每种操作系统支持的程度和方式存在差异)。Linux的支持方式是epoll,Windows的支持方式是IOCP。
2.epoll服务器的3个函数
1.epoll_create:创建保存epoll文件描述符的空间
select方式中为了保存监视对象文件描述符,直接声明了fd_set变量。但epoll方式下由操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时使用的函数就是epoll_create。 调用epoll _create函数时创建的文件描述符保存空间称为epoll例程。
epoll_create函数创建的资源与套接字相同,也由操作系统管理。因此,该函数和创建套接字的情况相同,也会返回文件描述符。也就是说,该函数返回的文件描述符主要用与于区分epoll例程。需要终止时,与其他文件描述符相同,也要调用close函数。
int epoll_create(int size);
//成功时返回epoll文件描述符,失败时返回-1
(1)size
epoll实例的大小
2.epoll_ctl:向空间注册并注销文件描述符
为了添加和删除监视对象文件描述符,select方式中需要FD_SET、FD_CLR函数。但在epoll方式中,通过epoll_ctl函数请求操作系统完成。
先来介绍epoll_event结构体,如下:
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);
//成功返回0,失败时返回-1
(1)epfd
用于注册监视对象的epoll例程的文件描述符
(2)op
用于指定监视对象的添加、删除和更改等操作,可填入下面的宏:
(3)fd
需要注册的监视对象文件描述符
(4)event
监视对象的事件类型
epoll_event结构体不仅用于保存发生事件的文件描述符集合,也可以在epoll例程中注册文件描述符时,用于注册关注的事件。如下:
成员events可能的值有:
可以通过位或运算同时传递多个上述参数。
3.epoll_wait:与select函数类似,等待文件描述符发送变化
select方式下调用select函数等待文件描述符的变化,而epoll中调用epoll_wait函数。还有,select方式中通过fd_set变量查看监视对象的状态变化(事件发生与否),而epoll方式中通过结构体epoll_event将发生变化的(发生事件的)文件描述符单独集中到一起。
int epoll_wait(int epfd,struct epoll_event *events,
int maxevents,int timeout);
//成功时返回发生事件的文件描述符数,失败返回-1
(1)epfd
表示事件发生监视范围的epoll例程的文件描述符
(2)events
保存发生事件的文件描述符集合的结构体地址值
(3)maxevents
第二个参数可以保存的最大事件数
(4)timeout
以毫秒为单位的等待时间,传递-1时,一直等待直到发生事件
如下:
声明足够大的epoll event结构体数组后,传递给epoll_wait函数时,发生变化的文件描述符信息将被填人该数组。因此,无需像select函数那样针对所有文件描述符进行循环。
调用函数后,返回发生事件的文件描述符数,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。因此,无需像select那样插人针对所有文件描述符的循环。
服务器代码:
#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 EPOLL_SIZE 50
char p[BUF_SIZE];
void printMess(char *mess)
{
fputs(mess,stderr);
fputc('\n',stderr);
exit(1);
}
int main(int argc,char *argv[])
{
if(argc!=3)
printMess("argc error!");
int serverSock=socket(PF_INET,SOCK_STREAM,IPPROTO_TCP);
if(serverSock==-1)
printMess("socket() error!");
struct sockaddr_in serverAddr;
memset(&serverAddr,0,sizeof(serverAddr));
serverAddr.sin_family=AF_INET;
serverAddr.sin_port=htons(atoi(argv[1]));
serverAddr.sin_addr.s_addr=inet_addr(argv[2]);
if(bind(serverSock,(struct sockaddr*)&serverAddr,sizeof(serverAddr))==-1)
printMess("bind() error!");
if(listen(serverSock,5)==-1)
printMess("listen() error!");
int epfd=epoll_create(EPOLL_SIZE);
struct epoll_event *pevents=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
struct epoll_event event;
event.events=EPOLLIN;
event.data.fd=serverSock;
epoll_ctl(epfd,EPOLL_CTL_ADD,serverSock,&event);
struct sockaddr_in clientAddr;
socklen_t clientAddrLen=sizeof(clientAddr);
while(1)
{
int eventCnt=epoll_wait(epfd,pevents,EPOLL_SIZE,-1);
if(eventCnt==-1)
{
puts("epoll_wait error");
break;
}
int i;
for(i=0;i<eventCnt;i++)
{
if(pevents[i].data.fd==serverSock)
{
int clientSock=accept(serverSock,(struct sockaddr*)&clientAddr,&clientAddrLen);
event.events=EPOLLIN;
event.data.fd=clientSock;
epoll_ctl(epfd,EPOLL_CTL_ADD,clientSock,&event);
printf("%d client connected.....\n",clientSock);
}
else
{
int readLen=read(pevents[i].data.fd,p,BUF_SIZE-1);
p[readLen]=0;
if(readLen==0)
{
epoll_ctl(epfd,EPOLL_CTL_DEL,pevents[i].data.fd,NULL);
close(pevents[i].data.fd);
printf("%d client disconnected.....\n",pevents[i].data.fd);
}
else
write(pevents[i].data.fd,p,readLen);
}
}
}
close(serverSock);
close(epfd);
return 0;
}
3.条件触发和边缘触发
条件触发和边缘触发的区别在于发生事件的事件点。
条件触发方式中,只要输入缓冲有数据就会一直通知该事件。
边缘触发则是输入缓冲中收到数据仅注册一次事件。即使输入缓冲中还留有数据,也不会再进行注册。
1.条件触发
epoll默认以条件触发方式工作。
服务器端:
#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 4
#define EPOLL_SIZE 50
char p[BUF_SIZE];
void printMess(char *mess)
{
fputs(mess,stderr);
fputc('\n',stderr);
exit(1);
}
int main(int argc,char *argv[])
{
if(argc!=3)
printMess("argc error!");
int serverSock=socket(PF_INET,SOCK_STREAM,IPPROTO_TCP);
if(serverSock==-1)
printMess("socket() error!");
struct sockaddr_in serverAddr;
memset(&serverAddr,0,sizeof(serverAddr));
serverAddr.sin_family=AF_INET;
serverAddr.sin_port=htons(atoi(argv[1]));
serverAddr.sin_addr.s_addr=inet_addr(argv[2]);
if(bind(serverSock,(struct sockaddr*)&serverAddr,sizeof(serverAddr))==-1)
printMess("bind() error!");
if(listen(serverSock,5)==-1)
printMess("listen() error!");
int epfd=epoll_create(EPOLL_SIZE);
struct epoll_event *pevents=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
struct epoll_event event;
event.events=EPOLLIN;
event.data.fd=serverSock;
epoll_ctl(epfd,EPOLL_CTL_ADD,serverSock,&event);
struct sockaddr_in clientAddr;
socklen_t clientAddrLen=sizeof(clientAddr);
while(1)
{
int eventCnt=epoll_wait(epfd,pevents,EPOLL_SIZE,-1);
if(eventCnt==-1)
{
puts("epoll_wait error");
break;
}
puts("epoll_wait\n");
int i;
for(i=0;i<eventCnt;i++)
{
if(pevents[i].data.fd==serverSock)
{
int clientSock=accept(serverSock,(struct sockaddr*)&clientAddr,&clientAddrLen);
event.events=EPOLLIN;
event.data.fd=clientSock;
epoll_ctl(epfd,EPOLL_CTL_ADD,clientSock,&event);
printf("%d client connected.....\n",clientSock);
}
else
{
int readLen=read(pevents[i].data.fd,p,BUF_SIZE-1);
p[readLen]=0;
if(readLen==0)
{
epoll_ctl(epfd,EPOLL_CTL_DEL,pevents[i].data.fd,NULL);
close(pevents[i].data.fd);
printf("%d client disconnected.....\n",pevents[i].data.fd);
}
else
write(pevents[i].data.fd,p,readLen);
}
}
}
close(serverSock);
close(epfd);
return 0;
}
和上面的代码区别只有两个:
1.BUF_SIZE改为4
2.加入了一条输入语句
客户端:
条件触发就是在epoll_ctl函数注册事件之后,假如注册事件经过epoll_wait之后的一次循环的处理,缓冲区仍然留有读剩下的数据,下一次循环的时候注册的事件仍然会被处理,也就是说这次循环还要继续解决事件,直到事件结束。
如果是边缘触发,epoll_wait之后的处理即使缓冲区还有数据,也不会再处理之前注册的事件,直到该事件再次被注册。
2.边缘触发
将上面的event.events=EPOLLIN改为event.events = EPOLLIN | EPOLLET;可以发现从客户端接收数据时,仅输出一次epoll_wait。
但客户端此时却会发生错误。我写的反正是无论输入啥,都没啥反应。
因为服务器,客户端的read函数阻塞了,导致客户端不管怎么输入都没反应。
我的想法:因为不会再执行该事件的处理,但又因为clientSock已经注册了,因此不会再注册该事件。
而注册的事件是边缘触发,也就是说只会执行一次循环,之后又因为发送端也就是客户端所发送的数据还没有完全被服务器端接收,也就是说客户端没有read函数的调用,因此卡在了write函数的阻塞上。
2.边缘触发
(1)实现边缘触发的必知内容
1.通过errno变量验证错误原因
Linux的套接字相关函数一般通过返回-1通知发送了错误。虽然知道发生了错误,但无法知道错误的原因是什么。因此,为了在发生错误时,了解错误的信息,Linux声明了全局变量int类型的errno。
为了访问改变量,需要引入error.h头文件。另外,每种函数发生错误时,保存到errno变量的值都不同,但没必要记住所有可能的值。
read函数发现输入缓冲中没有数据可读时返回-1,同时在errno中保存EAGAIN常量。
2.为了完成非阻塞(Non-blocking)I/O,更改套接字特性。
Linux提供更改或读取文件属性的方法:
int fcntl(int filedes,int cmd,...);
//成功返回cmd参数相关值,失败时返回-1
(1)filedes
属性更改目标的文件描述符
(2)cmd
表示函数调用的目的
cmd可能的值(还有其他值,但只列举2个):
F_GETFL:获取第一个参数的文件描述符属性
F_SETFL:可以更改文件描述符属性
若希望将文件(套接字)改为阻塞模式,需要如下的语句:
第一条语句获取之前设置的属性信息。
第二条语句在第一条语句基础上添加非阻塞O_NONBLOCK标志。
之后调用read或者write函数时,无论是否存在数据,都会形成非阻塞文件(套接字)。
以阻塞方式工作的read& write函数有可能引起服务器端的长时间停顿。因此,边缘触发方式中一定要采用非阻塞read &write函数。
因为边缘触发方式,接收数据时仅注册一次事件。则一旦发生输入相关事件,就应该读取输入缓冲中的全部数据。因此,需要验证输入缓冲中是否为空。
而read返回-1,变量errno中的值为EAGAIN,说明没有数据可读。
我的想法:这让我想起另一种检查输入缓冲是否还有数据的办法,就是使用send函数和recv函数的可选项MSG_PEEK和MSG_DONTWAIT。
服务器端代码如下:
#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
#define EPOLL_SIZE 50
char p[BUF_SIZE];
void printMess(char *mess)
{
fputs(mess,stderr);
fputc('\n',stderr);
exit(1);
}
void setnonblockingmode(int fd)
{
int flag=fcntl(fd,F_GETFL,0);
fcntl(fd,F_SETFL,flag|O_NONBLOCK);
}
int main(int argc,char *argv[])
{
if(argc!=3)
printMess("argc error!");
int serverSock=socket(PF_INET,SOCK_STREAM,IPPROTO_TCP);
if(serverSock==-1)
printMess("socket() error!");
struct sockaddr_in serverAddr;
memset(&serverAddr,0,sizeof(serverAddr));
serverAddr.sin_family=AF_INET;
serverAddr.sin_port=htons(atoi(argv[1]));
serverAddr.sin_addr.s_addr=inet_addr(argv[2]);
if(bind(serverSock,(struct sockaddr*)&serverAddr,sizeof(serverAddr))==-1)
printMess("bind() error!");
if(listen(serverSock,5)==-1)
printMess("listen() error!");
int epfd=epoll_create(EPOLL_SIZE);
struct epoll_event *pevents=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
setnonblockingmode(serverSock);
struct epoll_event event;
event.events=EPOLLIN;
event.data.fd=serverSock;
epoll_ctl(epfd,EPOLL_CTL_ADD,serverSock,&event);
struct sockaddr_in clientAddr;
socklen_t clientAddrLen=sizeof(clientAddr);
while(1)
{
int eventCnt=epoll_wait(epfd,pevents,EPOLL_SIZE,-1);
if(eventCnt==-1)
{
puts("epoll_wait error");
break;
}
puts("epoll_wait\n");
int i;
for(i=0;i<eventCnt;i++)
{
if(pevents[i].data.fd==serverSock)
{
int clientSock=accept(serverSock,(struct sockaddr*)&clientAddr,&clientAddrLen);
setnonblockingmode(clientSock);
event.events=EPOLLIN|EPOLLET;
event.data.fd=clientSock;
epoll_ctl(epfd,EPOLL_CTL_ADD,clientSock,&event);
printf("%d client connected.....\n",clientSock);
}
else
{
while(1)
{
int readLen=read(pevents[i].data.fd,p,BUF_SIZE-1);
p[readLen]=0;
if(readLen==0)
{
epoll_ctl(epfd,EPOLL_CTL_DEL,pevents[i].data.fd,NULL);
close(pevents[i].data.fd);
printf("%d client disconnected.....\n",pevents[i].data.fd);
break;
}
else if(readLen<0)
{
if(errno==EAGAIN)
break;
}
else
write(pevents[i].data.fd,p,readLen);
}
}
}
}
close(serverSock);
close(epfd);
return 0;
}
客户端:
服务端:
边缘触发相对于条件触发的优点是可以分离接收数据和处理数据的时间点。
书上的意思就是边缘触发可以先注册事件(缓冲接收到数据),留待合适的时机再处理(读取数据)。
而如果条件触发不处理的话,则每次调用epoll_wait都会产生相应事件。而且事件数增加,服务器端可能无法接受。
从实现模型的角度看,边缘触发更有可能带来高性能,但不能简单地认为只要使用边缘触发就一定能提高速度。