UNIX网络编程——客户/服务器程序设计示范(三)
TCP预先派生子进程服务器程序,accept无上锁保护
我们的第一个“增强”型服务器程序使用称为预先派生子进程的技术。使用该技术的服务器不像传统意义的并发服务器那样为每个客户现场派生一个子进程,而是启动阶段预先派生一定数量的子进程,当各个客户连接到达时,这些子进程立即就能为他们服务。下图展示了服务器父进程预先派生出N个子进程且正有2个客户连接着的情形。
这种技术的优点在于无须引入父进程执行fork的开销就能处理新到的客户。缺点则是父进程在服务器启动阶段猜测需要预先派生多少子进程。如果某个时刻客户数恰好等于子进程总数,那么新到的客户将被“忽略”,直到至少有一个子进程重新可用。其实这些客户并未被完全忽略。内核将为每个新到的客户完成三路握手,直到达到相应套接字上listen调用backlog数为止,然后再服务器调用accept时把这些已完成的连接传递给它。这么一来客户就能觉察到服务器在响应时间上的恶化,因为尽管它的connect调用可能立即返回,但是它的第一个请求可能是在一段时间之后才被服务器处理。
通过增加一些代码,服务器总能应对客户负载的变动。父进程必须做的就是持续监视可用(即闲置)子进程数,一旦该值降到低于某个阈值就派生额外的子进程。同样,一旦该值超过另一个阈值就终止一些过剩的子进程,因为过多的可用子进程也会导致性能退化。不过在考虑这些增强之前,我们首先查看这类服务器程序的基本结构。如下给出预先派生子进程服务器程序第一版本的main函数。
#include "unp.h" static int nchildren; static pid_t *pids; int main(int argc, char **argv) { int listenfd, i; socklen_t addrlen; void sig_int(int); pid_t child_make(int, int, int); if (argc == 3) listenfd = Tcp_listen(NULL, argv[1], &addrlen); else if (argc == 4) listenfd = Tcp_listen(argv[1], argv[2], &addrlen); else err_quit("usage: serv02 [ <host> ] <port#> <#children>"); nchildren = atoi(argv[argc-1]); pids = Calloc(nchildren, sizeof(pid_t)); for (i = 0; i < nchildren; i++) pids[i] = child_make(i, listenfd, addrlen); /* parent returns */ Signal(SIGINT, sig_int); for ( ; ; ) pause(); /* everything done by children */ } void sig_int(int signo) { int i; void pr_cpu_time(void); /* 4terminate all children */ for (i = 0; i < nchildren; i++) kill(pids[i], SIGTERM); while (wait(NULL) > 0) /* wait for all children */ ; if (errno != ECHILD) err_sys("wait error"); pr_cpu_time(); exit(0); }
14-21 增设一个命令行参数供用户指定预先派生的子进程的个数。分配一个存放各个子进程ID的数组,用于在父进程即将终止时由main函数终止所有子进程。
23-24 调用chid_make函数创建各个子进程。
38-42 既然getrusage汇报的是已终止子进程的资源利用统计,在调用pr_cpu_time之前就必须终止所有子进程。我们通过给每个子进程发送SIGTERM信号终止他们,并通过调用wait汇集所有子进程的资源利用统计。
#include "unp.h" pid_t child_make(int i, int listenfd, int addrlen) { pid_t pid; void child_main(int, int, int); if ( (pid = Fork()) > 0) return(pid); /* parent */ child_main(i, listenfd, addrlen); /* never returns */ } void child_main(int i, int listenfd, int addrlen) { int connfd; void web_child(int); socklen_t clilen; struct sockaddr *cliaddr; cliaddr = Malloc(addrlen); printf("child %ld starting\n", (long) getpid()); for ( ; ; ) { clilen = addrlen; connfd = Accept(listenfd, cliaddr, &clilen); web_child(connfd); /* process the request */ Close(connfd); } }
9-12 调用fork派生子进程后只有父进程返回。子进程调用child_main函数,它是个无限循环。
26-32 每个子进程调用accept返回一个已连接的套接字,然后调用web_child处理客户请求,最后关闭连接。子进程一直在这个循环中反复,直到被父进程终止。
#include "unp.h" #define MAXN 16384 /* max # bytes client can request */ void web_child(int sockfd) { int ntowrite; ssize_t nread; char line[MAXLINE], result[MAXN]; for ( ; ; ) { if ( (nread = Readline(sockfd, line, MAXLINE)) == 0) return; /* connection closed by other end */ /* 4line from client specifies #bytes to write back */ ntowrite = atol(line); if ((ntowrite <= 0) || (ntowrite > MAXN)) err_quit("client request for %d bytes", ntowrite); Writen(sockfd, result, ntowrite); } }
select冲突
当多个进程在引用同一个套接字的描述符上调用select时就会发生冲突,因为在socket结构中为存放本套接字就绪之时应该唤醒哪些进程而分配的仅仅是一个进程ID的空间。如果有多个进程在等待同一个套接字,那么内核必须唤醒的是阻塞在select调用中的所有进程,因为它不知道哪些进程受到刚变得就绪的这个套接字影响。
我们可以迫使本服务器发送select冲突,办法是在调用accept之前加上一个select调用,等待监听套接字变为可读。各个子进程将阻塞在selec调用而不是accept调用中。如下给出了child_main函数的改动部分,不同于上面的若干行通过标以加号指出。如此修改后,通过检查BSD/OS内核的nselcoll计数器在服务器运行前后的变化,我们发现某此运行本服务器出现1814个冲突,下一个运行出现2045个冲突。既然两个客户为每次运行本服务器总共产生5000个连接,这两个结果相当于约有35%-40%的select调用引起冲突。
从以上讨论我们可以得出如下经验:如果有多个进程阻塞在引用同一个实体(例如套接字或普通文件,由file结构直接或间接描述)的描述符,那么最好直接阻塞在诸如accept之类的函数而不是select之中。