您的位置:首页 > 其它

服务器设计范式

2015-04-16 18:39 253 查看
我们将在本章探究并发服务器程序设计的两类变体

1、预先派生子进程,让服务器在启动阶段调用fork创建一个子进程池,每个客户请求由当前可用的子进程池中的某个(闲置)子进程处理

2、预先创建线程,让服务器在启动阶段创建一个线程池,每个客户由当前可用线程池中的某个(闲置)线程处理

我们将针对同一客户端程序运行这些服务器以便相互比较

客户端程序代码如下:

#include "unp.h"

int main(int ac, char *av[])
{
int fd, nchildren, nloops, nbytes;
char request[MAXLEN], reply[MAXLEN];
ssize_t n;
pid_t pid;
int i, j;

if(ac != 6)
{
//每次运行本客户程序时,我们指定主机名/ip, 端口号, 由客户fork的子进程数(以允许客户并发的向同一服务器发起多个连接), 每个子进程发送给服务器的请求数,以及每个请求要求服务器反送的数据字节数
fprintf(stderr, "%s <host> <port> <#children> <#loops/child> <#bytes/reuqest>\n",av[0]);
exit(1);
}

nchildren = atoi(av[3]);
nloops = atoi(av[4]);
nbytes = atoi(av[5]);
//子进程向服务器发送的这行文本指出需要服务器返回多少字节的数据
snprintf(request, sizeof(request), "%d\n", nbytes);    //new line at the end is necessary

for(i=0; i<nchildren; i++)
{
if((pid=fork()) < 0)
oops("fork error");

if(pid == 0)
{
for(j=0; j<nloops; j++)
{
fd = tcp_connect(av[1],av[2]);
if((n=write(fd, request, strlen(request))) < 0)
oops("write error");

if((n=read(fd,reply,nbytes)) != nbytes)
oops("server did not send appropriate bytes");

close(fd);            //client be TIME_OUT, not server
}
printf("child %d is over\n",i);
exit(0);
}
}

while(wait(NULL) > 0)
;

if(errno == ECHILD)
oops("wait error");

return 0;
}


测试各个版本时候执行的命令如下:

% client xxx.xxx.xxx.xxx 8888 5 500 4000

这将建立2500个与服务器的连接,每个连接客户端发送的文本为 “4000\n”,服务器返回4000个数据

按照书上来的话,书上使用了两台和服务器处在同一网络的主机进行测试,所以总共5000个连接

以下预先给出本章所有服务器的性能测试结果



以及多个线程或多个进程情况下,每个进程或线程服务客户的次数:



接下来就给出每种服务器实现:

1、TCP迭代服务器程序

这类服务器总是在完全处理完一个客户端之后转向下一个请求

执行的测试为

% client xxx.xxx.xxx.xxx 8888 1 5000 4000

我们用同样数目的连接进行测试

很容易实现。

2、TCP并发服务器程序,每个客户一个子进程

并发服务器的问题在于为每个客户现场(即请求出现并接收后直接fork一个进程出来)fork一个子进程比较耗费CPU时间

#include "unp.h"

void sig_int(int)
void sig_chld(int)
void web_child(int)

int main(int ac, char *av[])
{
int listenfd, connfd;
socklen_t addrlen;
pid_t childpid;

if(ac != 5)
{
fprintf(stderr, "Usage: %s host port\n", av[0]);
exit(1);
}
else
listenfd = tcp_listen(av[1],av[2],&addrlen);

if(signal(SIGINT,sig_int) == SIG_ERR)
oops("SIGINT signal set error");
if(signal(SIGCHLD,sig_chld) == SIG_ERR)
oops("SIGCHLD signal set error");

for(;;)
{
if((connfd=accept(listenfd,NULL,NULL)) < 0)
{
if(errno == EINTR)
continue;
else{
oops("accept error");
}
}

if((childpid=fork()) < 0)
oops("fork error");

if(childpid == 0)
{
close(listenfd);
web_child(connfd);
exit(0);
}
close(connfd);
}
return 0;
}

void sig_int(int s)
{
//...书上在按下这个信号号,结束所有进程并列出了进程的资源使用时间
exit(0);
}

void sig_chld(int s)
{
pid_t pid;
int status;

while((pid=waitpid(-1,&status,WNOHANG)) > 0)
;
return;
}

void web_child(int fd)
{
int ntowrite;
ssize_t nread;
char line[MAXLEN], result[MAXN];

for(;;)
{
if((nread=readline(fd, line, MAXLEN)) == 0)
return;

ntowrite = atol(line);
if(ntowrite<=0 || ntowrite>MAXN)
{
fprintf(stderr,"client asked for %d bytes\n",ntowrite);
exit(1);
}

written(fd, result, ntowrite);
}
}


3、TCP预先派生子进程服务器,accept无上锁保护

使用这个技术的服务器不像传统的服务器那样为每个客户现场派生子进程,而是在启动阶段预先派生一定数量的子进程,当各个客户连接到达时,这些进程立即就能为他们服务。

优点在于无须引入父进程执行fork的开销就能处理新到的客户。

缺点是父进程必须在服务器启动阶段猜测预先需要派生多少个子进程。如果某个时刻客户数恰好等于子进程总数,那么新到的客户将被忽略,直到至少一个子进程可用。(当然,并不是忽略,之前在谈及listen函数调用细节的时候说到,内核为每个新到的客户完成三路握手直到达到backlog为止,然后服务器调用accept处理。客户也很容易察觉到服务器在响应时间上的恶化,因为尽管它的connect很快返回,但是它的第一个请求却是在很久以后才被处理)

既然如此,父进程要做的就是持续监视可用子进程数,一旦该值降低到某个阀值,就派生额外子进程;一旦超过阀值就终止一些过剩的子进程

#include "unp.h"

static int nchildren;
static pid_t *pids;

int main(int ac, char *av[])
{
int listenfd, connfd;
int i;
socklen_t addrlen;

if(ac != 4)
{
fprintf(stderr, "Usage: %s host port nchildren\n",av[0]);
exit(0);
}

//处理基本事务
listenfd = tcp_listen(av[1], av[2], &addrlen);
nchildren = atoi(av[3]);

//创建进程池
pids = calloc(nchildren, sizeof(pid_t));
if(pids == NULL)
oops("calloc error");

for(i=0; i<nchildren; i++)
pids[i] = child_child(i, listenfd, addrlen);    //function returns the pid of the process

//设置信号
if(signal(SIGINT, sig_int) == SIG_ERR)
oops("signal error");

for(;;)
pause();                    //parent just have to wait for a singal --- SIGINT

return 0;
}

//我们输入SIGINT信号叫停,并用wait处理每一个子进程
void sig_int(int s)
{
int i;
//terminate all the children
for(i=0; i<nchildren; i++)
kill(pid[i], SIGTERM);

while(wait(NULL) > 0)
;
if(errno == ECHILD)
oops("wait error");

exit(0);
}

//在客户请求到来之前先创建一定数量的进程
pid_t child_make(int i, int fd, socklen_t len)
{
int pid;
if((pid=fork()) < 0)
oops("fork error");

if(pid > 0)
return pid;
else
{
child_main(i, fd, len);
}
}

//每一个子进程都调用accept阻塞从而处于睡眠状态
void child_main(int i, int fd, socklen_t len)
{
int connfd;
for(;;)
{
if((connfd=accept(fd, NULL, NULL)) < 0)
{
if(errno == EINTR)
continue;
else
{
oops("accept error");
}
}
web_child(fd);
close(connfd);
}
}


按照上述服务器,多个进程在同一监听描述符上调用accept,我们可能很难想象这里面的流程。我们先看一下下面的讲解:

父进程在派生任何子进程之前创建套接字,而每次调用fork时,所有描述符也被复制。如下图



关于N个子进程

我们也知道,描述符只是本进程引用file结构的proc结构中一个数组中某个元素的下标而已。子进程中一个给定描述符引用的file结构正是父进程中同一描述符引用的file结构。每个file结构都有一个引用计数。当我们打开一个文件或套接字时,内核将为之构造一个file结构,并由作为打开操作返回值的描述符引用,它的引用计数最初值自然为1;以后每当调用fork派生子进程或是对描述符执行dup操作时候,file结构体的引用计数即递增。在我们的N个子进程例子里,file引用计数为N+1(包括父进程)

服务器派生N个子进程后,他们各自调用accept陷入睡眠。当第一个客户到达时,所有N个子进程都被唤醒了。尽管都被唤醒,但只有最先运行的子进程获得连接,其余N-1个继续陷入睡眠

这就是称为“惊群”的问题,这会导致性能受损。(越严重越受损)

之后我们通过分配共享内存来查看每一个子进程的调度情况:

其实就是在一个共享内存中设置一个数组,大小为子进程个数,每当一个子进程被唤醒处理客户结束前对共享内存里的对应数组元素加一,最后我们发现,所有子进程是被平均调度的。

select冲突

当多个进程在引用同一个套接字的描述符上调用select时就会发生冲突,因为在select结构中为存放本套接字就绪之时应该唤醒哪些进程而分配的仅仅是一个进程ID的空间,如果有多个进程在等待同一个套接字,那么内核必须唤醒的是阻塞在select调用中的所有进程,因为它不知到哪些进程受刚变得就绪的这个套接字的影响

如果我们让每个子进程不是阻塞在accept上,而是阻塞在select上,即在select之前调用select函数,等待其返回,若监听套接字在返回的可读集中,那么就再调用accept去获取,那么消耗的CPU时间将会增加,一部份因为多了个系统调用,一部份是系统处理select冲突的额外开销

因此,如果有多个进程阻塞在引用同一实体的描述符上,那么最好直接阻塞在accept之类上,而不是select中

这里还有个小插曲,共享内存在之前看APUE的时候被我跳过了,想着以后用到的时候再看也不迟。这时候真的用到了,我顿时亚历山大,不知所措,赶紧翻开UNP2,决定稍微认识一下这个共享内存,这一看吓尿了,怎么这么多。看就看吧,什么,怎么还谈到锁,还有信号量? 这么一下,我直接从UNP2开头开始看了,打算从第7章一直看到第14章,这下总没什么能阻止我理解了。只是这样开始,心里总是慌慌的,不知之前看过哪个前辈说过,要解决问题千万不能绕进圈子里去,不能本末倒置,看了两三天,看的我毫无兴致,因为我学了是不能直接用到的而是为了共享内存而学的,昏昏沉沉的。期间开始看之前因为一些没理解而停下的TCP/IP详解卷1,居然越看越来劲,因为这期间我已经补上了一些网络方面的知识,无论是一些基础的协议还是网络工具,感觉里面讲的确实不错,居然慢慢的把锁、信号量抛在脑后了。今天实在学不下去了,赶回来看看这章到底是如何使用共享变量的,特么的,怎么这么容易?至此,我也差不多把锁、信号量、共享内存稍微知道了用处,但心里还是空落落的,因为这像是转了一大圈,差点把自己的紧要问题给丢了。以后万万不应该这样了!
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: