15.4 多客户
到目前为止,本章一直介绍的是,如何用套接字来实现本地的和跨网络的客户/服务器系统。一旦连接建立,套接字连接的行为就类似于打开的底层文件描述符,而且在很多方面类似于双向管道。
我们现在来考虑有多个客户同时连接一个服务器的情况。你已经看到,服务器程序在接受来自客户的一个新连接时,会创建出一个新的套接字,而原先的监听套接字将被保留以继续监听以后的连接。如果服务器不能立刻接受后来的连接,它们将被放到队列中以等待处理。
原先的套接字仍然可用并且套接字的行为就像文件描述符,这一事实给我们提供了一种同时服务多个客户的方法。如果服务器调用fork为自己创建第二份副本,打开的套接字就将被新的子进程所继承。新的子进程可以和连接的客户进行通信,而主服务器进程可以继续接受以后的客户连接。这些改动对我们的服务器程序来说是非常容易的,下面的实验部分将给出修改过的服务器程序。
因为我们创建子进程,但并不等待它们的完成,所以必须安排服务器忽略SIGCHLD信号以避免出现僵尸进程(2)
实 验 可以同时服务多个客户的服务器
(1)这个程序server4.c的开始部分与我们前面的服务器一脉相承,只是增加了一条包含signal.h头文件的include语句。变量的定义和创建、命名套接字的过程与以前一样:
(2)创建一个连接队列,忽略子进程的退出细节,等待客户的到来:
(3)接受连接:
(4)通过fork调用为这个客户创建一个子进程,然后测试你是在父进程中还是在子进程中:
(5)如果你是在子进程中,就可以对client_sockfd上的客户执行读/写操作。5秒的延迟只是出于演示的目的:
(6)否则,你一定是在父进程中,你只需关闭这个客户:
在处理客户请求时插入的5秒延迟是为了模拟服务器的计算时间或数据库访问时间。如果在前面的服务器中这样做,client3的每次运行都将花费5秒钟的时间。而新服务器可以同时处理多个client3程序,所花费的总时间将只有5秒钟多一点。
实验解析
服务器程序现在将创建一个新的子进程来处理每个客户,所以你将看到好几个服务器在等待消息,而主进程将继续等待新的连接。ps命令的输出(这里进行了编辑)显示,PID为26566的server4进程正在等待新的客户,而3个client3进程正在由3个服务器的子进程进行服务。在经过5秒的暂停后,所有的客户都得到了它们的结果并结束运行。服务器的子进程也都退出,只留下主服务器进程在运行。
服务器程序用fork函数来处理多个客户。但在数据库应用程序中,这可能不是最佳的解决方案。因为服务器程序可能会相当大,而且在数据库访问方面还存在着需要协调多个服务器副本的问题。事实上,我们真正需要的是,如何让单个服务器进程在不阻塞、不等待客户请求到达的前提下处理多个客户。这个问题的解决方案涉及如何同时处理多个打开的文件描述符,并且它不仅仅局限于套接字应用程序,请看下一节的select系统调用。
15.4.1 select系统调用
在编写Linux应用程序时,我们经常会遇到需要检查好几个输入的状态才能确定下一步行动的情况。例如,像终端仿真器这样的通信程序,需要有效地同时读取键盘和串行口。如果是在一个单用户系统中,运行一个“忙等待”循环还是可以接受的,它不停地扫描输入设备看是否有数据,如果有数据到达就读取它。但这种做法很消耗CPU的时间。
select系统调用允许程序同时在多个底层文件描述符上等待输入的到达(或输出的完成)。这意味着终端仿真程序可以一直阻塞到有事情可做为止。类似地,服务器也可以通过同时在多个打开的套接字上等待请求到来的方法来处理多个客户。
select函数对数据结构fd_set进行操作,它是由打开的文件描述符构成的集合。有一组定义好的宏可以用来控制这些集合:
顾名思义,FD_ZERO用于将fd_set初始化为空集合,FD_SET和FD_CLR分别用于在集合中设置和清除由参数fd传递的文件描述符。如果FD_ISSET宏中由参数fd指向的文件描述符是由参数fdset指向的fd_set集合中的一个元素,FD_ISSET将返回非零值。fd_set结构中可以容纳的文件描述符的最大数目由常量FD_SETSIZE指定。
select函数还可以用一个超时值来防止无限期的阻塞。这个超时值由一个timeval结构给出。这个结构定义在头文件sys/time.h中,它由以下几个成员组成:
类型time_t文件sys/types.h中被定义为一个整数类型。
select系统调用的原型如下所示:
select调用用于测试文件描述符集合中,是否有一个文件描述符已处于可读状态或可写状态或错误状态,它将阻塞以等待某个文件描述符进入上述这些状态。
参数nfds指定需要测试的文件描述符数目,测试的描述符范围从0到nfds-1。3个描述符集合都可以被设为空指针,这表示不执行相应的测试。
select函数会在发生以下情况时返回:readfds集合中有描述符可读、writefds集合中有描述符可写或errorfds集合中有描述符遇到错误条件。如果这3种情况都没有发生,select将在timeout指定的超时时间经过后返回。如果timeout参数是一个空指针并且套接字上也没有任何活动,这个调用将一直阻塞下去。
当select返回时,描述符集合将被修改以指示哪些描述符正处于可读、可写或有错误的状态。我们可以用FD_ISSET对描述符进行测试,来找出需要注意的描述符。你可以修改timeout值来表明剩余的超时时间,但这并不是在X/Open规范中定义的行为。如果select是因为超时而返回的话,所有描述符集合都将被清空。
select调用返回状态发生变化的描述符总数。失败时它将返回-1并设置errno来描述错误。可能出现的错误有:EBADF(无效的描述符)、EINTR(因中断而返回)、EINVAL(nfds或timeout取值错误)。
虽然Linux系统会把参数timeout指向的结构修改为剩余的超时时间,但大多数UNIX版本不会这样做。许多现有的使用select函数的代码在初始化timeval结构后,就一直使用它而不会重新初始化它的内容。但这些代码在Linux系统上可能会工作不正常,因为Linux会在每次select调用返回时修改timeval结构。如果你正在编写或移植使用select函数的代码,就需要注意这一区别,并且总是重新初始化timeout。注意,这两种行为都是正确的,但它们确实不同!
实 验 select系统调用
下面这个程序select.c演示了select函数的使用方法。我们稍后还会看到一个更复杂的例子。这个程序读取键盘(即标准输入——文件描述符为0),超时时间设为2.5秒。它只有在输入就绪时才读取键盘。它可以很容易地通过添加其他描述符(如串行线、管道、套接字等)进行扩展,具体做法取决于应用程序的需要。
(1)开始部分还是与往常一样,包含必要的头文件和变量声明,然后对inputs进行初始化以处理来自键盘的输入:
(2)在标准输入stdin上最多等待输入2.5秒:
(3)经过这段时间之后,我们对result进行测试。如果没有输入,程序将再次循环。如果出现一个错误,程序将退出运行:
(4)如果在等待期间,你对文件描述符采取了一些动作,程序就将读取标准输入stdin上的输入,并在接收到行尾字符后把它们都回显到屏幕上,当你输入的字符是Ctrl+D时,就退出程序:
运行这个程序时,它会每隔2.5秒打印一个timeout。如果在键盘上敲入字符,它就会从标准输入读取数据并报告敲入的内容。对大多数shell来说,输入会在用户按下回车键或某个控制序列时被发送给程序,所以这个程序将在你按下回车键时把输入内容显示出来。注意,回车键本身也像其他字符一样被读取和处理(你可以尝试不按下回车键,而是在敲入几个字符后按下组合键Ctrl+D,看看会怎么样)。
实验解析
这个程序用select调用来检查标准输入的状态。程序通过事先安排的超时时间每隔2.5秒打印一个timeout信息,这是通过select调用返回0来判断的。在文件的结尾,标准输入描述符被标记为可读,但没有字符可以读取。
15.4.2 多客户
我们的简单服务器程序可以从select调用中获得益处,通过用select调用来同时处理多个客户就无需再依赖于子进程了。但在把这个技巧应用到实际的应用程序中时,你必须要注意,不能在处理第一个连接的客户时让其他客户等太长的时间。
服务器可以让select调用同时检查监听套接字和客户的连接套接字。一旦select调用指示有活动发生,就可以用FD_ISSET来遍历所有可能的文件描述符,以检查是哪个上面有活动发生。
如果是监听套接字可读,这说明正有一个客户试图建立连接,此时就可以调用accept而不用担心发生阻塞的可能。如果是某个客户描述符准备好,这说明该描述符上有一个客户请求需要我们读取和处理。如果读操作返回零字节,这表示有一个客户进程已结束,你可以关闭该套接字并把它从描述符集合中删除。
实 验 一个改进的多客户/服务器
(1)作为本章最后一个例子server5.c,我们用头文件sys/time.h和sys/ioctl.h替换掉上一个程序中的signal.h,并且为select调用定义了一些变量:
(2)为服务器创建并命名一个套接字:
(3)创建一个连接队列,初始化readfds以处理来自server_sockfd的输入:
(4)现在开始等待客户和请求的到来。因为你给timeout参数传递了一个空指针,所以select调用将不会发生超时。如果select调用的返回值小于1,程序将退出并报告出现的错误:
(5)一旦你得知有活动发生,可以用FD_ISSET来依次检查每个描述符,以发现活动发生在哪个描述符上:
(6)如果活动是发生在套接字server_sockfd上,它肯定是一个新的连接请求,你就把相关的client_sockfd添加到描述符集合中:
(7)如果活动不是发生在服务器套接字上,那肯定是客户的活动。如果接收到的活动是close,就说明客户已经离开,你可以把该客户的套接字从描述符集合中删除。否则,就可以像前面的例子那样为客户进行服务。
在实际应用的程序中,最好用一个变量来专门保存已连接套接字的最大文件描述符号(它不一定是最新连接的套接字文件描述符号)。这可以避免循环检查数千个其实并未连接的套接字,它们根本不可能处于可读状态。出于简洁和让代码易于理解的目的,我们在这里没有这样做。
运行服务器的这个版本时,它将在一个进程中对多个客户依次进行处理。
为了让本章开头的类比更完整,表15-6对套接字连接和电话接入进行了对比。
表 15-6