客户服务器设计范式

当开发—个Unix服务器程序时,我们有如下类型的进程控制可供选择。

迭代服务器(iterative server) 程序:不过这种类型的适用情形极为有限,因为这样的服务器在完成对当前客户的服务之前无法处理已等待服务的新客户。
基于多进程的并发服务器(concurrent server) 程序,它为每个客户调用fork派生一个子进程。传统上大多数Unix服务器程序属于这种类型。
基于I/O复用的迭代服务器:使用select处理任意多个客户的单个进程构成。
基于多线程的并发服务器:为每个客户创建一个线程。

并发服务器程序设计的另两类变体。

  1. 预先派生子进程(preforking) 是让服务器在启动阶段调用fork创建一个子进程池。每个客户诮求由当前可用子进程池中的某个(闲置)子进程处理。
  2. 预先创建线程(prethreading) 是让服务器在启动阶段创建一个线程池,每个客户由当前可用线程池中的某个(闲置)线程处理。
TCP 预先派生子进程服务器程序, accept 无上锁保护

使用称为预先派生子进程(preforking) 的技术。使用该技术的服务器不像传统意义的并发服务器那样为每个客户现场派生一个子进程,而是在启动阶段预先派生一定数量的子进程, 当各个客户连接到达时,这些子进程立即就能为它们服务。
这种技术的优点在于无须引入父进程执行fork的开销就能处理新到的客户。缺点则是父进程必须在服务器启动阶段猜测需要预先派生多少子进程。如果某个时刻客户数恰好等于子进程总数,那么新到的客户将被忽略,直到至少有一个子进程重新可用
我们知道这些客户并未被完全忽略。内核将为每个新到的客户完成三路握手, 直到达到相应套接字上listen调用的backlog数为止,然后在服务器调用accept时把这些已完成的连接传递给它。这么一来客户就能觉察到服务器在响应时间上的恶化,因为尽管它的connect调用可能立即返回,但是它的第一个请求可能是在一段时间之后才被服务器处理。
父进程必须做的就是持续监视可用(即闲置)子进程数, 一旦该值降到低于某个阈值就派生额外的子进程。同样,一旦该值超过另一个阈值就终止一些过剩的子进程,因为在本章后面我们会发现过多的可用子进程也会导致性能退化。

多个进程如何实现在同一个监听描述符上调用accept?
父进程在派生任何子进程之前创建监听套接字,而每次调用fork时,所有描述符也被复制。图展示了proc结构(每个进程一个)、监听描述符的单个file结构以及单个socket结构之间的关系。
述符只是本进程引用file结构的proc结构中一个数组中某个元素的下标而已。fork调用执行期间为子进程复制描述符的特性之一是:子进程中一个给定描述符引用的file结构正是父进程中同一个描述符引用的file结构。每个file结构都有一个引用计数。当打开一个文件或套接字时, 内核将为之构造一个file结构,并由作为打开操作返回值的描述符引用,它的引用计数初值自然为1 ; 以后每当调用fork以派生子进程或对打开操作返回的描述符(或其复制品)调用dup以复制描述符时, 该file结构的引用计数就递增(每次增1)。在我们的N个子进程的例子中, file结构的引用计数为N+ 1(别忘了父进程仍然保持该监听描述符打开着, 不过它从不调用accept ) 。

服务器进程在程序启动阶段派生N个子进程, 它们各自调用accept并因而均被内核投入睡眠。当第一个客户连接到达时,所有N个子进程均被唤醒(惊群)。这是因为所有N个子进程所用的监听描述符(它们有相同的值)指向同一个socket结构, 致使它们在同一个等待通道( wait channel) 即这个socket结构的so_timeo成员上进入睡眠。尽管所有N个子进程均被唤醒, 其中只有最先运行的子进程获得那个客户连接, 其余N- 1 个子进程继续回复睡眠。
这就是有时候称为惊群( thundering herd ) 的问题, 因为尽管只有一个子进程将获得连接,所有N个子进程却都被唤醒了。尽管如此这段代码依然起作用, 只是每当仅有一个连接准各好被接受时却唤醒太多进程的做法会导致性能受损。

TCP 预先派生子进程服务器程序, accept 使用文件上锁保护

为了避免多个进程都阻塞在对同一个套接字描述符上调用accept,解决办法是让应用进程在调用accept前后安置某种形式的锁(lock), 这样任意时刻只有一个子进程阻塞在accept调用中,其他子进程则阻塞在试图获取用于保护accept 的锁上。
上锁使用以fcntl 函数呈现的POSIX文件上锁功能。

TCP 预先派生子进程服务器程序, accept使用线程上锁保护

我们提过有多种方法可用于实现进程之间的上锁。上一节使用的POSIX文件上锁方法可移植到所有POSIX兼容系统,不过它涉及文件系统操作,可能比较耗时。本节我们改用线程上锁保护accept, 因为这种方法不仅适用于同一进程内各线程之间的上锁,而且适用于不同进程之间的上锁。
在不同进程之间使用线程上锁要求:
(1) 互斥锁变最必须存放在由所有进程共享的内存区中。
(2) 必须告知线程函数库这是在不同进程之间共享的互斥锁。

TCP 预先派生子进程服务器程序,传递描述符

对预先派生子进程服务器程序的最后一个修改版本是只让父进程调用accept, 然后把所接受的已连接套接字“传递”给某个子进程。这么做绕过了为所有子进程的accept调用提供上锁保护的可能需求,不过需要从父进程到子进程的某种形式的描述符传递。这种技术会使代码多少有点复杂,因为父进程必须跟踪子进程的忙闲状态,以便给空闲子进程传递新的套接字。
在调用fork之前先创建一个字节流管道,它是一对Unix域字节流套接字。派生出子进程之后,父进程关闭其中一个描述符(sockfd[1] ) , 子进程关闭另一个描述符( sockfd [0]) 。

TCP 并发服务器程序,每个客户一个线程

主线程大部分时间阻塞在一个accept调用之中,每当它返回一个客户连接时,就调用pthread_create创建一个新线程。新线程执行的函数是doit,其参数是所返回的已连接套接字。doit 函数先让自己脱离,使得主线程不必等待它,然后调用函数处理对客户的事务 。该函数返回后关闭已连接套接字。

TCP 预先创建线程服务器程序,每个线程各自accept

预先派生一个子进程池快于为每个客户现场派生一个子进程。在支持线程的系统上,我们有理由预期在服务器启动阶段预先创建一个线程池以取代为每个客户现场创建一个线程的做法有类似的性能加速。本服务器的基本设计是预先创建一个线程池,并让每个线程各自调用accept。取代让每个线程都阻塞在accept调用之中的做法,我们改用互斥锁以保证任何时刻只有— 个线程在调用accept 。这里没有理由使用文件上锁保护各个线程中的accept调用,因为对于单个进程中的多个线程,我们总可以使用互斥锁达到同样目的。

TCP 预先创建线程服务器程序,主线程统一accept

最后一个使用线程的服务器程序设计范式是在程序启动阶段创建一个线程池之后只让主线程调用accept并把每个客户连接传递给池中某个可用线程。这一点类似于之前子进程的描述符传递版本。
本设计范式的问题在于主线程如何把一个已连接套接字传递给线程池中某个可用线程。这里有多个实现手段。我们原本可以如前使用描述符传递,不过既然所有线程和所有描述符都在同一个进程之内,我们没有必要把一个描述符从一个线程传递到另一个线程。接收线程只需知道这个已连接套接字描述符的值,而描述符传递实际传递的并非这个值,而是对这个套接字的一个引用。
首先创建线程池中的每个线程,主线程大部分时间阻塞在accept调用中, 等待各个客户连接的到达。一旦某个客户连接到达, 主线程就把它的已连接套接字描述符告知处理该事务的子线程,

客户服务器设计范式总结:

(0) 迭代服务器;
(1) 并发服务器, 每个客户请求fork一个子进程;
(2) 预先派生子进程,每个子进程无保护地调用accept.;
(3) 预先派生子进程,使用文件上锁保护accept ;
(4) 预先派生子进程,使用线程互斥锁上锁保护accept ;
(5) 预先派生子进程,父进程向子进程传递套接字描述符;
(6) 并发服务器,每个客户请求创建一个线程;
(7) 预先创建线程服务器,使用互斥锁上锁保护accept ;
(8) 预先创建线程服务器,由主线程调用accept. 。

惊群现象:

惊群现象(thundering herd)就是当多个进程和线程在同时阻塞等待同一个事件时,如果这个事件发生,会唤醒所有的进程,但最终只可能有一个进程/线程对该事件进行处理,其他进程/线程会在失败后重新休眠,这种性能浪费就是惊群。
其实在Linux2.6版本以后,内核内核已经解决了accept()函数的“惊群”问题,大概的处理方式就是,当内核接收到一个客户连接后,只会唤醒等待队列上的第一个进程或线程。所以,如果服务器采用accept阻塞调用方式,在最新的Linux系统上,已经没有“惊群”的问题了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值