TLPI读书笔记第60章-服务器设计
60.1 迭代型和并发型服务器
对于使用 Socket(套接字)的网络服务器端程序,有两种常见的设计方式。
1.迭代型:服务器每次只处理一个客户端,只有当完全处理完一个客户端的请求后才去处理下一个客户端。
2.并发型:这种类型的服务器被设计为能够同时处理多个客户端的请求。
在 44.8 节中我们已经见过一个使用 FIFO 的迭代型服务器了,在 46.8 节中也有一个使用System V 消息队列的并发型服务器的例子。
迭代型服务器通常只适用于能够快速处理客户端请求的场景,因为每个客户端都必须等待,直到前面所有的客户端都处理完了服务器才能继续服务下一个客户端。迭代型服务器的典型应用场景是当客户端和服务器之间交换单个请求和响应时。
并发型服务器适用于对每个请求都需要大量处理时间,或者是当客户端和服务器在进行扩展对话中需要来回传递消息的场景。在本章中,我们把重点放在并发型服务器的传统(也是最简单的)设计方法上:针对每个新的客户端连接,创建一个新的子进程来处理。每个服务器子进程执行完所有服务于单个客户端的任务后就终止。由于这些子进程能独立地运行,因此可以同时处理多个客户端。服务器主进程(父进程)的主要任务就是为每个新的客户端连接创建一个新的子进程。(这种方法有一个变种,即为每个客户端创建一个新的线程。)
在接下来的几节中,我们将学习迭代型和并发型服务器程序的例子,它们都采用 Internet域套接字。这两个服务器都实现了 echo 服务(RFC 862),这种基本的服务能够返回客户端向其发送的任何内容。
60.2 迭代型 UDP echo 服务器
在本节以及下一节中, 我们展示了 echo 服务的服务器端程序。echo 服务支持 UDP 和 TCP, 工作在端口 7 上。(由于端口 7 是保留端口, echo 服务器必须以超级用户权限运行)。 UDP echo 服务器连续读取数据报,将每个数据报的拷贝返回给发送者。由于服务器一次只需处理一条单独的消息,因此设计为迭代型服务器就足够了。
60.3 并发型 TCP echo 服务器
TCP echo 服务同样也工作在端口 7 上。 TCP echo 服务器接受一条连接然后不断循环,读取所有已传输的数据并在同一个套接字上将它们发回给客户端。服务器不断读取数据直到它检测到文件结尾为止,此时服务器就关闭它的套接字(因此如果客户端仍在从套接字中读取数据的话,就可以看到文件结尾了)。
由于客户端可能会发送无限量的数据给服务器(因而服务这样的客户端可能需要无限的时间),因此这种情况下适合将服务器设计为并发型,这样多个客户端能够同时得到服务。
程序清单 60-4 给出了服务器的实现。(我们在 61.2 节中给出了该服务的客户端实现。)关于实现的细节,需要注意以下几点。 1.服务器通过调用 37.2 节中的 becomeDaemon()成为了一个守护进程。
2.为了使程序更短小,我们使用了程序清单 59-9 中的 Internet 域套接字函数库。
3.由于服务器为每一个客户端连接创建了一个子进程,我们必须确保不会出现僵尸进程。这可以通过为信号 SIGCHLD 安装信号处理例程来实现。
4.服务器程序的主体部分由 for 循环组成,在循环中我们接受客户端的连接,然后通过fork()创建子进程。在子进程中调用 handleRequest()函数来处理客户端。同时,父进程继续在 for 循环中接受下一个客户端的连接。
5.每次调用 fork()后, 监听套接字和连接套接字都在子进程中得到复制(见 24.2.1 节)。这意味着父子进程都可以通过连接套接字和客户端通信。但是,只有子进程才需要进行这样的通信, 因此父进程应该在 fork()调用之后立刻关闭连接套接字的文件描述符。 (如果父进程不这么做的话,那么套接字将永远不会真正关闭;此外,父进程最终会用完所有的文件描述符。)由于子进程不接受新的连接,它需要将监听套接字的文件描述符副本关闭。
6.每个子进程在处理完一个客户端后终止。
60.4 并发型服务器的其他设计方案
对于许多需要通过 TCP 连接同时处理多个客户端的应用来说, 前面几节描述的传统型并发服务器模型已经足够用了。
但是,对于负载很高的服务器来说(例如, Web 服务器每分钟要处理成千上万次请求)
1,为每个客户端创建一个新的子进程(甚至是线程)所带来的开销对服务器来说是个沉重的负担(参见 28.3 节),因此需要有其他的设计方案。下面我们主要考虑这几种可选方案。
在服务器上预先创建进程或线程
预先创建进程或线程的服务器已经在[Stevens et al., 2004]的第 30 章中做了详细的描述。其核心理念有如下几点。 1.服务器程序在启动阶段(即在任何客户端请求到来之前)就立刻预先创建好一定数量的子进程(或线程),而不是针对每个客户端来创建一个新的子进程(或线程)。这些子进程构成了一种服务池(server pool)
2.服务池中的每个子进程一次只处理一个客户端。在处理完客户端请求后,子进程并不会终止,而是获取下一个待处理的客户端继续处理,如此类推。
采用上述技术需要在服务器应用中仔细地管理子进程。服务池应该足够大,以确保能充分响应客户端的请求。这意味着服务器父进程必须对未占用的子进程加以监视,并且在服务器处于负载高峰期时增加服务池的大小,这样就总会有足够多的子进程存在,从而可以立刻服务于新的客户端请求。如果负载下降了,那么应该相应地降低服务池的大小,因为过多的空余进程会降低系统的整体性能。 此外,服务池中的子进程必须遵循某些协议,使得它们能以独占的方式选择一个客户端连接。 在大多数 UNIX 实现中(包括 Linux), 让服务池中的每个子进程在监听描述符的 accept()调用上阻塞就足够了。换句话说,服务器父进程在创建任何子进程之前先创建监听套接字, 然后每个子进程在 fork()调用中继承该套接字的文件描述符。当一个新的客户端连接到来时,只有其中一个子进程能完成 accept()调用。但是,由于 accept()在一些老式的实现中并不是一个原子化的系统调用,因此可能需要通过一些互斥技术(例如文件锁)来支持,以确保每次只有一个子进程可以执行 accept()调用
在单个进程中处理多个客户端
在某些情况下,我们可以设计让单个服务器进程来处理多个客户端。为了实现这点,我们必须采用一种能允许单个进程同时监视多个文件描述符上 I/O 事件的 I/O 模型(I/O 多路复用、信号驱动 I/O 或者 epoll)。本书第 63 章中描述了这些模型。 在设计单进程服务器时,服务器进程必须做一些通常由内核来处理的调度任务。在每个客户端一个服务器进程地解决方案中,我们可以依靠内核来确保每个服务器进程(从而也确保了客户端)能公平地访问到服务器主机的资源。但当我们用单个服务器进程来处理多个客户端时,服务器进程必须自行确保一个或多个客户端不会霸占服务器,从而使其他的客户端处于饥饿状态。关于这点我们将在 63.4.6 节中继续讨论。
采用服务器集群
其他用来处理高客户端负载的方法还包括使用多个服务器系统—服务器集群(server farm)。构建服务器集群最简单的一种方法是 DNS 轮转负载共享(DNS round-robin load sharing)(或负载分发, load distribution),一个地区的域名权威服务器将同一个域名映射到多个 IP地址上(即,多台服务器共享同一个域名)。后续对 DNS 服务器的域名解析请求将以循环轮转的方式以不同的顺序返回这些 IP 地址。更多关于 DNS 轮转负载共享的信息可以在[Albitz& Liu, 2006]中找到。 DNS 循环轮转的优势是成本低,而且容易实施。但是,它也存在着一些问题。
其中一个问题是远端 DNS 服务器上所执行的缓存操作, 这意味着今后位于某个特定主机(或一组主机)上的客户端发出的请求会绕过循环轮转 DNS 服务器,并总是由同一个服务器来负责处理。此外,循环轮转 DNS 并没有任何内建的用来确保达到良好负载均衡(不同的客户端在服务器上产生的负载不同)或者是确保高可用性的机制(如果其中一台服务器宕机或者运行的服务器 程序崩溃了怎么办?)。
在许多采用多台服务器设备的设计中,另一个我们需要考虑的因素是服务器亲和性(server affinity)。这就是说,确保来自同一个客户端的请求序列能够全部定向到同一台服务器上,这样由服务器维护的任何有关客户端状态的信息都能保持准确。 一个更灵活但也更加复杂的解决方案是服务器负载均衡(server load balancing)。在这种场景下,由一台负载均衡服务器将客户端请求路由到服务器集群中的其中一个成员上。(为了确保高可用性,可能还会有一台备用的服务器。一旦负载均衡主服务器崩溃,备用服务器就立刻接管主服务器的任务。)这消除了由远端 DNS 缓存所引起的问题,因为服务器集群只对外提供了一个单独的 IP 地址(也就是负载均衡服务器的 IP 地址)。负载均衡服务器结合一些算法来衡量或计算服务器负载(可能是根据服务器集群的成员所提供的量值),并智能化地将负载分发到集群中的各个成员之上。负载均衡服务器也会自动检测集群中失效的成员(如果需要,还会自动检测新增加的服务器成员)。最后,负载均衡服务器可能还会提供对服务器亲和力的支持。更多关于服务器负载均衡的信息可以在[Kopparapu, 2002]中找到。
60.5 inetd( Internet 超级服务器)守护进程
如果我们查看一下/etc/services 的内容,可以看到列出了数百个不同的服务项目。这暗示了一个系统理论上可以运行数量庞大的服务器进程。但是,大部分服务器进程通常只是等待着偶尔发送过来的连接请求或数据报,除此之外它们什么都不做。所有这些服务器进程依然会占用内核进程表中的槽位,而且也会占用一些内存和交换空间,因而对系统产生了负载。
守护进程 inetd 被设计为用来消除运行大量非常用服务器进程的需要。 inetd 可提供两个主要的好处。
1.与其为每个服务运行一个单独的守护进程,现在只用一个进程—inetd 守护进程—就可以监视一组指定的套接字端口,并按照需要启动其他的服务。因此可降低系统上运行的进程数量。
2.inetd 简化了启动其他服务的编程工作。因为由 inetd 执行的一些步骤通常在所有的网络服务启动时都会用到。 由于 inetd 监管着一系列的服务,可按照需要启动其他的服务,因此 inetd 有时候也被称为 Internet 超级服务器。
inetd 守护进程所做的操作
inetd 守护进程通常在系统启动时运行。在成为守护进程后(见 37.2 节), inetd 执行如下步骤。
1.对于在配置文件/etc/inetd.conf 中指定的每项服务, inetd 都会创建一个恰当类型的套接字(即流式套接字或数据报套接字),然后绑定到指定的端口号上。此外,每个 TCP套接字都会通过 listen()调用允许客户端发来连接。
2.通过 select()调用(见 63.2.1 节), inetd 对前一步中创建的所有套接字进行监视,看是否有数据报或请求连接发送过来
3.select()调用进入阻塞态,直到一个 UDP 套接字上有数据报可读或者 TCP 套接字上收到了连接请求。在 TCP 连接中, inetd 在进入下一个步骤之前会先为连接执行 accept()调用。
4.要启动这个套接字上指定的服务, inted 调用 fork()创建一个新的进程, 然后通过 exec()启动服务器程序。在执行 exec()前,子进程执行如下的步骤。 (a)除了用于 UDP 数据报和接受 TCP 连接的文件描述符外,将其他所有从父进程继 承而来的文件描述符都关闭。 (b)使用本书 5.5 节中描述的技术,在文件描述符 0、 1 和 2 上复制套接字文件描 述符,并关闭套接字文件描述符本身(因为已经不需要它了)。完成这一步之后, 启动的服务器进程就能通过这三个标准的文件描述符同套接字通信了。 ( c)这一步是可选的。为启动的服务器进程设定用户和组 ID,设定的值可在 /etc/inetd.conf 中的相应条目找到。
5.第 3 步中,如果在 TCP 套接字上接受了一个连接, inetd 就关闭这个连接套接字(因为这个套接字只会在稍后启动的服务器进程中使用)。
6.inetd 服务跳转回第 2 步继续执行。
/etc/inetd.conf 文件
inetd 守护进程的操作由一个配置文件来控制,通常是/etc/inetd.conf。该文件中的每一行都描述了一种由 inetd 处理的服务。程序清单 60-5 展示了一些/etc/inetd.conf 文件中的条目以作为示例。
程序清单 60-5 中的前两行由字符#打头,因此它们被注释掉了。我们这里给出这两行是因为稍后会简单提到 echo 服务。 /etc/inetd.conf 文件中的每一行都由以下字段组成,由空格来将它们分隔开。
1.服务名称(service name):
该字段指定了一项服务的名称,这项服务可在/etc/services 文件中找到。结合协议字段(protocol),就可以通过查找/etc/services 文件以确定 inetd 应该为这项服务监视哪一个端口号。
2.套接字类型(Socket type):
该字段指定了这项服务所用的套接字类型—例如,流式 套接字(stream)还是数据报套接字(dgram)。
3.协议( protocol):
该字段指定了这个套接字所使用的协议。这个字段可以包含文件 /etc/protocols 中所列出的任何 Internet 协议(在 protocol(5)用户手册页中注明),但几 乎所有的服务都会指定 tcp(针对 TCP 协议)或 udp(针对 UDP 协议)。 4.标记(flags):
该字段的内容要么是 wait,要么是 nowait。这个字段指明了由 inetd 启 动的服务器(暂时的)是否会接管用于该服务的套接字。如果启动的服务器需要管理 这个套接字,那么该字段被指定为 wait。这将导致 inetd 把这个套接字从它所监视(通 过 select()实现对多个文件描述符的监视)的文件描述符集合中移除,直到这个服务器 程序退出为止(inetd 可以通过 SIGCHLD 的信号处理例程来检测子进程是否退出)。 对于这个字段,我们下面会做更多的说明
5.登录名( login name):
该字段由/etc/passwd 中的用户名部分组成,还可以在其后 紧跟一个句号以及一个/etc/group 中的组名称。这些名称确定了运行的服务器程 序的用户 ID 和组 ID。(由于 inetd 以 root 方式运行,它的子进程也同样是特权级 的,因而可以在有需要的时候通过调用 setuid()和 setgid()来修改进程的凭据。 )
6.服务器程序(server program):
该字段指定了被执行的服务器程序的路径名。
7.服务器程序参数(server program arguments):
该字段指定了一个或多个参数,参数之间由空格符分隔。当执行服务器程序时,这些参数就作为程序的参数列表。在被执行的服务器程序中, 第一个参数对应于 argv[0], 通常和服务器程序名称的基础部分相同。 下一个参数对应于 argv[1],以此类推。
由 inetd 调用的流式套接字(TCP)服务器通常都被设计为只处理一个单独的客户端连接,处理完后就终止, 把监听其他连接的任务留给了 inetd。 对于这样的服务器, flags 字段应该被设为 nowait。 (相反,如果是由被执行的服务器进程来接受连接的话,那么该字段就应该设为 wait。此时 inetd 不会去接受连接,而是将监听套接字的文件描述符当做描述符 0 传递给被执行的服务器进程。) 对于大部分的 UDP 服务器, flags 字段应该指定为 wait。由 inetd 调用的 UDP 服务器通常被设计为读取并处理所有套接字上未完成的数据报,然后终止。 (从套接字中读取数据时,通常需要一些超时机制,这样在指定的时间间隔内如果没有新的数据报到来,服务器进程就会终止。)通过指定为 wait,我们可以阻止 inetd 在套接字上同时尝试做 select()操作,此时可能会出现我们不期望的结果,因为 inetd 可能会在检查数据报的时候同 UDP 服务器之间产生竞争条件。如果 inetd 赢了,那么它会启动另一个 UDP 服务实例。
inetd 作为一种提高效率的机制,本身就实现了一些简单的服务,而不用通过执行单独的服务器进程来完成任务。 UDP 和 TCP 的 echo 服务就是由 inetd 所实现的例子。对于这样的服务, /etc/inetd.conf 中服务器程序字段对应的记录应该是 internal,而服务器程序参数字段被忽略。 当我们修改了/etc/inetd.conf 文件后,需要发送一个 SIGHUP 信号给 inetd,请求它重新读取配置文件
示例:通过 inetd 调用一个 TCP echo 服务
之前我们提到了 inetd 可以简化服务器程序的编程工作,特别是并发型(通常是 TCP)
服务器。这是因为 inetd 已经帮它所调用的服务器程序完成了以下步骤。
1.执行所有和套接字相关的初始化工作,调用 socket()、 bind()以及 listen()(针对 TCP服务器)。
2.对于一个 TCP 服务,为新到来的连接执行 accept()操作。
3.创建一个新的进程来处理到来的 UDP 数据报或者是 TCP 连接。自动将调用的服务器进程设置为守护进程。 inetd 通过 fork()处理所有与进程创建相关的细节,通过 SIGCHLD 信号处理例程清除所有退出的子进程。
4.将代表 UDP 套接字或 TCP 连接套接字的文件描述符复制到标准文件描述符 0、 1 和2 上,并关闭所有其他的文件描述符(因为它们并不会在调用的服务器进程中用到)。
5.执行服务器程序。 (在上面描述的步骤中, 我们假设 TCP 服务在/etc/inetd.conf 中的 flags 字段指定为 nowait, 而 UDP 服务的 flags 字段指定为 wait。) 在程序清单 60-6 中我们展示了 inetd 是如何简化 TCP 服务的编程工作的。 我们让 inetd 调用了一个 TCP echo 服务,该服务同程序清单 60-4 所示的 TCP echo 服务相同。由于 inetd 执行了所有上述描述过的步骤,因此剩下的任务就是编写子进程所执行的处理客户端请求的代码,客户端请求可以从文件描述符 0(STDIN_FILENO)读取。 如果服务器程序在/bin 目录下(打个比方),那么我们可能需要在/etc/inetd.conf 文件中创建如下的条目,使得 inetd 可以调用该服务器程序
60.6 总结
迭代型服务器一次只处理一个客户端,在处理下一个客户端请求之前必须将当前客户端的请求处理完毕。
并发型服务器可以同时处理多个客户端请求。在高负载的情况下,传统的并发型服务器为每个客户端创建新的子进程(或线程),这样的性能表现并不能达到要求。为此,我们针对需要同时处理大量客户端的并发型服务器,列举出了一些其他的设计方法。 Internet 超级服务器守护进程 inetd 可以监视多个套接字, 并启动合适的服务器进程作为到来的 UDP 数据报或 TCP 连接的响应。通过使用 inetd,可以将运行在系统上的网络服务进程的数量降到最小,从而降低系统的整体负载。同时,也可以简化服务器端的编程工作。因为服务器进程初始化阶段所需要的大部分操作 inetd 都可以帮我们完成。