基于I/O复用的服务器端
多进程服务器端的缺点和解决方法
为了构建并发服务器,只要有客户端连接请求就会创建新进程。这的确是实际操作中采用的种方案,但并非十全十美,因为创建进程时需要付出极大代价。这需要大量的运算和内存空间,由于每个进程都具有独立的内存空间,所以相互间的数据交换也要求采用相对复杂的方法(IPC属于相对复杂的通信方法)。各位应该也感到需要IPC时会提高编程难度。“那有何解决方案?能否在不创建进程的同时向多个客户端提供服务?”
当然能!本节讲解的I/O复用就是这种技术。大家听到有这种方法是否感到一阵兴奋?但请不要过于依赖该模型!该方案并不适用于所有情况,应当根据目标服务器端的特点采用不同实现方法。下面先理解“复用”(Multiplexing)的意义。
理解复用
在网络编程中,复用(Multiplexing)是指在一个物理通信链接(如网络传输介质)上同时传输多个独立的数据流。它通过将多个数据流合并成一个流并在接收端将其分解,从而提高网络资源的利用效率。
复用技术可以通过以下几种方式实现:
-
时间复用(Time Division Multiplexing,TDM):将时间划分为若干个间隔,每个间隔分配给不同的数据流进行传输。发送端按照一定的规则在每个时间间隔内发送数据,接收端则根据间隔进行数据的提取和恢复。
-
频分复用(Frequency Division Multiplexing,FDM):将频率范围划分为多个窄带信道,每个信道专门用于传输一个数据流。数据流经过调制后,在不同的频率上进行传输,接收端则对信号进行解调得到原始数据。
-
码分复用(Code Division Multiplexing,CDM):利用不同的码序列来区分各个数据流。发送端使用特定的码序列对数据进行扩展,接收端则使用相同的码序列进行解扩,从而将数据流分离。
以上这些复用技术都旨在实现多个数据流在同一物理通信链接上的传输,以提高网络的带宽利用率和传输效率。在网络编程中,我们可以使用不同的复用技术来实现同时处理多个客户端请求或在单个连接上传输多个数据流。
复用技术在服务器端的应用
对于网络编程中的IO复用(IO multiplexing),它是一种高效处理多个IO事件的机制。传统的IO模型中,每个IO操作都会阻塞线程,导致程序在处理一个IO时无法同时处理其他IO事件,造成资源浪费。
而IO复用通过利用特定的系统调用函数(如select、poll、epoll等)来监视多个IO事件,将多个IO操作集中在一个线程中进行管理和处理,从而实现同时处理多个IO事件的能力。它的基本原理是将需要监听的IO事件加入到一个事件集合中,然后通过系统调用阻塞等待其中任何一个事件就绪,一旦有就绪事件,程序就可以执行相应的操作。
IO复用的主要好处包括:
-
资源利用率高:使用IO复用可以避免每个IO操作都阻塞线程,从而减少线程数量,提高了资源利用效率。
-
响应速度快:IO复用可以同时监听多个IO事件,一旦有事件就绪,立即进行处理,大大减小了事件响应的延迟。
-
编程简洁:相比于多线程或多进程模型,使用IO复用可以简化代码,降低开发和维护的难度。
总而言之,IO复用是一种高效处理多个IO事件的机制,它可以减少线程数量、提高资源利用率和响应速度,是网络编程中常用的技术之一。
我再举个例子来理解一下IO复用服务器端,某教室中有10名学生和1位教师,这些孩子并非等闲之辈,上课时不停地提问。学校没办法,只能给每个学生都配1位教师,也就是说教室中现有10位教师。此后,只要有新的转校生,就会增加1位教师,因为转校生也喜欢提问。这个故事中,如果把学生当作客户端,把教师当作与客户端进行数据交换的服务器端进程,则该教室的运营方式为多进程服务器端方式。
有一天,该校来了位具有超能力的教师。这位教师可以应对所有学生的提问,而且回答速度很快,不会让学生等待。因此,学校为了提高教师效率,将其他老师转移到了别的班。现在,学生提问前必须举手,教师确认举手学生的提问后再回答问题。也就是说,现在的教室以IO复用方式运行。
虽然例子有些奇怪,但可以通过它理解IO复用方法:教师必须确认有无举手学生,同样,IO复用服务器端的进程需要确认举手(收到数据)的套接字,并通过举手的套接字接收数据。
理解select函数并实现服务器端
运用select函数是最具代表性的实现复用服务器端方法。Windows平台下也有同名函数提供相
同功能,因此具有良好的移植性。
select函数的功能和调用顺序
使用sclect函数时可以将多个文件描述符集中到一起统一监视,项目如下。
□是否存在套接字接收数据?
□无需阻塞传输数据的套接字有哪些?
□哪些套接字发生了异常?
select函数的使用方法与一般函数区别较大,更准确地说,它很难使用。但为了实现IO复用服务器端,我们应掌握select函数,并运用到套接字编程中。认为“select函数是IO复用的全部内容”也并不为过。接下来介绍select函数的调用方法和顺序。
步骤一:
设置文件描述符
指定监视范围
设置超时
步骤二:
调用select函数
步骤三:
查看调用结果
可以看到,调用select函数前需要一些准备工作,调用后还需查看结果。接下来按照上述顺序逐一讲解。
设置文件描述符
利用select函数可以同时监视多个文件描述符。当然,监视文件描述符可以视为监视套接字.此时首先需要将要监视的文件描述符集中到一起。集中时也要按照监视项(接收、传输、异常进行区分,即按照上述3种监视项分成3类。
使用fd_set数组变量执行此项操作,如图所示。该数组是存有0和1的位数组。
图中最左端的位表示文件描述符0(所在位置)。如果该位设置为1,则表示该文件描述
符是监视对象。那么图中哪些文件描述符是监视对象呢?很明显,是文件描述符1和3。
“是否应当通过文件描述符的数字直接将值注册到fd_set变量?”
当然不是!针对fd_ set变量的操作是以位为单位进行的,这也意味着直接操作该变量会比较
繁琐。难道要求各位自己完成吗?实际上,在fd_set变量中注册或更改值的操作都由下列宏完成
□FD_ZERO(fd_set *fdset):将fd_set变量的所有位初始化为0。
□ FD_SET(int fd, fd_set *fdset):在参数fdset指向的变量中注册文件描述符的信息。
□ FD_CLR(int fd, fd_set *fdset):从参数fdset指向的变量中清除文件描述符的信息。
□ FD_ISSET(int fd,fd_set *fdset):若参数fdset指向的变量中包含文件描述符的信息,则返回“真”。
上述函数中,FD_ISSET用于验证select函数的调用结果。
设置检查(监视)范围及超时
先简单的介绍一下select函数。
#include<sys/select.h>
#include<sys/time.h>
int select(int maxfd,fd_set* readset,fd_set* writeset,fd_set* exceptset,const struct timeval *timeout);//成功时返回大于0的值,失败时返回-1
maxfd //监视对象文件描述符数量。
readset //将所有关注“是否存在待读取数据”的文件描述符注册到fd_set型变量,并传递其地址值。
writeset //将所有关注“是否可传输无阻塞数据”的文件描述符注册到fd_set型变量,并传递其地址值。
exceptset //将所有关注“是否发生异常”的文件描述符注册到fd_set型变量,并传递其地址值。
timeout //调用select函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息。
返回值 //发生错误时返回-1,超时返回时返回0。因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数
如上所述,selcct函数用来验证3种监视项的变化情况。根据监视项声明3个fd_set型变量,分别向其注册文件描述符信息,并把变量的地址值传递到上述函数的第二到第四个参数。但在此之前(调用select函数前)需要决定下面2件事。
“文件描述符的监视(检查)范围是?”
“如何设定select函数的超时时间?”
第一,文件描述符的监视范围与select函数的第一个参数有关。实际上,select函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在fd_set变量中的文件描述符数。但每次新建文件描述符时,其值都会增1,故只需将最大的文件描述符值加1再传递到select函数即可。加1是因为文件描述符的值从0开始。
第二,select函数的超时时间与select函数的最后一个参数有关,其中timeval结构体定义如下。
struct timeval{
long tv_sec; //seconds
long tv_usec; //microseconds
}
本来select函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过声明上述结构体变量,将秒数填入tv_sec成员,将微秒数填入tv_usec成员,然后将结构体的地址值传递到select函数的最后一个参数。此时,即使文件描述符中未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下,select函数返回0。因此,可以通过返回值了解返回原因。如果不想设置超时,则传递NULL参数。
调用select函数后查看结果
函数调用后查看结果也同样重要。我们已经讨论过select函数的返回值,如果返回了大于0的整数,说明相应数量的文件描述符发生变化。那么这个变化是如何变化的呢?select函数调用完成过后,向其传递的fd_set变量中将发生变化。原来为1的所有位均变为0,但发生变化的文件描述符对应位除外。因此,可以认为值仍为1的位置上的文件描述符发生了变化。
select函数调用示例
#include<stdio.h>
#include<unistd.h>
#include<sys/time.h>
#include<sys/select.h>
#define BUF_SIZE 30
int main(int argc,char *argv){
fd_set reads,temps;
int result,str_len;
char buf[BUF_SIZE];
struct timeval timeout;
FD_ZERO(&reads);
FD_SET(0,&reads);
while(1){
temps=reads;
timeout.tv_sec=5;
timeout.tv_usec=0;
result=select(1,&temp,0,0,&timeout);
if(result==-1){
puts("select() error!");
break;
}
else if(result==0){
puts("Time-out!");
}
else{
if(FD_ISSET(0,&temps)){
str_len=read(0,buf,BUF_SIZE);
buf[str_len]=0;
printf("message from console: %s",buf);
}
}
}
return 0;
}
上述内容有两个点要注意:
1.由于select函数调用后会修改监视fd_set中的内容,所以我们需要保存一份副本,上述代码保存在temps中。
2.由于select函数调用时,timeout中的时间最后会被替换为超时前剩余时间,所以这个timeout初始量也需要每次初始化。
实现IO复用服务器端
#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
void error_handling(char *buf);
int main(int argc,char *argv[]){
int serv_sock,clnt_sock;
struct sockaddr_in serv_addr,clnt_addr;
struct timeval timeout;
fd_set reads,cpy_reads;
socklen_t addr_sz;
int fd_max,str_len,fd_num,i;
char buf[BUF_SIZE];
if(argc!=2){
printf("Usage: %s <port>\n",argv[0]);
exit(1);
}
serv_sock=socket(PF_INET,SOCK_STREAM,0);
memset(&serv_addr,0,sizeof(serv_addr));
serv_addr.sin_family=AF_INET;
serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_addr.sin_port=htons(atoi([argv[1]]));
if(bind(serv_sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr))==-1)
error_handling("bind() error");
if(listen(serv_sock,5)==-1)
error_handling("listen() error");
FD_ZERO(&reads);
FD_SET(serv_sock,&reads);
fd_max=serv_sock;
while(1){
cpy_reads=reads;
timeout.tv_sec=5;
timeout.tv_usec=5000;
if((fd_num=select(fd_max+1,&cpy_reads,0,0,&timeout))==-1)
break;
if(fd_num==0)
continue;
for(i=0;i<fd_max+1;++i){
if(FD_ISSET(i,&cpy_reads)){
if(i==serv_sock){ //连接请求到来
addr_sz=sizeof(clnt_addr);
clnt_sock=accept(serv_sock,(structsockaddr*)&clnt_addr,addr_sz);
FD_SET(clnt_sock,&reads);
if(fd_max<clnt_sock)fd=clnt_sock;
printf("connected client: %d \n",clnt_sock);
}
else{
str_len=rad(i,buf,BUF_SIZE);
if(str_len==0){
FD_CLR(i,&reads);
close(i);
printf("closed client: %d \n",i);
}
else{
write(i,buf,str_len);//回声
}
}
}
}
}
close(serv_sock);
return 0;
}
void error_handling(char *buf){
fputs(buf,stderr);
fputc('\n',stderr);
exit(1);
}
基于Windows的实现
在Windows平台调用select函数
Windows同样提供select函数,而且所有参数与Linux的select函数完全相同。只不过Window平台select函数的第一个参数是为了保持与(包括Linux的)UNIX系列操作系统的兼容性而添加的,并没有特殊意义。
#include <winsock2.h>
int select(int nfds, fd_set *treadfds, fd_set *writefds, fd_set *excepfds, const struct
timeval * timeout);//成功时返回0,失败时返回-1。
返回值、参数的顺序及含义与之前的Linux中的select函数全相同,故省略。下面给出timeval
结构体定义。
typedef struct timeval{
long tv_sec;
long tv_usec;
} TIMEVAL;
可以看到,基本结构与之前Linux中的定义相同,但Windows中使用的是typedef声明。接下来观察fd_set结构体。Windows中实现时需要注意的地方就在于此。可以看到,Windows的fd_set并非像Linux中那样采用了位数组。
typedef struct fd_set{
u_int fd_count;
SOCKET fd_array[FD_SETSIZE];
} fd_set;
Windows的fd_set由成员fd_count和fd_array构成,fd_count用于接字句柄数,fd_array用于保存套接字句柄,只要略加思考就能理解这样声明的原因。Linux的文件描述符从0开始递增,因此可以找出当前文件描述符数量和最后生成的文件描述符之间的关系。但Windows的套接字句柄并非从0开始,而且句柄的整数值之间并无规律可循,因此需要直接保存句柄的数组和记录句柄数的变量。幸好处理fd_set结构体的FDXXX型的4个宏的名称、功能及使用方法与Linux完全相同(故省略),这也许是微软为了保证兼容性所做的考量。