这里描述了如何构造一个单线程服务器,它使用异步IO在多个连接之间提供表面上的并发性。这里上面了一个多协议服务器如何同时在TCP和UDP传输协议之上提供服务。本文将扩展这些概念,把这些循环和并发服务器的设计思想结合起来
合并服务器
在大多数情况下,程序员为每个服务设计一个单独的服务器:每个服务器在一个熟知端口上等待,并回答与该端口相关联的服务的请求。因此,一台服务器往往为DAYTIME服务运行一个服务器,而为ECHO服务又运行另一个服务器…
一个使用多协议的服务器有助于节约系统资源,并使程序维护更容易。把多个服务合并到一个多服务服务器中的动机与设计多协议服务器的动机是一样的,它们具有相同的优点(缺点是可靠性低,因为它引入了单点故障)
- 若为每个服务创建一个服务器,为估计这些方法的开销,你需要检测一下业已标准化的服务有多少。TCP/IP定义了非常多的简单服务,这些服务是用来帮助对计算机网络进行测试、排错和维护的。
- 一个系统,如果为每个标准化了的服务运行一个服务器,尽管它们中的多数可能根本不会收到请求,但系统中可能有几十个服务器进程,因此,把多个服务结合到一个服务器进程中可以显著的减少正在执行的进程的数量(因为Linux实现限制了单个进程可以打开的套接字的最大数目。单个服务器也许不能提供所有的服务。然而,如果一个进程可以打开N个套接字,使用多服务器能以因子N减少所要求的进程数目)。
- 而且,因为很多小的服务可以由一个简单的计算完成,这样,一个服务器中的大多数代码是用来处理接收请求和发送应答的,将许多服务结合进一个服务中可以减少所需要的总的代码数量
设计
无连接的、多服务服务器的设计
多服务服务器既可以使用无连接的也可以使用面向连接的传输协议。下图说明了一个无连接的、多服务服务器的一种可能的进程结构
一个循环的、无连接的、多服务服务器往往由一个控制线程以及提供服务所需要的全部代码组成。
- 这个服务器打开一组套接字,并将每个套接字与一个熟知端口绑定,每个端口与一个所提供的服务相对应。
- 服务器使用一个很小的表将套接字映射到服务上。对每个套接字描述符,这个表记录了处理这个服务(由这个套接字提供)的过程的地址。
- 服务器使用select系统调用等待任意套接字上的数据报的到达
- 当一个数据报到达后,服务器调用合适的过程,计算出响应,并将应答发送出去。由于映射表记录了每个套接字所提供的服务,服务器可以很方便的将套接字描述符映射到处理这个服务的过程上
面向连接的、多服务服务的设计
面向连接的、多服务服务器也可以遵照一种循环的算法。从原理上来说,这样一个服务器同一组循环的、面向连接的服务器执行相同的任务。更准确的说,就是在一个多服务服务器中,单个执行线程取代了一组面向连接的服务器中的多个主服务器线程。在顶层中,这个多服务服务器使用异步IO处理任务。如下图所示:
- 当这个多服务服务器开始执行时,它先为它所提供的每个服务创建一个套接字,并将该套接字绑定到这个服务的熟知端口上
- 接着,使用select等待任意套接字的传入连接请求。
- 只要这些套接字中有一个就绪,服务器就调用accept获取这个刚刚到来的新链接。
- accept为这个传入连接创建了一个新套机字。服务器使用这个新套接字与客户交互,之后便将其关闭。
- 因此,除了每个服务器由一个主套接字之外,服务器在任何时候最多只有一个打开的附加套接字
如同无连接服务器的情况,服务器保持着一个映射表,这样就可以决定如何处理每个传入连接。
- 当服务器启动时,它分配了主套接字。
- 对每个主套接字,服务器都在映射表中增加一个条目,这个映射表指明了该套接字号以及实现这个(由这个套接字所提供的)服务的过程。
- 在为每个服务分配了一个主套接字之后,服务器调用select等待连接。
- 一旦服务到达,服务器使用映射来确定要调用众多内部过程中的哪一个,由这个过程处理客户所请求的服务
并发的、面向连接的、多服务服务器
当一个连接请求到达,多服务服务器就调用一个过程,接收并且直接处理(使服务器循环执行)这个新的连接,或者它也可以创建一个新的从进程来处理这个新连接(使服务器并发执行)。
实际上,一个多服务器程序可以设计成循环的处理某些服务,面向其他的一些服务则并发的处理;程序员并不需要对所有服务都采用一种单一的方式
并发性可以通过多个单线程的进程来实现,也可以通过一个多线程的进程来实现。下图显示了一种多服务服务器的进程/线程结构,它使用了一种并发的,面向连接的方法
在循环方式的实现里,一旦过程同客户的通信结束,它将关闭这个新连接。而在并发的方式里,主服务进程一旦创建了从进程就立即关闭这个连接,而在从进程中,这个连接继续保持打开。这个从进程就像一个常规的、面向连接的服务中的从进程一样工作。它在这个连接之上与客户通信,接收请求并发送应答。当结束交互后,终止与客户的通信,然后退出
单线程的、多服务服务器的设计
用单个执行线程管理多服务服务器中的所有活动,这种设计尽管不多见但是有可能的,它就像这里讨论的服务器。
不同于为每个传入连接创建一个从线程/进程,服务器把为每个新连接所分配的套接字加入到select调用所要使用的套接字集参数中。如果个主套接字中有一个就绪,服务器就调用accept;如果个从套接字之一就绪,服务器就调用read以便从客户那里获得传入请求,接着它就构成响应,并且调用write把响应发回给客户
从多服务服务器调用单独的程序
上面所有设计的缺点是改变任何一个服务的代码都需要重新编译这个多服务器服务器。可以通过将一个多服务服务器分为来一个个独立的单元来解决
下图说明了如何修改设计以便将庞大的服务器划分为独立的小片
从图中可以看出,主服务器使用fork创建一个新进程来处理各个连接。然而,与以前的设计不同,从进程以调用execve的方式用一个新的程序替代了原来的代码,这个新的程序将处理所有的客户通信
由于execve是从一个文件中获取这个新程序的,上述设计允许系统管理员在替换文件时,不必在重新编译多服务服务器。从概念上来说,使用exe就把处理各个服务的程序同设计连接的主服务代码分离开了
多服务、多协议设计
多服务服务器可以设计为适用于多协议的程序,这样服务器可以为它提供的一些甚至全部的服务管理UDP和TCP套接字
很多网络专家使用术语“超级服务器”(super server)来指一种多服务、多协议的服务器。在原理上,超级服务器的运行很像是一个常规的多服务服务器。
- 在开始时,服务器为它所提供的每个服务打开一个或者两个主套接字。
- 对于某个给定的服务,它的主套接字对应于无连接的传输(UDP)或者面向连接(TCP)的传输。服务器使用select等待任一套接字就绪。
- 如果一个UDP套接字就绪,服务器调用一个过程,该过程从这个套接字读取下一请求(数据报)并计算出响应,然后将应答发送出去
- 如果一个TCP套接字就绪,服务器调用一个过程,该过程从这个套接字中接收下一个连接并对其进行处理。服务器可以采用循环的方式直接处理这个连接,也可以创建一个新进程,使服务器按照并发方式来处理这个连接
实例
下面程序中:
- 在初始化数据结构并为它所提供的每个服务打开了套接字之后,服务器就进入了无限循环。每一次循环都调用select,以便等待各套接字中的某个准备就绪。当有请求到达时,select就返回
- 当select返回时,服务器循环扫描每个可能的套接字描述符,使用宏FD_ISSET来测试描述符是否就绪。如果发现了就绪的描述符,它就调用一个函数来处理这个请求。为此,服务器首先利用数组fd2sv将描述符映射到数组svent中的某个条目
- svent中的每个条目中都含有service类型的结构,它将套接字描述符映射为服务。service中的sv_func字段含有函数地址,该函数负责处理这个服务。在将描述符映射到svent中的某个条目之后,程序就调用这个选中的函数。
- 对于UDP套接字,服务器直接调用服务句柄;而对于TCP套接字,服务器通过doTCP间接的调用服务句柄
- TCP上的服务要求另外的过程,这是因为TCP套接字对应于面向连接服务器的主套接字。当此套接字就绪时 ,就意味着有一个连接请求已经到达这个套接字。这个服务器需要创建一个新的进程来管理这个连接。因此,过程doTCP调用accept来接受这个新连接。接着它调用fork创建了一个新的从进程。在关闭了那些无关的文件描述符后,doTCP调用服务句柄函数(sv_func)。当服务函数返回后,从进程退出。
/* superd.c - main */
#define _USE_BSD
#include <sys/types.h>
#include <sys/param.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/resource.h>
#include <sys/errno.h>
#include <sys/signal.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#define UDP_SERV 0
#define TCP_SERV 1
#define NOSOCK -1
struct service{
char *sv_name;
char sv_useTCP;
int sv_sock;
void (*sv_func)(int);
};
void TCPechod(int), TCPchanged(int), TCPdaytimed(int), TCPtimed(int);
int passiveTCP(const char *service, int qlen);
int passiveUDP(const char *service);
int errexit(const char *format, ...);
void doTCP(struct service *psv);
void reaper(int sig);
struct service svent[] = {
{"echo", TCP_SERV, NOSOCK, TCPechod},
{"chargen", TCP_SERV, NOSOCK, TCPchanged},
{"daytime", TCP_SERV, NOSOCK, TCPdaytimed},
{"time", TCP_SERV, NOSOCK, TCPtimed},
{0, 0, 0, 0},
};
#ifndef MAX
#define MAX(x, y) ((x) > (y) ? (x) : (y))
#endif /* MAX */
#define QLEN 32
#define LINELEN 128
extern unsigned short portbase; /* from passivesock() */
int main(int argc, char *argv[])
{
struct service *psv, *fd2sv[NOFILE];
int fd, nfds;
fd_set afds, rfds;
switch (argc) {
case 1:
break;
case 2:
portbase = (unsigned short) atoi(argv[1]);
break;
default:
errexit("usage: superd [portbase]\n");
}
nfds = 0;
FD_ZERO(&afds);
for(psv = &svent[0]; psv->sv_name; ++psv){
if (psv->sv_useTCP){
psv->sv_sock = passiveTCP(psv->sv_name, QLEN);
}else{
psv->sv_sock = passiveUDP(psv->sv_name);
}
fd2sv[psv->sv_sock] = psv;
nfds = MAX(psv->sv_sock + 1, nfds);
FD_SET(psv->sv_sock, &afds);
}
(void) signal(SIGCHLD, reaper);
while (1){
memcpy(&rfds, &afds, sizeof(rfds));
if (select(nfds, &rfds, NULL, NULL, NULL) < 0){
if (errno == EINTR){
continue;
}
errexit("select error: %s\n", strerror(errno));
}
for(fd = 0; fd = nfds; ++fd){
if (FD_ISSET(fd, &rfds)){
psv = fd2sv[fd];
if (psv->sv_useTCP){
doTCP(psv);
}else{
psv->sv_func(psv->sv_sock);
}
}
}
}
}
void doTCP(struct service *psv){
struct sockaddr_in fsin;
unsigned int alen;
int fd, ssock;
alen = sizeof(fsin);
ssock = accept(psv->sv_sock, (struct sockaddr *)&fsin, &alen);
if (ssock < 0){
errexit("accept:%s\n", strerror(errno));
}
switch (fork()) {
case 0:
break;
case -1:
errexit("fork:%s\n", strerror(errno));
default:
(void) close(ssock);
return;
}
for(fd = NOFILE; fd >= 0; --fd){
if (fd != ssock){
close(fd);
}
}
psv->sv_func(ssock);
exit(0);
}
我们的超级服务器例子提供了四种服务:ECHO、CHARGEN、DAYTIME、TIME。CHARGEN服务是用于测试客户软件的。客户一旦同CHARGEN服务器构成了一个连接,服务器就生成了一个无限的字符序列,并将其发送给客户(TCPchargend过程含有一个循环,它不停的产生一个填满ASCII字符的缓存,并调用write将这个缓存的内容发送给客户)
/* sv_funcs.c - TCPechod, TCPchargend, TCPdaytimed, TCPtimed */
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <time.h>
#include <string.h>
#define BUFFERSIZE 4096 /* max read buffer size */
extern int errno;
void TCPechod(int), TCPchargend(int), TCPdaytimed(int), TCPtimed(int);
int errexit(const char *format, ...);
/*------------------------------------------------------------------------
* TCPecho - do TCP ECHO on the given socket
*------------------------------------------------------------------------
*/
void TCPechod(int fd)
{
char buf[BUFFERSIZE];
int cc;
while (cc = read(fd, buf, sizeof buf)) {
if (cc < 0)
errexit("echo read: %s\n", strerror(errno));
if (write(fd, buf, cc) < 0)
errexit("echo write: %s\n", strerror(errno));
}
}
#define LINELEN 72
/*------------------------------------------------------------------------
* TCPchargend - do TCP CHARGEN on the given socket
*------------------------------------------------------------------------
*/
void TCPchargend(int fd)
{
char c, buf[LINELEN+2]; /* print LINELEN chars + \r\n */
c = ' ';
buf[LINELEN] = '\r';
buf[LINELEN+1] = '\n';
while (1) {
int i;
for (i=0; i<LINELEN; ++i) {
buf[i] = c++;
if (c > '~')
c = ' ';
}
if (write(fd, buf, LINELEN+2) < 0)
break;
}
}
/*------------------------------------------------------------------------
* TCPdaytimed - do TCP DAYTIME protocol
*------------------------------------------------------------------------
*/
void TCPdaytimed(int fd)
{
char buf[LINELEN], *ctime();
time_t now;
(void) time(&now);
sprintf(buf, "%s", ctime(&now));
(void) write(fd, buf, strlen(buf));
}
#define UNIXEPOCH 2208988800UL /* UNIX epoch, in UCT secs */
/*------------------------------------------------------------------------
* TCPtimed - do TCP TIME protocol
*------------------------------------------------------------------------
*/
void TCPtimed(int fd)
{
time_t now;
(void) time(&now);
now = htonl((unsigned long)(now + UNIXEPOCH));
(void) write(fd, (char *)&now, sizeof(now));
}
静态和动态的服务器配置
在实际中,很多系统都提供了一个超级服务器框架,系统管理员可以在它上面增加其他的服务。为便于使用,超级服务器通常是可配置的,即不必重新编译源代码就可以改变服务器所能处理的各种服务。可以有两种类型的配置方法:静态的和动态的
静态配置发生在超级服务器开始执行的时候,典型的情况是,配置信息放置在一个服务器启动时可以读取的文件中,配置文件指明服务器可以处理的一组服务以及每个服务所使用的某个可执行的程序。要改变所要处理的服务,系统管理员只需要改变配置文件并重新启动服务器
动态配置发生在超级服务器运行的时候。动态配置服务器也需要在开始时读取配置文件,不同的是它不必重新启动就可以重新定义它所提供的服务。为了改变服务,系统管理员先改变配置文件,然后通知服务器要求重新配置。于是,服务器检测配置文件,按文件所述来改变它的行为。
那管理员如何通知服务器需要重新配置?通过操作系统。
- 在Linux系统中,signal机制被用作进程间通信。管理员向服务器发送一个信号,服务器必须捕获这个信号并把它的到来解释为重配置请求。
- 如果没有这样的通信机制的操作系统中,可以通过TCP/IP来通知。这时服务器需要打开一个用于控制的额外的套接字,当要求重新配置时,管理员就在这个控制连接上与服务器通信。
服务器通过读取配置文件来更改它所提供的服务。如果配置文件新增之前没有的服务,服务器就需要为新的服务打开套接字并接收服务请求;如果配置文件删除之前的服务,服务器应该将这些不必在处理的服务对应的套接字关闭。一个设计良好的服务器会从容的处理重新配置,即对停止了的服务,尽管服务器不会再接受新的请求,但它并不将那些已在进程中存在的连接终止。因此,从客户来的请求要么被拒绝,要么被完全处理
Unix超级服务器,inetd
大多数UNIX,都运一个能处理很多服务的超级服务器,叫做inetd
设计inetd的初始动机是这样的:人民期望一种有效的机制可以提供许多服务,但并不过分的使用系统资源。具体来说就是,尽管一些TCP/IP的服务,比如ECHO和CHARCEH,对测试和调试很有帮助,但在实际工作的系统中却很少使用它们。为每个服务都创建一个服务器要占用系统资源(比如进程表中的条目和换页空间)。此外,如果各个单独的进程并发执行,它们会竞争使用内存。因此,把各个服务器合并到超级服务器中会减少开销,但并不减少功能。
inted是可动态配置的(收到信号SIGHUO之后),其配置信息保存在一个文本文件中,一个条目中包含很多字段,如下图(每个条目开始的六个字符是必须要有的,由一些连续的非空格字符构成;一行中剩下的字构成了参数)
当服务器首次启动或者在重新配置之后,它必须为配置文件中的每个新服务创建一个主套接字。为此,服务器将解析配置文件,取出文件中的各个字段。套接字类型(socket type字段)决定决定了主套接字是使用流还是数据报(dgram)类型的套接字。inetd必须为每个套接字绑定一个本地协议端口号。为找到一个协议端口号,inetd取出配置文件中的服务名(service name)字段和协议(protocol)字段,用这两个字段向系统的服务数据库查看。该数据库返回这个服务所使用的协议端口号;如果服务数据库没有包含该服务名字段和协议字段组成构成的条目,inetd就不能处理这个服务。
主套接字一旦创建,inetd便记录下配置文件中剩下的信息,并等待主套接字上到达的请求。当某个客户联络某个特定的服务时,inetd利用它记录下来的信息决定如何进行。比如,等待状态(waitstatus)字段决定了inetd是否并发的运行服务程序的多个副本。
- 如果配置文件指明该字段为nowait(不等待),inetd就为每个到达的请求创建一个新的进程,并允许所有进程并发运行。因为inetd要派生一个执行服务程序的子进程,这样,每当一个请求到达,就要创建一个新进程。inetd进程一直保持运行,它不停的在主套接字上等待请求的到来。
- 如果配置文件指明该字段为wait(等待),inetd将循环的处理服务请求。有趣的是,如果一个请求首次到达,而它所请求的服务器被指明为wait状态,inetd就会派生一个单独的进程来运行服务器程序。为理解其原因,观察一下inetd就会直到,当等待某个服务时,inetd不能被阻塞,这是因为其他服务也许需要继续并发执行。为了防止启动多个进程,inetd只是简单的选择了这样的方式,即在服务器程序结束前不接受进一步的请求:在为某个给定服务启动了一个进程后,inetd使用等待状态字段决定如何继续下去。如果一个服务的等待状态指定为wait,inetd从它所监听的套接字集合中把这个服务的主套接字删除。当运行这个服务的进程接受后,inetd便将这个套接字加入到活动集合1中,又开始等待接收这个服务的请求
尽管等待状态字段提供了循环执行和并发执行之间的概念上的区别,但选择wait字段还有一个实际的理由。具体来说就是,UDP服务对这样的服务使用wait,即这个服务要求客户和服务器交换多个数据报。wait状态防止inetd在服务程序结束前就使用该套接字。这样,客户可以不受干扰的向服务器程序发送数据。一旦服务程序结束,inetd就可以重新使用这个套接字了
无论哪种形式的等待方式,inetd都使用配置文件中的服务器程序(sever program)字段决定执行哪个服务程序。如果该字段指明为内部(internal),inetd就调用一个内部过程来处理这个服务。否则,inetd将这个字符串看作待执行的服务程序的文件名。在inetd调用了一个服务器之后,它将参数字段的内容传递给该服务器程序
inetd服务器的例子
假设程序员希望为DAYTIME服务对一个新的服务器进行排错。这个新的服务器能够很容易地加入到inedt中。首先,要给这个服务指派一个临时名,并选择一个临时的协议端口号,还要将这个信息加入到系统服务数据库中。比如,如果程序员选择了名字timetest以及协议号10250,下面的条目将被加到文件/ect/serivces中:
timetest 10250/ctp
另外,还必须写一个服务器程序。由于inetd创建了必要的套接字并接收一个传入连接,服务器程序就不需要包含这些细节了,它只需要处理一个针对连接1的通信。如下:
在inetd接收一个传入连接后,在执行服务器前,它将连接转移到文件描述符0上。因此,服务器尝尝使用描述符0与客户通信。此外,服务器并没有包含选择使用循环方式还是并发方式的代码,这是因为inetd处理了所有这些细节
服务器代码被编译后,编译的结构是个可执行程序,它被放置在一个文件中,inetd的配置可以更改成能引用这个服务器。比如,如果上面的程序的已编译版本放入文件/pub/inetd_daytimed,下面条目可以加到/ect/inetd.conf中:
timetest stream tcp nowait root /pub/inetd_dayttimed inetd_daytimed
这个条目说明了一个叫做timetest的服务器,它要求一个流(stream)套结字和tcp协议。服务器并发执行并作为root用户运行。最后服务器的可执行版本可以在/pub/inetd_dayttimed中找到,并且传递给服务器的唯一的命令行参数就是它的名字,inetd_daytimed