当讨论客户-服务器情形和过程调用时,存在着三种不同类型的过程调用,如图15-1所示。
图15-1 三种不同类型的过程调用
(1)本地过程调用(local procedure call):这是我们从日常的C编程中早就熟知的过程调用,也就是被调用的过程(函数)与调用过程处于同一个进程中。典型情况是,调用者通过执行某条机器指令把控制传给新过程,被调用过程保存机器寄存器的值,并在栈顶分配存放其本地变量的空间。
(2)远程过程调用(remote procedure call, RPC):被调用过程和调用过程处于不同的进程中。我们通常称调用者为客户,称被调用的过程为服务器。在图15-1中间的情形中,我们展示客户和服务器是在同一台主机上执行的。它是该图底部的情形经常会发生的一种特殊情况,也是门(door)所提供的能力:一个进程调用同一台主机上另一个进程中的某个过程(函数)。通过给本进程内的某个过程创建一个门,一个进程(服务器)就使得该过程能为其他进程(客户)所调用。我们也可以认为门是一种特殊类型的IPC,因为客户和服务器之间以函数参数和返回值形式交换信息。
(3)RPC通常允许一台主机上的某个客户调用另一台主机上的某个服务器过程,只要这两台主机以某种形式的网络连接着(图15-1底部的情形)。我们将在第16章讲述这样的RPC。
从历史上说,门是为Spring分布式操作系统开发的,具体细节可以从http://www.sun.com/tech/projects/spring上得到。[Hamilto.an.Kougiouri.1993]中有关于该操作系统中门IPC机制的一个说明。
后来门添加到了Solaris 2.5中,不过有关它的唯一手册页面中只含有一个警告,说门是仅由某些Sun应用程序使用的一个试验性接口。到了Solaris 2.6后,该接口的文档出现在8个手册页面中,不过这些手册页面把该接口的稳定性列为“进展中(evolving)”。预期我们在本章讲述的API可能会随Solaris将来版本的出现而发生变化。Linux上门接口的初级版本也在开发中,可访问http://www.cs.brown.edu/~tor/doors。 [1]
Solaris 2.6中门的实现涉及一个函数库(它含有我们将在本章中描述的door_XXX函数)和一个内核文件系统(/kernel/sys/doorfs),用户的应用程序需链接这个函数库(使用-ldoor命令行选项链接程序)。
尽管门是一个Solaris特有的特性,我们还是详细地讲述它,因为它们提供了很不错的远程过程调用的入门知识,又不必对付任何连网支持细节。我们还将在附录A中看到,与所有其他形式的消息传递机制相比较,门不是更快也至少一样快。
本地过程调用是同步的:调用者直到被调用过程返回后才重新获得控制。线程可认为是提供了某种形式的异步过程调用:有一个函数被调用(pthread_create的第三个参数),该函数和调用者看起来在同时执行。调用者可通过调用pthread_join等待这个新线程的完成。远程过程调用可能是同步的,也可能是异步的,不过我们将看到门调用是同步的。
在进程(客户或服务器)内部,门是用描述符标识的。在进程以外,门可能是用文件系统中的路径名标识的。一个服务器通过调用door_create创建一个门,传递给该函数的参数是将与该门关联的过程的一个指针,该函数的返回值是新创建的门的一个描述符。该服务器然后通过调用fattach给这个门描述符关联一个路径名。一个客户通过调用open来打开一个门,传递给该函数的参数是该门的服务器关联在其上的路径名,该函数的返回值是本客户访问该门的描述符。该客户然后通过调用door_call调用服务器过程。自然,某个门的服务器可以是另一个门的客户。
我们说过门调用是同步的:当客户调用door_call时,该函数直到服务器过程返回(或发生某个错误)时才返回。Solaris的门实现还跟线程相联系。每当有一个客户调用某个服务器过程时,服务器进程中的一个线程就处理该客户的调用。线程管理通常是由门函数库自动进行的,该函数库根据需要创建新的线程,然而我们将看到,如果需要服务器进程亲自管理这些线程的话,它应该怎么去做。这还意味着一个给定的服务器可以同时为多个客户调用同一个服务器过程提供服务,每个客户一个线程。这是一个并发(concurrent)服务器。既然一个给定服务器过程可能有多个实例在同时执行(每个实例作为一个线程),因此服务器过程必须是线程安全的。
调用一个服务器过程时,可以同时从客户向服务器传递数据和描述符。也可以同时从服务器向客户传递回数据和描述符。描述符传递对于门来说是内在的。而且,既然门是用描述符标识的,因此描述符传递允许一个进程把一个门传递给另外某个进程。我们将在15.8节详细讨论描述符传递。
例子
我们以一个简单的例子开始关于门的讨论:客户向服务器传递一个长整数,服务器以长整数结果返回该值的平方。图15-2给出了客户程序。(我们在本例子中掩盖了许多细节,它们在本章以后再讨论。)
图15-2 向服务器发送一个长整数以求取其平方值的客户
打开门
8~10 调用open打开由命令行上的路径名指定的门。所返回的描述符称为门描述符(door descriptor),不过我们有时候就称它为门(door)。
设置参数和指向结果的指针
11~18 arg参数含有一个指向参数的指针和一个指向结果的指针。data_ptr指向参数的第一个字节,data_size指定参数的字节数。desc_ptr和desc_num处理描述符的传递,我们将在15.8节中讲述。rbuf指向结果缓冲区的第一个字节,rsize是它的大小。
调用服务器过程并输出结果
19~21 通过调用door_call来调用服务器过程,作为参数指定的是所打开的门描述符和指向所设置参数结构的指针。调用返回后输出结果。
图15-3给出了服务器程序。它由一个名为servproc的服务器过程和一个main函数构成。
服务器过程
2~10 调用服务器过程需有5个参数,但是我们真正使用的是dataptr,它指向参数的第一个字节。通过该指针取出长整数参数后求它的平方。然后控制随结果由door_return传递回客户。该函数的第一个参数是指向结果的指针,第二个参数是结果的大小,其余两个参数处理描述符的返回。
图15-3 返回一个长整数的平方值的服务器程序
创建一个门描述符并附接到路径名
17~21 调用door_create创建一个门描述符。该函数的第一个参数是指向与该门关联的服务器函数(servproc)的指针。取得门描述符后,必须将它与文件系统中的一个路径名关联,因为该路径名是客户标识这个门的手段。这种关联通过在文件系统中创建一个普通文件(我们首先调用unlink以防该文件已存在,同时忽略它的任何出错返回)后调用fattach完成,其中fattach是把一个描述符与一个路径名相关联的SVR4函数。
主服务器线程不干活
22~24 主服务器线程然后阻塞在一个pause调用中。所有工作全由servproc函数去做,每次有一个客户请求到达时,该函数就作为服务器进程中的另一个线程来执行。
为运行这个客户和服务器程序,我们首先在一个窗口中启动服务器:
solaris % server1 /tmp/server1
然后在另一个窗口中启动客户,所指定的路径名参数与我们传递给服务器的相同:
solaris % client1 /tmp/server1 9
result: 81
solaris % ls -l /tmp/server1
Drw-r--r-- 1 rstevens other1 0 Apr 9 10:09 /tmp/server1
结果与预期的一致,当执行ls时,我们看到其输出中第一个字符为D,表明路径名/tmp/server1是某个门的路径名。
图15-4展示了本例子表现的行为过程。它看起来是door_call调用了服务器过程,服务器过程然后返回。
图15-4 从一个进程到另一个进程的表象过程调用
图15-5展示了调用同一台主机上另一个进程中的某个过程时真正发生的行为。
图15-5 从一个进程到另一个进程的过程调用的真正控制流
图15-5中发生了以下几个编了号的步骤。
(0)服务器进程首先启动,它调用door_create创建一个指代函数servproc的门描述符,然后把该描述符附接到文件系统中的某个路径名。
(1)客户进程启动,它调用door_call。这实际上是门函数库中的一个函数。
(2)door_call库函数执行一个进入内核的系统调用。标识出目标过程后,控制被传递到目标进程中的某个门库函数。
(3)真正的服务器过程(本例子中名为servproc)被调用。
(4)服务器过程执行处理客户请求所需的任意操作,执行完后调用door_return。
(5)door_return实际上是门函数库中的一个函数,它执行一个进入内核的系统调用。
(6)标识出客户后把控制传递回该客户。
本章其余各节更为详尽地讲述门API,同时查看许多例子。在附录A中我们将看到,就延迟而言,门提供了最快的IPC形式。
door_call函数由客户调用,它会调用在服务器进程的地址空间中执行的一个服务器过程。
#include <door.h>
int door_call(int fd,door_arg_t *argp);
返回:若成功则为0,若出错则为−1
其中描述符fd通常是由open返回的(例如图15-2)。由客户打开并将所返回的描述符作为第一个参数传递给door_call的路径名标识了该函数所调用的服务器过程。
第二个参数argp指向一个结构,该结构描述了调用参数和用于容纳返回值的缓冲区。
typedef struct door_arg {
char *data_ptr; /* call: ptr to data arguments;
return: ptr to data results */
size_t data_size; /* call: #bytes of data arguments;
return: actual #bytes of data results */
door_desc_t *desc_ptr; /* call: ptr to descriptor arguments;
return: ptr to descriptor results */
size_t desc_num; /* call: number of descriptor arguments;
return: number of descriptor results */
char *rbuf; /* ptr to result buffer */
size_t rsize; /* #bytes of result buffer */
} door_arg_t;
返回时也由该结构描述返回值。该结构的所有6个成员在返回时都可能有变化,我们马上就讲到。
给其中的两个指针成员使用char*数据类型有些奇怪,为避免出现编译警告,我们不得不在代码中显式地对它们进行类型强制转换。我们倒希望它们是void*数据类型的指针。door_return的第一个参数也同样使用了char*。Solaris 2.7也许会把desc_num的数据类型改为unsigned int,door_return的最后一个参数也将相应地作修改。
无论是参数还是结果都存在两种数据类型:数据和描述符。
数据参数(data arguments)是由data_ptr指向的一系列总共data_size个字节。客户和服务器必须以某种方式“知道”这些参数(以及结果)的格式。举例来说,无法通过特殊的编码告诉服务器参数的数据类型。在图15-2和图15-3中,客户程序和服务器程序编写成知道参数是一个长整数,结果也是一个长整数。封装数据类型信息的办法之一是(为了若干年后有人仍能读懂代码):把所有的参数放进一个结构中,把所有的结果放进另一个结构中,把这两个结构都定义在一个头文件中,客户程序和服务器程序都包括进这个头文件。我们将随图15-11和图15-12给出一个这样的例子。如果没有数据参数,我们就把data_ptr指定成一个空指针,把data_size指定为0。
由于客户和服务器处理的是封装到一个参数缓冲区和一个结果缓冲区中的二进制参数和结果,因而隐含着客户程序和服务器程序必须以同样的编译器编译的要求。有时候在同样的系统上,不同的编译器也会以不同的方式封装结构。
描述符参数(descriptor arguments)是一个door_desc_t结构的数组,每个元素含有一个从客户往服务器过程传递的描述符。所传递的door_desc_t结构数为desc_num。(我们将在15.8节描述这个结构以及“传递描述符”的含义。)如果没有描述符参数,我们就把desc_ptr指定成一个空指针,把desc_num指定为0。
返回时data_ptr指向数据结果(data results),data_size则指定这些结果的大小。如果没有数据结果,data_size就为0,这时我们应该忽略data_ptr。
返回时也可能有描述符结果(descriptor results):desc_ptr指向一个door_desc_t结构的数组,每个元素含有一个由服务器过程传递回客户的描述符。所返回的door_desc_t结构数存放在desc_num中。如果没有描述符结果,desc_num就为0,这时我们应该忽略desc_ptr。
给参数和结果使用同样的缓冲区是可行的。这就是说调用door_call时,data_ptr和desc_ptr都可指向由rbuf指定的缓冲区中。
在调用door_call前,客户把rbuf设置成指向存放结果的缓冲区,把rsize设置成该缓冲区的大小。返回时data_ptr和desc_ptr通常都指向这个结果缓冲区中。如果该缓冲区太小而容纳不了服务器的结果,门函数库就会在调用者的地址空间中使用mmap(12.2节)自动分配一个新的缓冲区,然后相应地更新rbuf和rsize。data_ptr和desc_ptr将指向这个新分配的缓冲区中。留意rbuf的变化并在以后某个时刻以rbuf和rsize为参数调用munmap把门函数库分配的缓冲区返还给系统,这些是调用者的责任。我们将随图15-7给出这样的一个例子。
服务器进程通过调用door_create建立一个服务器过程。
#include <door.h>
typedef void Door_server_proc(void *cookie,char *dataptr,size_t datasize,
door_desc_t *descptr,size_t ndesc);
int door_create(Door_server_proc *proc,void *cookie,u_int attr);
返回:若成功则为非负描述符,若出错则为−1
在上述的声明中,我们加上了自己的typedef,这样简化了服务器过程的函数原型。这个typedef语句说,门服务器过程(例如图15-3中的servproc)是以5个参数调用的,不返回任何值。
当一个服务器调用door_create时,传递给该函数的第一个参数proc是一个服务器过程的地址,该服务器过程将与由该函数返回的门描述符相关联。当调用这个服务器过程时,它的第一个参数cookie是作为door_create的第二个参数传递进去的值。这么一来给服务器提供了向服务器过程传递某个指针的一种方式,该指针在每次有一个客户调用该过程时传递。服务器过程的以后4个参数(dataptr、datasize、descptr、ndesc)描述来自服务器的数据参数和描述符参数,也就是上一节中描述的door_arg_t结构的前4个成员所描述的信息。
door_create的最后一个参数attr描述所创建服务器过程的特殊属性,它或为0,或为以下两个常值的按位或。
DOOR_PRIVATE 随着客户请求的到达,门函数库在服务器进程中自动创建必要的新线程以调用服务器过程。按照默认设置,这些线程放在进程范围的线程池中,可用于给服务器进程中的任何门提供客户请求的服务。指定DOOR_PRIVATE属性就是告诉门函数库该门得有自己的服务器线程池,与进程范围的池分开。
DOOR_UNREF 当指代该门的描述符数从2降为1时,将第二个参数(dataptr)指定为DOOR_UNREF_DATA再次调用该服务器过程。这次调用的descptr参数是一个空指针,datasize和ndesc则均为0。我们将从图15-16的讨论开始给出这个属性的一些例子。
服务器过程的返回值声明为void,因为它们从不通过调用return或掉出函数尾返回。它们会调用我们将在下一节描述的door_return。
我们从图15-3中已看到,调用door_create获取一个门描述符之后,服务器通常调用fattach给该描述符关联以一个文件系统中的路径名。客户open该路径名就获得了调用door_call所需的门描述符。
fattach不是一个Posix.1函数,但是Unix 98却需要它。另外,有一个名为fdetach的函数撤销这种关联,名为fdetach的命令则简单地激活该同名函数。
由door_create创建的门描述符在其文件描述符标志中设置了FD_CLOEXEC位。这意味着创建一个门描述符的进程如果调用了任意一个exec函数,该描述符就被内核关闭。至于fork,尽管父进程中已打开的所有描述符随后为子进程所共享,却只有父进程会收到来自客户的门激活请求,这些请求不会递交给子进程,即使由door_create返回的描述符在子进程中也是打开的。
如果我们把一个门考虑成是由一个进程ID和待调用的相应服务器过程的地址标识的(我们将从在15.6节介绍的door_info_t结构中看到这两个标识信息),那么关于fork和exec的这两条规则就有意义了。子进程永远得不到门激活请求是因为与该门关联的进程ID是调用door_create的父进程的进程ID。遇到exec调用时门描述符必须关闭的原因则在于,即使进程ID没有改变,与该门关联的服务器过程的地址在exec之后新激活的程序中已失去意义。
服务器过程完成工作时通过调用door_return返回。这会使客户中相关联的door_call调用返回。
#include <door.h>
int door_return(char *dataptr,size_t datasize,door_desc_t *descptr,size_t *ndesc);
返回:若成功则不返回到调用者,若出错则为−1
数据结果由dataptr和datasize指定,描述符结果由descptr和ndesc指定。
门有一个很不错的特性:服务器过程能够获取每个调用对应的客户凭证。这是由door_cred函数完成的。
#include <door.h>
int door_cred(door_cred_t *cred);
返回:若成功则为0,若出错则为−1
其中由cred指向的door_cred_t结构将在返回时填入客户的凭证。
typedef struct door_cred {
uid_tdc_euid; /* effective user ID of client */
gid_tdc_egid; /* effective group ID of client */
uid_tdc_ruid; /* real user ID of client */
gid_tdc_rgid; /* real group ID of client */
pid_tdc_pid; /* process ID of client */
} door _cred_t;
APUE的4.4节讨论了有效ID和实际ID之间的差别,我们将随图15-8给出体现这种差异的一个例子。
注意本函数中没有描述符参数。本函数返回发起当前门激活请求的客户的有关信息,因而必须在服务器过程或由该过程调用的某个函数中调用它。
我们刚才描述的door_cred函数给服务器提供关于客户的信息。客户也可通过调用door_info函数找出有关服务器的信息。
#include <door.h>
int door_info(int fd,door_info_t *info);
返回:若成功则为0,若出错则为−1
其中fd指定一个已打开的门。由info指向的door_info_t结构将在返回时填入关于服务器的信息。
typedef struct door_info {
pid_t di_target; /* server process ID */
door_ptr_t di_proc; /* server procedure */
door_ptr_t di_data; /* cookie for server procedure */
door_attr_t di_attributes; /* attributes associated with door */
door_id_t di_uniquifier; /* unique number */
} door_info_t;
其中di_target是服务器的进程ID,di_proc是所指定的服务器过程在服务器进程中的地址(对于客户来说,这个地址信息也许没什么用)。作为第一个参数传递给该服务器过程的cookie指针由di_data返回。
该门的当前属性存放在di_attributes中,我们已在15.3节中描述过两个属性:DOOR_PRIVATE和DOOR_UNREF。两个新属性是DOOR_LOCAL(该过程局部于本进程)和DOOR_REVOKE (服务器已通过调用door_revoke函数撤销了与该门关联的过程)。
每个门刚被创建时都被赋予一个系统范围内唯一的数值,它作为di_uniquifier返回。
本函数通常由客户调用,以获取关于服务器的信息。然而它也可以由一个服务器过程调用,这时第一个参数指定为DOOR_QUERY,所返回的信息是关于调用线程的。这种情形下,服务器过程的地址(di_proc)和cookie(di_data)也许有用。
现在给出使用之前所述五个函数的一些例子。
15.7.1 door_info函数
图15-6给出的程序打开一个门,调用door_info,然后输出关于该门的信息。
图15-6 输出关于一个门的信息
我们open所指定的路径名并首先验证它是一个门。对应一个门的stat结构的st_mode成员含有一个值,该值使得S_ISDOOR宏求值为真。我们接着调用door_info。
我们首先指定一个不是门的路径名运行该程序,然后针对Solaris 2.6所用的两个门运行它。
solaris % doorinfo /etc/passwd
pathname is not a door
solaris % doorinfo /etc/.name_service_door
server PID = 308,uniquifier = 18,DOOR_UNREF solaris % doorinfo /etc/.syslog_door
server PID = 282,uniquifier = 1635
solaris % ps -f -p 308
root 308 1 0 Apr 01 ? 0:34 /usr/sbin/nscd
solaris % ps -f -p 282
root 282 1 0 Apr 01 ? 0:10 /usr/sbin/syslogd -n -z 14
我们使用ps命令来查看以由door_info返回的进程ID运行的是什么程序。
15.7.2 结果缓冲区大小
在描述door_call函数时我们提到过,如果结果缓冲区太小而容纳不了服务器的结果,门函数库就会自动分配一个新的缓冲区。我们现在给出展示这一特性的一个例子。图15-7给出了新的客户程序,它是对图15-2的简单修改。
图15-7 输出结果的地址
19~23 在这个版本的程序中,我们输出oval变量的地址、data_ptr的内容(它指向从door_call返回的结果)以及结果缓冲区的地址和大小(rbuf和rsize)。
这时运行该程序,我们还没有修改来自图15-2的结果缓冲区的大小,因此预期data_ptr和rbuf都指向oval变量,rsize则为4字节。确实,这跟我们实际看到的一致:
solaris % client2 /tmp/server2 22
&oval = effff740,data_ptr =effff740,rbuf = effff740,rsize = 4
result: 484
现在只改动图15-7中一行,把客户的结果缓冲区大小减少1字节。新版本中将图15-7的第18行改为如下:
arg.rsize = sizeof(long)- 1; /* size of data results */
执行这个新的客户程序,我们看到分配了一个新缓冲区,data_ptr就指向这个新缓冲区。
solaris % client3 /tmp/server3 33
&oval = effff740,data_ptr =ef620000,rbuf = ef620000,rsize = 4096
result: 1089
所分配缓冲区的大小4096是该系统上的页面大小,我们已在12.6节中看到过这个大小。从这个例子可以看出,我们应该总是通过data_ptr指针访问服务器的结果,而不能通过其地址在rbuf中传递给服务器的变量。这就是说在我们的例子中,我们应该以*((long*)arg.data_ptr)访问长整数结果,而不是以oval来访问(图15-2中是这么做的)。
通过调用mmap分配的这个新缓冲区可使用munmap返还给系统。客户也可以给后续的door_call调用一直使用该缓冲区。
15.7.3 door_cred函数和客户凭证
这一次我们对图15-3中的servproc函数做了修改:调用door_cred函数来获取客户的凭证。图15-8给出了这个新的服务器过程,客户和服务器main函数不变,仍然是图15-2和图15-3。
图15-8 获取并输出客户凭证的服务器过程
首先运行客户程序,正如所料,我们将看到有效用户ID等于实际用户ID。接着变换为超级用户,把客户程序可执行文件的属主改为root,并启用该文件的SUID位,然后再次运行客户程序。 [2]
solaris % client4 /tmp/server4 77 第一次运行客户程序
result: 5929
solaris % su 变为超级用户
Password:
Sun Microsystems Inc. SunOS 5.6 Generic August 1997
solaris # cd 含有客户程序可执行文件的目录
solaris # ls -l client4
-rwxrwxr-x 1 rstevens other1 139328 Apr 13 06:02 client4
solaris # chown root client4 把属主改为root
solaris # chmod u+s client4 并打开SUID位
solaris # ls -l client4 检查文件权限与属主
-rwsrwxr-x 1 root other1 139328 Apr 13 06:02 client4
solaris # exit
solaris % ls -l client4
-rwsrwxr-x 1 root other1 139328 Apr 13 06:02 client4
solaris % client4 /tmp/server4 77 然后再次运行客户程序
result: 5929
查看服务器的输出,我们看到第二次运行客户程序时,有效用户ID发生了变化。
solaris % server4 /tmp/server4
euid = 224,ruid = 224,pid = 3168
euid = 0,ruid = 224,pid =3176
其中有效用户ID为0表示超级用户。
15.7.4 服务器的自动线程管理
为了查看由服务器执行的线程管理,我们让服务器过程在一开始执行时输出自己所在线程的线程ID,然后让它睡眠5秒,以此模拟一个运行时间较长的服务器过程。这段睡眠使我们可以在已有一个客户正在接受服务期间启动多个客户。图15-9给出了新的服务器过程。
图15-9 输出线程ID后睡眠的服务器过程
其中引入了来自我们自己的函数库的一个新函数pr_thread_id。它需要一个参数(指向一个线程ID的指针或表示使用调用线程之线程ID的空指针),返回的是该线程的一个long类型的整数标识符(往往是一个小整数)。一个进程总是可以由一个整数值来标识,这个整数就是它的进程ID。即使我们不清楚进程ID是int类型还是long类型,也只需把getpid的返回值类型强制转换成long类型后输出其值(图9-2)。但是线程的标识符是一个pthread_t数据类型的值(称为线程ID),它不必是一个整数。事实上,Solaris 2.6使用小整数作为线程ID,Digital Unix则使用指针。然而我们往往希望只给线程输出一个小整数标识符(本例子就是这样),以用于调试目的。图15-10给出的pr_thread_id库函数可处理这个问题。
图15-10 pr_thread_id函数:给调用线程返回小整数标识符
如果实现没有给线程提供小整数标识符,这个函数就可能复杂得多,需要把pthread_t值映射成小整数,并记住这种映射关系(存放在一个数组或链表中),供以后的调用使用。[Lewis and Berg 1998]中的thread_name函数完成了这个工作。
返回图15-9,我们接连运行客户程序三次。由于我们在启动下一个客户之前等待shell提示符,因此知道每次在服务器端的5秒钟等待都是完整的。
solaris % client5 /tmp/server5 55
result: 3025
solaris % client5 /tmp/server5 66
result: 4356
solaris % client5 /tmp/server5 77
result: 5929
查看服务器的输出,我们看到同一个服务器线程为每个客户提供服务:
solaris % server5 /tmp/server5
thread id 4,arg = 55
thread id 4,arg = 66
thread id 4,arg = 77
现在同时启动三个客户:
solaris % client5 /tmp/server5 11 & client5 /tmp/server5 22 & \
client5 /tmp/server5 33 &
[2] 3812
[3] 3813
[4] 3814
solaris% result: 484
result: 121
result: 1089
服务器的输出表明,服务器进程创建了两个新线程来处理同一个服务器过程的第二次和第三次激活请求。
thread id 4,arg = 22
thread id 5,arg = 11
thread id 6,arg = 33
接着同时启动另外两个客户:
solaris % client5 /tmp/server5 11 & client5 /tmp/server5 22 &
[2] 3830
[3] 3831
solaris% result: 484
result: 121
我们看到服务器使用的是以前创建的线程:
thread id 6,arg = 22
hread id 5,arg = 11
从本例子可以看出,服务器进程(实际上是跟我们的服务器代码相链接的门函数库)根据需要自动创建服务器线程。如果一个应用程序希望亲自处理线程的管理,那么它可以使用我们将在15.9节中描述的函数去这么做。
我们还验证了服务器过程是一个并发(concurrent)服务器:同一个服务器过程同时可有多个实例在运行,它们作为彼此独立的线程给不同的客户提供服务。认定服务器并发的第一个办法是,当我们同时运行三个客户时,所有三个结果都在5秒后输出。要是服务器是迭代的(iterative),那么第一个结果在所有三个客户都启动后过5秒输出,下一个结果再过5秒输出,最后一个结果又过5秒后才输出。
15.7.5 服务器的自动线程管理:多个服务器过程
前一个例子的服务器进程中只有一个服务器过程。我们的下一个问题是,同一服务器进程中的多个服务器过程是否可以使用同一个线程池。为测试这一点,我们给服务器进程增加了另一个服务器过程,同时重新编写了前一个例子的代码,以表现出在不同进程间处理参数和结果的一种更好的风格。
我们的第一个文件是名为squareproc.h的头文件,它给我们的求平方函数定义了一个输入参数的数据类型和一个输出参数的数据类型。它还给该过程定义了路径名。图15-11给出了该文件。
图15-11 squareproc.h头件
我们的新服务器过程接受一个长整数输入值,返回一个double类型的值,该值是输入值的平方根。我们在sqrtproc.h头文件中定义了该过程的路径名、输入结构和输出结构,图15-12给出了该文件。
图15-12 sqrtproc.h头文件
我们的客户程序在图15-13中给出。它只是一先一后调用那两个服务器过程,然后输出结果。该程序与本章中已给出的其他客户程序类似。
图15-13 调用求平方过程和求平方根过程的客户程序
图15-14给出了我们的两个服务器过程。每个过程输出其所在线程的线程ID和输入参数,睡眠5秒钟,计算结果,然后返回。
图15-15给出的服务器程序main函数打开两个门描述符,然后给每个门描述符关联一个服务器过程。
图15-14 两个服务器过程
图15-15 服务器程序main函数
我们运行客户程序,它需花10秒钟才输出结果(正如我们的预料)。
solaris % client7 77
result: 5929 8.77496
查看服务器的输出,我们看到服务器进程中同一线程处理了该客户的先后两个请求。
solaris % server7
squareproc: thread id 4,arg = 77
sqrtproc: thread id 4,arg = 77
这个例子告诉我们,对于一个给定进程,其服务器线程池中的任意线程都能够处理针对任意服务器过程的客户请求。
15.7.6 服务器的DOOR_UNREF属性
我们在15.3节中提到过,DOOR_UNREF可作为一个新创建的门的属性之一指定给door_create函数。该函数的手册页面中说,当指代某个具备该属性的门的描述符数降为1(即该门的引用计数从2变为1)时,该门的服务器过程将有一次特殊的激活。特殊之处在于,传递给该服务器过程的第二个参数(指向数据参数的指针)是常值DOOR_UNREF_DATA。下面列出了引用该门的三种方法。
(1)服务器中由door_create返回的描述符算作一个引用。事实上,激活某个不再引用过程的触发条件是其引用计数从2变为1而不是从1变为0的原因在于,服务器进程通常在其整个存活期内一直保持该描述符打开。
(2)附接到该门上的文件系统中的路径名也算作一个引用。我们可以删除这个引用,办法有:调用fdetach函数,运行fdetach程序,或者从文件系统中删除该路径名(既可调用unlink函数,也可运行rm命令)。
(3)客户中由open返回的描述符算作一个打开的引用,直到该描述符关闭为止,这种关闭既可以显式地调用close完成,也可以隐式地由客户进程的终止完成。本章中已给出的所有客户进程都是隐式地关闭门描述符的。
我们的第一个例子展示,如果服务器在调用fattch之后关闭所创建的门描述符,那么其服务器过程的不再引用激活(unreferenced invocation)将立即发生。图15-16给出了我们的服务器过程和服务器main函数。
7~10 服务器过程认出特殊的不再引用激活后输出一个消息。当前线程通过以两个空指针和两个值为0的大小调用door_return从这个特殊调用中返回。
28 fattach返回后close所创建的门描述符。fattach之后该描述符对于服务器的唯一用途是供调用door_bind、door_info或door_revoke之用。
启动该服务器,我们注意到不再引用激活立即发生:
solaris % serverunref1 /tmp/door1
door unreferenced
追踪该门的引用计数变化情况,它在door_create返回后变为1,在fattach返回后变为2。服务器调用close使它从2变为1,于是触发了不再引用激活。该门现在所剩的唯一引用是它在文件系统中的路径名,它是客户指代该门所需的手段。这就是说,客户仍能正常工作。
solaris % clientunref1 /tmp/door1 11
result: 121
solaris % clientunref1 /tmp/door1 22
result: 484
图15-16 处理不再引用激活的服务器过程
而且,该服务器过程没有进一步的不再引用激活发生。事实上对于一个给定门,只会递交一次不再引用激活。
现在把我们的服务器程序改回平常的情形,也就是并不close所创建的门描述符。图15-17给出了服务器过程和服务器main函数。我们设置了6秒钟的睡眠,并在服务器过程返回之前输出信息。在一个窗口中启动服务器,在另一个窗口中验证服务器所创建的门的关联路径名存在于文件系统中,然后用rm命令删除该路径名:
solaris % ls -l /tmp/door2
Drw-r--r-- 1 rstevens other1 0 Apr 16 08:58 /tmp/door2
solaris % rm /tmp/door2
我们一删除该路径名,相应服务器过程的不再引用激活就发生:
solaris % serverunref2 /tmp/door2
door unreferenced 一旦从系统中删除该路径名
追踪该门的引用计数变化情况,它在door_create返回后变为1,在fattach返回后变为2。当我们rm该门的路径名时,这个命令把它的引用计数从2降为1,于是触发了不再引用激活。
在下面的有关该属性的最后一个例子中,我们仍然从文件系统中删除路径名,不过是在启动门的三次客户激活之后。我们要展示的是,每次客户激活给引用计数加1,只有所有三个客户都终止后,不再引用激活才发生。我们使用先前的在图15-17中给出的服务器程序,客户程序没有变动,如图15-2所示。
图15-17 不关闭所创建门描述符的服务器程序
solaris % clientunref2 /tmp/door2 44 & clientunref2 /tmp/door2 55 & \
clientunref2 /tmp/door2 66 &
[2] 13552
[3] 13553
[4] 13554
solaris % rm /tmp/door2 在三个客户运行期间
solaris % result: 1936
result: 3025
result: 4356
下面是服务器的输出:
solaris % serverunref2 /tmp/door2
thread id 4,arg = 44
thread id 5,arg = 55
thread id 6,arg = 66
thread id 4 returning
thread id 5 returning
thread id 6 returning
door unreferenced
追踪该门的引用计数变化情况,它在door_create返回后变为1,在fattach返回后变为2。当每个客户调用open时,引用计数逐次加1,从2变为3,从3变为4,最后从4变为5。当我们rm该门的路径名时,引用计数从5变为4。然后随着每个客户的终止,引用计数器从4变为3,从3变为2,最后从2变为1,最后这次减1触发了不再引用激活。
以上这些例子表明,尽管DOOR_UNREF属性的说明很简单(“当引用计数从2变为1时,不再引用激活发生”),但是要使用这一特性,必须首先理解引用计数。
当考虑把一个打开着的描述符从一个进程传递到另一个进程时,我们通常想到以下两点:
调用fork之后,子进程与父进程共享所有打开着的描述符;
调用exec之后,所有描述符通常仍保持打开。
前一个例子中,父进程打开一个描述符,调用fork派生出子进程,然后父进程关闭该描述符,由子进程来处理它。这样就把一个打开着的描述符从父进程传递给了子进程。
现今的Unix系统扩充了这种描述符传递概念,提供了把任何打开着的描述符从一个进程传递给任何其他进程的能力,这两个进程间有无亲缘关系皆可。门提供了从客户到服务器以及从服务器到客户的一个描述符传递API。
我们在UNPv1的14.7节 [3] 讲述过使用Unix域套接字的描述符传递。源自Berkely的内核使用这些套接字传递描述符,TCPv3第18章中提供了全部细节。SVR4内核使用另一种技术来传递描述符,即I_SENDFD和I_RECVFD这两个ioctl命令,APUE的15.5.1节讲述了它们。但是SVR4进程仍可以使用Unix域套接字来访问这一内核特性。
注意理解传递描述符的含义。图4-7中,服务器打开文件后把整个文件内容复制到底部的管道中。如果该文件的大小为1MB,那么通过底部的管道从服务器往客户流动了1MB的数据。然而如果是服务器往客户传递回一个描述符的话,通过图4-7中底部的管道传递的仅仅是这个描述符(我们假设它是某些特定于内核的小量信息),而不是文件本身。客户取得该描述符后读出文件内容,并把它写往标准输出。所有的文件读出操作都发生在客户中,服务器只是打开该文件。
注意,服务器不能只是通过图4-7中底部的管道写出描述符号,就像以下代码那样:
int fd;
f..Open....);
Write(pipefd,&fd,sizeof(int));
这种方法并不奏效。描述符号是特定于进程的属性。假设fd的值在服务器中为4。即使该描述符在客户中是打开的,也几乎可以肯定它指代的文件不同于服务器进程中描述符4所指代的文件(从一个进程到另一个进程描述符号意义不变的唯一时刻是穿越fork前后和穿越exec前后)。如果服务器中的最低未用描述符为4,那么服务器中一个成功的open将返回4。如果服务器把描述符4“传递”给客户,而客户中的最低未用描述符为7,那么我们希望客户中的描述符7与服务器中的描述符4指代同一个文件。APUE的图15-4和TCPv3的图18-4展示了从内核角度看来必然发生的情况:这两个描述符(这儿的例子是服务器中的4和客户中的7)必须同时指向内核中的同一文件表项。描述符传递涉及一定的内核神秘技巧,然而像门和Unix域套接字这样的API隐藏了这些内部细节,从而允许进程们很容易从一个进程向另一个进程传递描述符。
通过一个门从客户向服务器传递描述符的手段是,把door_arg_t结构的desc_ptr成员设置成指向一个door_desc_t结构的数组,door_num成员则设置成这些door_desc_t结构的数目。从服务器向客户传递回描述符的手段是,把door_return的第三个参数设置成指向一个door_desc_t结构的数组,把该函数的第四个参数设置成待传递描述符的数目。
typedef struct door_desc {
door_attr_t d_attributes; /* tag for union */
union {
struct { /* valid if tag = DOOR_DESCRIPTOR */
int d_descriptor; /* descriptor number */
door_id_t d_id; /* unique id */
} d_desc;
} d_data;
} door_desc_t;
该结构含有一个联合,其第一个成员是一个标识该联合中含有哪个成员的标记。不过该联合当前只定义了一个成员(描述一个描述符的d_desc结构),标记(d_attributes)于是必须设置为DOOR_DESCRIPTOR。
例子
我们修改以前的文件服务器例子(回想图1-9),使服务器打开文件并把打开着的描述符传递给客户,然后由客户把文件的内容复制到标准输出。图15-18展示了这样的编排。
图15-18 服务器传递回打开着描述符的文件服务器例子
图15-19给出了客户程序。
图15-19 描述符传递文件服务器例子的客户程序
打开门,从标准输入读入路径名
9~15 与待打开的门关联的路径名是一个命令行参数。打开该门后,从标准输入读入客户希望打开的文件名,并删掉结尾的换行符。
设置参数和指向结果的指针
16~22 设置door_arg_t结构。我们给路径名的大小多加了1,以允许服务器用空字符终止该路径名。
调用服务器过程并检查结果
23~31 调用服务器过程,然后检查其结果是我们预期的:没有数据,有一个描述符。我们不久将看到,服务器只在打不开客户指定的文件时才返回数据(含有一个出错消息),这种情况下,我们调用err_quit输出这个错误。
获取描述符,把文件复制到标准输出
32~34 从door_desc_t结构取得描述符,然后把相应的文件复制到标准输出。
图15-20给出了服务器过程。服务器main函数没有变化,如图15-3所示。
图15-20 打开一个文件并传递回其描述符的服务器过程
为客户打开文件
9~14 用空字符终止由客户提供的路径名,然后尝试open该文件。如果发生错误,数据结果就是含有相应出错消息的字符串。
成功
15~20 如果open成功,那就返回所得到的描述符,这时没有数据结果。
我们启动服务器,指定其待创建的门的路径名为/tmp/fd1,然后运行客户程序:
solaris % clientfd1 /tmp/fd1
/etc/shadow
/etc/shadow: can't open,Permission denied
solaris % clientfd1 /tmp/fd1
/no/such/file
/no/such/file: can't open,No such file or directory
solaris % clientfd1 /tmp/df1
/etc/ntp.conf 一个只有2行文本的文件
multicastclient 224.0.1.1
driftfile /etc/ntp.drift
前面两次我们指定了导致出错返回的路径名,最后一次服务器返回了一个只含2行文本的文件的描述符。
通过门来传递描述符存在一个问题。在我们的例子中要看到这个问题,只需在服务器过程中成功的open调用之后加一个printf语句。你将看到每次打开的描述符值比上一个描述符值大1。问题在于服务器把这些描述符传递给客户后没有关闭它们。然而没有简单的办法做到这一点。从逻辑上讲,执行close的合适位置是在door_return返回之后,因为那时所打开的描述符已发送给客户,然而door_return并不返回!要是我们通过一个Unix域套接字使用sendmsg来传递该描述符,或者通过一个SVR4管道使用ioctl来传递它,那么可以在sendmsg或ioctl返回后close它。然而门的描述符传递规范不同于这两种技术,因为从传递描述符的函数上不会有返回发生。绕过这个问题的唯一办法是,让服务器过程以某种方式记着它已打开了一个描述符,并在以后某个时候关闭它,这么一来程序会变得非常杂乱。
这个问题到Solaris 2.7中应得到纠正,办法是增加一个新的DOOR_RELEASE属性。发送者把d_attributes设置成DOOR_DESCRIPTOR | DOOR_RELEASE,这样告诉系统在把相应的描述符传递给接收者之后要将其关闭。
我们随图15-9展示出,当客户请求到达时,门函数库会按照处理它们的需要自动地创建新线程。这些线程由该函数库作为脱离的线程创建,具有默认的线程栈大小,禁止了线程取消功能,并具有从调用door_create的线程初始继承来的信号掩码和调度类。如果我们想要改变上述任何特性,或者希望亲自管理服务器线程池,那么可以调用door_server_create以指定我们自己的服务器创建过程(sever creation procedure)。
#include <door.h>
typedef void Door_create_proc(door_info_t *);
Door_create_proc *door_server_create(Door_create_proc *proc);
返回:指向先前的服务器创建过程的指针
跟15.3节中door_create的声明一样,我们使用C的typedef语句来简化库函数的函数原型。我们的新数据类型把服务器创建过程定义为接受单个参数(指向一个door_info_t结构的指针),不返回任何值(void)。当我们调用door_server_create时,其参数是指向我们的服务器创建过程的指针,返回值是指向上一个服务器创建过程的指针。
每当需要一个新线程来给某个客户请求提供服务时,我们的服务器创建过程就被调用。至于哪个服务器过程需要这个新线程的信息则存放在其地址作为参数传递进本创建过程的door_info_t结构中。该结构的di_proc成员含有这个服务器过程的地址,di_data成员则含有该服务器过程每次被调用时所传递进去的cookie指针。
查看所发生的活动的最简单办法是使用例子。我们的客户程序没有变化,如图15-2所示。我们的服务器程序中除原来的服务器过程函数和main函数外,增加了两个新函数。图15-21给出服务器进程中四个函数的关系概貌,当时一些函数已注册而且所有函数都已调用。
图15-22给出了服务器程序main函数。
与图15-3相比,有四个变动:(1)去掉了门描述符fd的声明(它现在是一个我们将在图15-23中给出并描述的全局变量);(2)以一个互斥锁保护door_create调用(也在图15-23中说明);(3)在创建门之前调用door_server_create,将其参数指定为我们的服务器创建过程(my_create,它将在下面给出);(4)door_create调用中,最后一个参数(属性)现在是DOOR_PRIVATE而不是0。DOOR_PRIVATE告诉门函数库本门将有它自己的称为私用服务器池(private server poo1)的线程池。
使用DOOR_PRIVATE指定一个私用服务器池和使用door_server_create指定一个服务器创建过程是互相独立的。总共有以下四种可能情形。
图15-21 我们的服务器进程中四个函数的概貌
图15-22 线程池管理例子程序的main函数
(1)默认情形:没有私用服务器池,也没有服务器创建过程。系统根据需要创建线程,它们都进入进程范围的线程池。
(2)指定DOOR_PRIVATER,不过没有服务器创建过程。系统根据需要创建线程,对于创建时没有指定DOOR_PRIVATE属性的门,新创建的线程将进入进程范围的池,对于创建时指定了DOOR_PRIVATE属性的门,新创建的线程将进入该门的私用服务器池。
(3)没有私用服务器,但是指定了一个服务器创建过程。每当需要一个新线程时,该服务器创建过程就被调用,所创建的线程都进入进程范围的线程池。
(4)指定DOOR_PRIVATE,同时指定了一个服务器创建过程。每当需要一个新线程时,该服务器创建过程就被调用。一个线程创建出来后,必须调用door_bind把自己赋给合适的私用服务器池,否则它将被赋给进程范围的线程池。
图15-23给出了我们的两个新函数:my_create和my_thread。my_create是我们的服务器创建过程,它把my_thread指定为它所创建的每个线程都要执行的函数。
图15-23 我们自己的线程管理函数
服务器创建过程
30~41 每当my_create被调用时,我们就创建一个新线程。但是在调用pthread_create前,我们先初始化该线程的属性,设置其竞用范围(contention scope)为PTHREAD_SCOPE_SYSTEM,将它指定为脱离的线程。接着调用pthread_create创建该线程,它启动执行的是my_thread函数。服务器创建过程以及新线程启动函数的参数都是指向待激活之门的door_info_t结构的指针。如果有一个带多个门的服务器,而且指定了一个服务器创建过程,该服务器创建过程就在其中任何一个门需要一个新线程时被调用。该服务器创建过程以及由它指定给pthread_create的线程启动函数区分这些不同的服务器过程的唯一办法是:查看该door_info_t结构中的di_proc指针。
把竞用范围设置成PTHREAD_SCOPE_SYSTEM意味着本线程将跟其他进程中的线程竞争处理器资源的使用。与之相对的PTHREAD_SCOPE_PROCESS意味着本线程只跟本进程内的其他线程竞争处理器资源的使用。后者对于门不起作用,因为门函数库要求执行door_return的内核轻权进程跟引发这个激活请求的轻权进程相同。未绑定的线程(PTHREAD_SCOPE_PROCESS)可能在执行服务器过程期间改变轻权进程。 [4]
要求作为脱离的线程来创建新线程是为了防止系统在该线程终止时保存有关它的任何信息,因为不会有其他线程对它调用pthread_join。
线程启动函数
15~20 my_thread是通过调用pthread_create指定的线程启动函数。传递给它的参数是早先传递进my_create函数的指向待激活之门的door_info_t结构的指针。本进程中唯一存在的服务器过程是servproc,因此我们只是验证该参数引用了这个过程。
等待描述符变为有效
21~22 服务器创建过程在door_create首次被调用时调用,目的是为了创建一个初始的服务器线程。门函数库中该调用是在door_create返回前发出的。然而变量fd要到door_create返回后才含有有效的门描述符。(这是一个鸡与蛋的问题。)既然知道my_thread是作为独立于调用door_create的主线程的另一个线程运行的,我们解决这个定时问题的办法于是就是按下述方式使用互斥锁fdlock:主线程在调用door_create前给该互斥锁上锁,在door_create返回并往fd中存入一个值(图15-22)后给该互斥锁解锁。我们的my_thread函数先是给该互斥锁上锁(也许要阻塞到主线程解开该互斥锁为止),然后给它解锁。我们也许可以增设一个由主线程向它发送信号的条件变量,不过这儿没有这个必要,因为我们知道将发生的调用的顺序。
禁止线程取消功能
23 当使用pthread_create创建一个新的Posix线程时,线程取消功能默认是启用的。这种情况下,当某个客户中止一个进展中的door_call调用时(我们将在图15-31中展示这个操作),线程取消处理程序(如果有的话)将被调用,相应的服务器过程所在线程随后终止。在取消功能禁止的情况下(如这儿将做的那样),当某个客户中止一个进展中的door_call调用时,相应的服务器过程仍然完成(其所在的线程未被终止),不过来自door_return的结果被简单地丢弃。既然取消功能启用时服务器线程有可能被终止,而且服务器过程当时可能处于为其客户执行的某个操作中(它可能持有某些锁或信号量),因此门函数库(默认)禁止了由它创建的所有线程的线程取消功能。如果一个服务器过程希望在某个客户过早终止时被取消,那么它所在的线程必须启用取消功能,并准备好处理这种事件。
注意,PTHREAD_SCOPE_SYSTEM竞用范围和脱离状态时在创建线程时作为属性指定的。然而取消模式只能由当事线程本身在开始运行后设置。事实上,即使我们禁止了取消功能,线程仍能在任何时候随心所欲地启用和禁止它。
把本线程捆绑到一个门
24 调用door_bind把调用线程捆绑到与某个门关联的私用服务器池,其中该门的描述符为该函数的参数。既然该调用需要这个门描述符,于是我们让fd成为这个版本的服务器程序的一个全局变量。
使得本线程对于客户调用可用
25 通过以两个空指针和两个0长度为参数调用door_return,本线程使其自身对于外来的门激活请求可用。
图15-24给出了服务器过程。它与图15-9中的版本相同。
图15-24 服务器过程
为展示所发生的情况,我们首先启动服务器:
solaris % server6 /tmp/door6
my_create: created server thread 4
服务器启动后一调用door_create,我们的服务器创建过程就被首次调用,尽管当时我们还没有启动客户。这就创建了第一个线程,它将等待第一个客户调用请求。我们然后接连运行客户程序三次。
solaris % client6 /tmp/door6 11
result: 121
solaris % client6 /tmp/door6 22
result: 484
solaris % client6 /tmp/door6 33
result: 1089
查看服务器的相应输出,我们看到第一个调用发生时创建了另一个线程(其线程ID为5),然后ID为4的线程给所有三个客户请求提供了服务。门函数库看来总是保留一个额外的线程备用。
my_create: created server thread 5
thread id 4,arg = 11
thread id 4,arg = 22
thread id 4,arg = 33
接着在后台几乎同时执行客户程序三次:
solaris % client6 /tmp/door6 44 & client6 /tmp/door6 55 & \
client6 /tmp/door6 66 &
[2] 4919
[3] 4920
[4] 4921
solaris % result: 1936
result: 4356
result: 3025
查看服务器的相应输出,我们看到创建了两个新线程(线程ID分别为6和7),给三个客户请求提供服务的分别是线程4、5和6。
thread id 4,arg = 44
my_create: created server thread 6
thread id 5,arg = 66
my_create: created server thread 7
thread id 6,arg = 55
加上以下三个额外函数后,门API就完整了。
#include <door.h>
int door_bind(int fd);
int door_unbind(void);
int door_revoke(int fd);
均返回:若成功则为0,若出错则为−1
我们在图15-23中引入了door_bind函数。它把调用线程捆绑到与描述符为fd的门关联的私用服务器池中。如果调用线程已绑定在另外某个门上,那就执行一个隐式的松绑操作。
door_unbind显式地把调用线程从其已绑定的门上松绑。
door_revoke撤销对于由fd标识的门的访问。一个门描述符只能由创建它的进程撤销。调用该函数时已在进展中的任何门激活实例仍允许正常地完成。
到此为止的所有例子都假设客户和服务器上都没有异常之事发生。我们现在考虑客户和服务器任何一方出错时发生什么情况。我们知道,当客户和服务器处于同一个进程中时(图15-1中的本地过程调用),客户不必担心服务器的崩溃,反之亦然,因为如果任何一方崩溃,那么整个进程崩溃。然而当客户和服务器分散到两个进程上时,我们必须考虑任何一方崩溃时会发生什么情况,以及如何通知对方这种失败。客户和服务器无论是在同一台主机上还是在不同的主机上,我们都得考虑这些问题。
15.11.1 服务器的过早终止
客户阻塞在door_call调用中等待结果期间,必须知道服务器线程是否因某种原因而终止了。为了观察所发生的情况,我们让服务器过程调用pthread_exit以终止所在的线程。这样仅仅终止该线程本身,而不是终止整个服务器进程。图15-25给出了服务器过程。
图15-25 被激活后终止其自身的服务器过程
服务器程序的其余部分没有变化,如图15-3所示,客户程序没有变化,如图15-2所示。
运行客户程序,我们看到如果服务器过程在返回前终止了,那么客户的door_call将返回一个EINTR错误。
solaris % clientintr1 /tmp/door1 11
door_call error: Interrupted system call
15.11.2 door_call系统调用的不可中断性
door_call的手册页面警告说,该函数不是一个可重新启动的系统调用。(门函数库中door_call函数激活一个同名的系统调用。)通过把服务器程序修改成其中的服务器过程在返回前睡眠6秒钟,我们就可以看到这种特性。图15-26给出了这个服务器过程。
图15-26 睡眠6秒钟的服务器过程
我们然后对图15-2给出的客户程序进行修改,即建立一个SIGCHLD信号处理程序,fork一个子进程,让子进程睡眠2秒后终止。这么一来,客户父进程调用door_call后约2秒时,该父进程捕获SIGCHLD信号,接着其信号处理程序返回,从而中断door_call系统调用。图15-27给出了这个客户程序。
图15-27 2秒钟后捕获SIGCHLD信号的客户程序
客户看到的错误就像服务器过程过早地终止一样,都是EINTR。
solaris % clientintr2 /tmp/door2 22
door_call error: Interrupted system call
这意味着我们必须阻塞调用door_call期间可能产生的任何信号,防止它们被递交给进程,因为这些信号会中断door_call。
15.11.3 等势过程与非等势过程
要是我们知道刚刚捕获了一个信号,当检测到由door_call返回的EINTR错误后接着再次调用同一个服务器过程,情况会怎么样呢?这么做时我们知道错误来自被捕获的信号,而不是来自服务器过程的过早终止。然而这么做会导致我们将马上看到的问题。
首先把服务器过程修改为:(1)当被调用时输出当前线程ID;(2)睡眠6秒;(3)在返回之前输出当前线程ID。图15-28给出了这个版本的服务器过程。
图15-28 当被调用时和准备返回时输出当前线程ID的服务器过程
图15-29给出了我们的客户程序。
2~8 声明全局变量caught_sigchld,它在SIGCHLD信号被捕获时由其信号处理程序设置为1。
31~42 只要返回的错误是EINTR,而且这是由我们的信号处理程序导致的,我们就在一个循环中调用door_call。
如果只看客户的输出,那么似乎没有问题:
solaris % clientintr3 /tmp/door3 33
calling door_call
calling door_call
result: 1089
第一次调用door_call后约2秒时,我们的信号处理程序激活,把caught_sigchld设置为1,该信号处理程序的返回导致第一次door_call调用返回EINTR错误,我们于是再次调用door_call。第二次调用时服务器过程运行到完毕,从而返回预期的结果。
但是查看服务器的输出,我们发现服务器过程调用了两次:
solaris % serverintr3 /tmp/door3
thread id 4 called
thread id 4 returning
thread id 5 called
thread id 5 returning
客户的第一次door_call调用被所捕获的信号中断后,它的第二次door_call调用启动了再次调用服务器过程的另一个线程。如果该服务器过程是等势的(idempotent),那是没有问题。但是如果该服务器过程是非等势的,那就有问题了。
等势(idempotent)一词在用于描述一个过程时,意思是该过程可调用任意多次而不出问题。我们那个计算平方值的服务器过程是等势的:不论调用一次还是两次,我们都得到正确的结果。另外一个等势过程的例子是返回当前时间和日期的过程。尽管该过程每次可能返回不同的信息(譬如说它被调用了两次,彼此相差1秒,于是导致返回时间也相差1秒),不过仍然是正确的。非等势过程的经典例子是从某个银行账户减去一笔费用的过程:除非该过程只调用了一次,否则最终结果是错误的。
图15-29 接收到EINTR错误后再次调用door_call的客户程序
15.11.4 客户的过早终止
现在看一看客户在调用door_call之后但在服务器返回之前终止时,服务器过程是如何得到通知的。图15-30给出了我们的客户程序。
图15-30 调用door_call后过早终止的客户程序
20 与图15-2相比唯一的变化是,在调用door_call之紧前调用alarm(3)。该函数调度了一个3秒后发出的SIGALAM信号,但是由于我们没有捕获这个信号,因此它的默认行为是终止客户进程。这将导致客户在door_call返回前终止,因为我们将在服务器过程中放置一个6秒钟的睡眠。
图15-31给出了我们的服务器过程以及它的线程取消处理程序。
回想8.5节中就线程取消功能的讨论以及随图15-23进行的相关讨论。当系统检测到客户在其door_call调用仍然进展期间即将终止时,就向处理该调用的服务器线程发送一个取消请求。
如果该服务器线程禁止了取消功能,那就什么事情都不发生,该线程继续执行到完毕(调用door_return之时),结果则被丢弃。
如果该服务器线程启用了取消功能,那就调用所设置的任何清理处理程序,该线程随后终止。
在图15-31给出的服务器过程中,我们首先调用pthread_setcancelstate以启用线程取消功能,因为门函数库创建新线程时禁止该功能。该函数还在变量oldstate中保存当前的取消状态,以便在本函数尾恢复状态。我们然后调用pthread_cleanup_push以把函数servproc_cleanup注册为取消处理程序。该函数只是输出本线程已被取消的消息,不过这儿正是其服务器过程在客户过早终止后做必要的清理工作(譬如说释放互斥锁、写一个日志文件记录,等等)的地方。当清理处理程序返回时,本线程即终止。
我们还在服务器过程中放置了一个6秒钟的睡眠,以允许客户在其door_call调用仍在进展期间中止。
图15-31 检测客户过早终止的服务器过程
运行客户程序两次,我们看到当它们的进程被SIGALRM信号所杀灭时,shell输出了“Alarm Clock(报警时钟)”消息。
solaris % clientintr4 /tmp/door4 44
Alarm Clock
solaris % clientintr4 /tmp/door4 44
Alarm Clock
查看相应的服务器输出,我们看到每次有客户过早终止时,服务器线程确实被取消,清理处理程序也被调用。
solaris % serverintr4 /tmp/door4
servproc canceled,thread id 4
servproc canceled,thread id 5
我们运行客户程序两次是为了展示,当ID为4的线程被取消后,门函数库创建了一个新线程来处理客户的第二个服务器过程激活请求。
门提供了调用同一台主机上另一个进程中某个过程的能力。下一章中我们将对这种远程过程调用概念加以扩展,讲述如何调用另一台主机上另一个进程中的某个过程。
基本的API函数比较简单。服务器调用door_create创建一个门,并给它关联一个服务器过程,然后调用fattach给该门附接一个文件系统中的路径名。客户对该路径名调用open,然后调用door_call以调用服务器进程中的服务器过程。该服务器过程通过调用door_return返回。
通常情况下,对一个门所执行的唯一权限测试是由open函数在打开该门时进行的,这种测试基于客户的用户ID和组ID以及该门的路径名的权限位和属主/属组ID。门具有本书介绍的其他IPC形式所不具备的一个精妙特性,即服务器具有确定客户的凭证的能力,这些凭证包括客户的有效用户ID和实际用户ID以及有效组ID和实际组ID。服务器可使用这些信息来确定自己是否想给相应客户的请求提供服务。
门允许从客户向服务器以及从服务器向客户传递描述符。这是一个非常有用的技巧,因为Unix中描述符代表着许多访问手段:访问文件以进行文件或设备I/O,访问套接字或XTI以进行网络通信(UNPv1),访问门以进行远程过程调用。
调用另一个进程中的过程时,我们必须考虑对端的过早终止,这是本地过程调用所不必担心的问题。如果门服务器线程过早终止,其客户通过由door_call返回一个EINTR错误得以通知。如果门客户在其door_call调用仍在进展期间终止,其服务器线程就通过接收一个线程取消请求得到通知。该服务器线程必须确定是否处理这个取消请求。
15.1 由door_call作为参数从客户传递给服务器的信息有多少字节?
15.2 在图15-6中,有必要首先调用fstat以验证所打开的描述符是一个门吗?去掉这个调用,看一看发生了什么。
15.3 Solaris 2.6中sleep(3C)的手册页面这么陈述:“The current process is suspended from exection.(当前进程从执行状态挂起。)”在图15-9中,既然这句话意味着一旦有一个线程调用sleep,整个服务器进程即阻塞,那么为什么在第一个线程(ID为4)开始运行后,门函数库仍能创建第二个和第三个线程(ID为5和6)呢?
15.4 在15.3节中我们说过,对于使用door_create创建的描述符,它们的FD_CLOEXEC位是自动设置的。然而我们可以在door_create返回后,调用fcntl把该位关掉。如果我们这么做后调用exec,再从某个客户中激活服务器过程,将发生什么?
15.5 在图15-28和图15-29中,将客户程序和服务器程序各自的两个printf调用改为输出当前时间。运行这两个程序。为什么第一次激活服务器过程在2秒后返回?
15.6 把图15-22和图15-23中保护fd的互斥锁去掉,验证程序不再正确工作。你看到了什么错误?
15.7 如果我们想要改变的唯一的服务器线程特性是启用取消,那么需要建立一个服务器创建过程吗?
15.8 验证door_revoke允许已在进行的客户调用继续完成,并确定服务器过程被取消后door_call的执行情况。
15.9 在上一道习题的解答以及图15-22中我们说过,当服务器过程或服务器创建过程需使用门描述符时,它必须是一个全局变量。这种说法并不正确。重新编写上一道习题解答的程序,让fd作为main函数中的一个自动变量。
15.10 在图15-23中,我们每次创建一个线程都得调用pthread_attr_init和pthread_attr_destroy。这样做是最佳的吗?
构筑一个应用程序时,我们首先在以下两者之间作出选择:
(1)构筑一个庞大的单一程序,完成全部工作;
(2)把整个应用程序散布到彼此通信的多个进程中。
如果我们选择后者,接下去的择决是:
2a)假设所有进程运行在同一台主机上(允许IPC用于这些进程间的通信);
2b)假设某些进程会运行在其他主机上(要求使用进程间某种形式的网络通信)。
在图15-1中,顶部的情形是选择1,中部的情形是选择2a,底部的情形是选择2b。本书的大部分关注的是2a这种情况,也就是使用消息传递、共享内存区,并可能使用某种形式的同步来进行同一台主机上的进程间IPC。同一进程内不同线程间的IPC以及不同进程内各个线程间的IPC只是这种情形的特殊情况。
不同部分之间需要网络通信的应用程序大多数是使用显式网络编程(explicit network programming)方式编写的,也就是如UNPvl中讲述的那样直接调用套接字API或XTI API。使用套接字API时,客户调用socket、connect、read和write,服务器则调用socket、bind、listen、accept、read和write。我们熟悉的大多数应用程序(Web浏览器、Web服务器、Telnet客户、Telnet服务器等程序)就是以这种方式编写的。
编写分布式应用程序的另一种方式是使用隐式网络编程(implicit network programming)。远程过程调用(RPC)提供了这样的一个工具。我们使用早已熟悉的过程调用来编写应用程序,但是调用进程(客户)和含有被调用过程的进程(服务器)可在不同的主机上执行。客户和服务器运行在不同的主机上而且过程调用中涉及网络I/O,这样的事实对于程序员基本上是透明的。事实上衡量一个RPC软件包的测度之一就是它能使作为底层支撑的网络I/O对程序员的透明度有多大。
16.1.1 例子
作为RPC的一个例子,我们把图15-2和图15-3重新编写成改用Sun RPC代替门。客户以一个长整数调用服务器的过程,返回值则是该值的平方。图16-1给出了我们的第一个文件square.x。
其名字以.x结尾的文件称为RPC说明书文件(RPC specification file),它们定义了服务器过程以及这些过程的参数和结果。
定义参数和返回值
1~6 定义两个结构,一个用于参数(其成员为单个long变量),另一个用于结果(其成员为单个long变量)。
图16-1 RPC说明书文件
定义程序、版本和过程
7~11 定义一个名为SQUARE_PROG的RPC程序,它由一个版本(SQUARE_VERS)构成,该版本中又定义了单个名为SQUAREPROC的过程。该过程的参数是一个square_in结构,其返回值则是一个square_out结构。我们还给该过程赋了一个值为1的过程号,给版本赋的值为1,给程序号赋的是一个32位十六进制值(我们将在图16-9中详细讨论程序号)。
我们使用一个随Sun RPC软件包提供的程序来编译这个说明书文件,该程序就是rpc_gen。
我们编写的下一个程序是调用我们的远程过程的客户程序main函数。图16-2给出了该函数。
图16-2 调用远程过程的客户程序main函数
包括进由rpcgen生成的头文件
2 #include由rpcgen产生的square.h头文件。
声明客户句柄
6 我们声明一个名为cl的客户句柄(client handle)。客户句柄意图看起来像标准I/O的FILE指针(因而有全为大写的名字CLIENT)。
获取客户句柄
11 我们调用clnt_create,它运行成功时返回一个客户句柄。
#include <rpc/rpc.h>
CLIENT *clnt_create(const char *host,unsigned long program,
unsigned long versnum,const char *protocol);
返回:若成功则为非空客户句柄,若出错则为NULL
与标准I/O的FILE指针一样,我们并不关心客户句柄指向什么内容。它可能是由RPC运行时系统维护的某个信息结构。clnt_create分配一个这样的结构,并返回指向它的指针,以后每次调用一个远程过程时,我们就把该指针传递给RPC运行时系统。
clnt_create的第一个参数既可以是运行我们的服务器的主机的主机名,也可以是它的IP地址。第二个参数是程序名,第三个参数是版本号,这两者都来自我们的square.x文件(图16-1)。最后一个参数是我们的协议选择,通常指定为TCP或UDP。
调用远程过程并输出结果
12~15 调用我们的远程过程,其中第一个参数是指向输入结构的指针(&in),第二个参数是所获取的客户句柄。(在大多数标准I/O调用中,FILE句柄是最后一个参数。类似地, RPC函数的最后一个参数通常为CLIENT句柄。)返回值是指向结果结构的指针。注意,我们给输入结构分配了空间,但是结果结构是由RPC运行时系统分配的。
在square.x说明书文件中,我们称远程过程为SQUAREPROC,但是在客户程序中,我们称它为squareproc_1。这儿的约定是:.x文件中的名字转换成小写字母形式,添上一个底划线后跟以版本号。
在服务器方,我们只编写服务器过程,如图16-3所示。服务器程序的main函数由rpc_gen程序自动生成。
图16-3 使用Sun RPC调用的服务器过程
过程参数
3~4 我们首先注意到服务器过程的名字在版本号后添加了_svc。这样允许square.h头文件中有两个ANSI C函数原型,一个是图16-2中由客户调用的函数(它的一个参数是客户句柄),另一个是实际的服务器函数(它使用的参数与由客户调用的函数不一样)。
当我们的服务器过程被调用时,传递给它的第一个参数是指向输入结构的指针,第二个参数是指向由RPC运行时系统传递的一个结构的指针,该结构含有关于这次激活的信息(我们这个简单的过程忽略了这些信息)。
执行并返回
6~8 取出输入参数并计算其平方值。该结果存放在一个结构中,而该结构的地址则作为本函数的返回值。由于我们是在从函数中返回一个变量的地址,因为该变量不能是一个自动变量。我们把它声明为static变量。
机敏的读者将注意到,这样做妨碍服务器函数成为线程安全函数。我们将在16.2节中讨论这一点,并给出一个线程安全的版本。
现在在Solaris下编译我们的客户程序,在BSD/OS下编译我们的服务器程序,启动服务器,然后运行客户程序:
solaris % client bsdi 11
result: 121
solaris % client 209.75.135.35 22
result: 484
第一次运行时我们指定服务器主机的主机名,第二次运行时指定它的IP地址。这表明客户程序调用的clnt_create函数以及RPC运行时函数都是既允许使用主机名,也允许使用IP地址。
接着展示服务器主机不存在或者尽管存在但没有运行我们的服务器程序时,由clnt_create返回的一些错误。
solaris % client nosuchhost 11
nosuchhost: RPC: Unknown host 出自RPC运行时系统
clnt_create error 出自我们的包裹函数
solaris % client localhost 11
localhost: RPC: Program not registered
clnt_create error
我们已编写了一个客户程序和一个服务器程序,并展示了其中没有使用任何显式的网络编程方式。我们的客户程序只是调用两个函数(clnt_create和squareproc_1),而在服务器方,我们只是编写squareproc_1_svc函数。涉及Solaris下的XTI、BSD/OS下的套接字以及网络I/O的所有细节都由RPC运行时系统来处理。这就是RPC的目的:不需要显式的网络编程知识就允许编写分布式应用程序。
本例子的另一个重要之处在于,所用的两个系统(运行Solaris的Sparc系统和运行BSD/OS的Intel x86系统)具有不同的字节序(byte order)。其中Sparc系统是大端(big endian)字节序, Intel系统是小端(little endian)字节序(我们在UNPv1的3.4节中展示了这两种字节序)。这些字节排序上的差异也是由运行时函数库自动处理的,其中使用了一个称为XDR(external data representation,外部数据表示)的标准,我们将在16.8节中讨论。
本例子中客户程序和服务器程序的构建所涉及的步骤比本书中其他程序的构建都要多。下面是构建客户程序可执行文件所涉及的步骤:
solaris % rpcgen -C square.x
solaris % cc -c client.c -o client.o
solaris % cc -c square_clnt.c -o square_clnt.o
solaris % cc -c square_xdr.c -o square_xdr.o
solaris % cc -o client client.o square_clnt.o square_xdr.o libunpipc.a -lnsl
其中rpcgen的-C选项告诉它在square.h头文件中生成ANSI C原型。rpcgen还产生一个称为客户程序存根(client stub)的源文件square_clnt.c和一个名为square_xdr.c的用来处理XDR数据转换的文件。libunpipc.a是我们的函数库(存放本书中所用的函数),-lnsl选项则指定Solaris下存放网络支撑函数(包括RPC和XDR运行时系统)的系统函数库。
构建服务器程序时我们会看到类似的命令,不过rpcgen不必再运行。文件square_svc.c中含有服务器程序main函数,另外,构建客户程序时生成的含有XDR函数的square_xdr.o文件在服务器程序的构建中也需要。
solaris % cc -c server.c -o server.o
solaris % cc -c square_svc.c -o square_svc.o
solaris % cc -o server server.o square_svc.o square_xdr.o libunpipc.a -lnsl
这样会生成都运行在Solaris下的客户程序和服务器程序。
当客户程序和服务器程序是为不同的系统构建时(例如在我们早先的例子中,客户程序运行在Solaris下,服务器程序运行在BSD/OS下),可能需要额外的步骤。举例来说,某些文件必须共享(例如通过NFS)或者在两个系统之间复制,另外,客户程序和服务器程序都使用的文件(square_xdr.o)必须在每个系统上分别编译。
图16-4汇总了构建我们的客户-服务器例子程序所需的文件和步骤。其中三个带阴影的方框是我们必须编写的文件。短划线指出了需要C伪指令#include square.h的那些文件。
图16-4 构建一个RPC客户-服务器程序所需的步骤汇总
图16-5汇总了一次远程过程调用中通常发生的步骤。编了号的步骤是按顺序执行的。
(0)服务器启动,它向所在主机上的端口映射器(port mapper)注册自身。然后客户启动,它调用clnt_create,该函数则与服务器主机上的端口映射器联系,以找到服务器的临时端口。clnt_create函数还建立一个与服务器的TCP连接(因为我们在图16-2中指定的协议为TCP)。我们在本图中没有展示这些步骤,留待16.3节中详细地讲述。
(1)客户调用一个称为客户程序存根(client stub)的本地过程。在图16-2中,该过程名为squareproc_1,而含有这个客户程序存根的文件是由rpcgen产生的,名为square_clnt.c。对于客户来说,客户程序存根看起来像是它想要调用的真正的服务器过程。存根的目的在于把有待传递给远程过程的参数打成包,可能的话把它们转换成某种标准格式,然后构造一个或多个网络消息。把客户提供的参数打包成一个网络消息的过程称为集结(marshaling)。客户程序的各个例程和存根通常调用RPC运行时函数库中的函数(例如我们早先的例子中的clnt_create)。在Solaris下链接时,这些运行时库函数是从_lnsl函数库中加载的,而BSD/OS下它们是在标准C函数库中。
图16-5 一次远程过程调用中涉及的步骤
(2)这些网络消息由客户程序存根发送给远程系统。这通常需要一次陷入本地内核的系统调用(例如write或sendto)。
(3)这些网络消息传送到远程系统。这一步所用的典型网络协议为TCP或UDP。
(4)一个服务器程序存根(sever stub)过程一直在远程系统上等待客户的请求。它从这些网络消息中解散(unmarshaling)出参数。
(5)服务器程序存根执行一个本地过程调用以激活真正的服务器函数(图16-3中我们的squareproc_1_svc过程),传递给该函数的参数是它从来自客户的网络消息中解散出来的。
(6)当服务器过程完成时,它向服务器程序存根返回其返回值。
(7)服务器程序存根在必要时对返回值进行转换,然后把它们集结到一个或多个网络消息中,以便发送回客户。
(8)这些消息通过网络传送回客户。
(9)客户程序存根从本地内核中读出这些网络消息(例如read或recvfrom)。
(10)对返回值进行可能的转换后,客户程序存根最终返回客户函数。这一步看起来像是一个普通的过程返回客户。
16.1.2 历史
关于RPC的最早期论文之一也许是[White 1975]。按照[Corbin 1991]的陈述,White当时转到了Xerox(施乐)公司,那儿于是开发了若干个RPC系统。其中之一的Courier是于1981年面世的一个产品。关于RPC的经典论文是[Birrell and Nelson 1984],它讲述了20世纪80年代早期在Xerox公司的Dorado单用户工作站上运行的Cedar项目的RPC机制。Xerox在大多数人还不知道工作站是什么的时候就打算在工作站上实现RPC了!Courier的一个Unix实现版本随4.x BSD各个发行版本传播了许多年,不过现今Courier只具有历史意义了。
Sun公司于1985年发行它的RPC软件包的第一个版本。它是由Bob Lyon开发的,Bob于1983年离开Xerox加入Sun。它的正式名字是ONC/RPC:Open Network Computing Remote Procedure Call(开放的网络计算远程过程调用),不过往往被称为“Sun RPC”。技术上讲,它类似于Courier。Sun RPC的初期版本是用套接字API编写的,既能用于TCP,也能用于UDP。公开可得的源代码版本称为RPCSRC。20世纪90年代早期,Sun RPC改用TLI API重新编写,能用于内核支持的任何网络协议,其中TLI是XTI(在UNPv1第四部分讲述)的前身。套接字版本和TLI版本的公开可得源代码实现都可从ftp://playground.sun.com/pub/rpc中获得,前者的名字为rpcsrc,后者的名字为tirpcsrc(称为TI-RPC,其中“TI”代表“传输独立(transport independent)”)。
RFC 1831[Srinivasan 1995a]提供了Sun RPC的一个概貌,并描述了通过网络发送的RPC消息的格式。RFC 1832[Srinivasan 1995b]讲述了XDR,既包括所支持的数据类型,又包括它们的“在线上(on the wire)”格式。RFC 1833[Srinivasan 1995c]讲述了捆绑协议:RPCBIND及其前身端口映射器。
使用Sun RPC的最广泛流行的应用也许是NFS,即Sun的网络文件系统。正常情况下,NFS不是使用本章讲述的标准RPC工具、rpcgen以及RPC运行时函数库构造的。相反,它的大多数库例程是手工优化过的,并且因性能原因而驻留在内核中。然而支持NFS的大多数系统也支持Sun RPC。
20世纪80年代中期,Appollo公司与Sun在工作站市场展开竞争,并自行设计了称为NCA (Network Computing Architecture,网络计算体系结构)的RPC软件包与Sun RPC一较高下,其实现称为NCS(Network Computing System,网络计算系统)。NCA/RPC是RPC协议,NDR(Network Data Representation,网络数据表示)类似于Sun的XDR,NIDL(Network Interface Definition Language,网络接口定义语言)则定义了客户和服务器之间的接口(例如类似于图16-1中的.x文件)。运行时函数库称为NCK(Network Computing Kernel,网络计算内核)。
Apollo于1989年被Hewlet.Packard(惠普)公司收购,NCA于是发展成为开放软件基金会(OSF)的分布式计算环境(Distribute.Computin.Environment,DCE),其中RPC是一个基本元素,大多数部件就构建在其上。有关DCE的详细信息可从http://www.camb.opengroup.org/tech/dce中得到。DC.RPC软件包有一个实现是公开可得的,其URL为ftp://gatekeeper.dec.com/pub/DEC/DCE。该目录还含有一个171页的文档,讲述DC.RPC软件包的内部工作原理。许多平台上有DCE可用。
Sun RPC比DCE RPC要流行得多,其原因也许是前者有免费可得的实现,而且大多数版本的Unix把Sun RPC软件包作为基本系统的一部分提供。DCE RPC通常是作为一个增值(也就意味着单独计价)特性提供的。它的公开可得实现并没有得到广泛的移植,尽管往Linux上的移植正在进展中。本书中我们只讨论Sun RPC。Courier、Sun RPC和DCE RPC这三个RPC软件包都极其相似,因为基本的RPC概念是一样的。
大多数Unix厂家提供关于Sun RPC的除手册页面外的详细文档。举例来说,Sun的文档可从http://docs.sun.com获取,在“Developer Collection(开发人员资料汇编)”第1卷中,它是名为“ONC+Developer’S Guide(ONC+开发人员指南)”的共280页的一个部分。Digital Unix的文档可从http://www.u_nix.digita1.com/faqs/publications/pub_page/V40D_DOCS.HTM获取,包括一本题目为“Programming with ONC RPC(使用ONC RPC编程)”的116页手册。
RPC本身是一个让人争议的主题。http://www.kohala.com/~rstevens/papers.others/rpc.comments.txt中收集了关于这个主题的8篇文章。
本章中我们假设给大多数例子使用TI-RPC(早先提及的传输独立版本的RPC),其中TCP和UDP作为TI-RPC支持的协议讨论,不过TI-RPC能够支持主机所能支持的任何协议。
回想图15-9,我们从中展示了由门服务器执行的自动线程管理,由此默认提供了一个并发服务器。我们现在展示Sun RPC默认提供的是一个迭代服务器(iterative server)。我们从上一节中的例子程序着手,并只修改其中的服务器过程。图16-6给出了这个新函数,它输出所在线程的线程ID,睡眠5秒钟,再输出自己的线程ID,然后返回。
图16-6 睡眠5秒钟的服务器过程
我们启动服务器,然后运行客户程序三次:
solaris % client localhost 22 & client localhost 33 & \
client localhost 44 &
[3] 25179
[4] 25180
[5] 25181
solaris % result: 484 shell提示符输出后约5秒
result: 1936 另一个5秒后
result: 1089 另一个5秒后
尽管光看这些输出,我们不能识别每个客户输出各自的结果时彼此间有5秒钟的等待发生。然而要是查看服务器的输出,我们就会看到各个客户请求是迭代地处理的:处理完第一个客户的请求后,接着处理第二个客户的请求直到处理完毕,最后是处理第三个客户的请求直到处理完毕。
solaris % server
thread 1 started,arg = 22
thread 1 done
thread 1 started,arg = 44
thread 1 done
thread 1 started,arg = 33
thread 1 done
可看出单个线程在处理所有的客户请求:默认情况下服务器并不多线程化。
第15章中我们的门服务器程序从shell启动时都运行在前台,例如:
solaris % server
这样允许我们在服务器过程中放置调试用的printf调用。但是Sun RPC服务器默认作为守护进程运行,也就是执行了UNPv1的12.4节 [5] 中概括出的若干步骤。这就要求从服务器过程中调用syslog来输出任何诊断信息。然而我们的做法是在编译服务器程序时指定C编译器标志-DDEBUG,这跟在服务器程序存根(由rpcgen产生的square_svc.c文件)中放置如下行是等效的:
#define DEBUG
这么一来就阻止了服务器程序main函数将自身变为一个守护程序,服务器于是继续连接到启动它的终端上。这就是我们可以从服务器过程中调用printf的原因。
Sun RPC是随Solaris 2.4提供多线程化的服务器的,它通过向rpcgen指定一个-M命令行选项启用。这使由rpcgen产生的服务器代码变得线程安全。另一个选项-A是让服务器根据处理新客户请求的需要自动创建线程。我们运行rpcgen时,同时使能这两个选项。
客户程序和服务器程序的源代码都需要修改,这是我们应该预期到的,因为我们在图16-3中使用了static类型变量。对square.x文件的唯一改动是把版本号从1改为2。服务器过程的参数结构和结果结构的声明都不变。
图16-7给出了新的客户程序。
图16-7 用于多线程化服务器的客户程序main函数
声明存放结果的变量
8 声明一个square_out类型的变量,而不是指向该类型的一个指针。
过程调用的新参数
12~14 指向我们的out变量的指针成为squareproc_2的第二个参数,客户句柄则是最后一个参数。该函数返回的不再是指向结果的指针(如图16-2中所示),而是返回RPC_SUCCESS,或者返回表示发生错误的某个其他值。<rpc/clnt_stat.h>头文件中的clnt_stat枚举列出了所有可能的出错返回值(enum)。
图16-8给出了新的服务器过程。与图16-6一样,它输出自己所在线程的线程ID,睡眠5秒钟,输出另一个消息,然后返回。
图16-8 多线程化的服务器过程
新的参数和返回值
3~12 多线程化所需的变动涉及函数参数和返回值。我们不再返回一个指向结果结构的指针(如图16-3中所示),指向该结构的指针现在成了本函数的第二个参数。指向svc_req结构的指针现在变为第三个参数。本函数的返回值在成功时为TRUE,在发生错误时为FALSE。
释放XDR内存空间的新函数
13~19 我们必须完成的另一个源代码变动是提供一个函数来释放自动分配的内存空间。该函数是在服务器过程返回并且其结果已发送给客户后,从服务器程序存根中调用的。在图16-8中,我们只是调用普通的xdr_free函数。(我们将随图16-19和习题16.10详细讨论该函数。)要是我们的服务器过程曾经分配了任何必要的内存空间以容纳结果(譬如说一个链表),这个新函数就可以释放这部分内存空间。
构造出新的客户程序和服务器程序后,我们再次同时运行客户程序的三个副本:
solaris % client localhost 55 & client localhost 66 & \
client localhost 77 &
[3] 25427
[4] 25428
[5] 25429
solaris % result: 4356
result: 3025
result: 5929
这一次我们能够辨别出那三个结果是一个紧接一个地输出的。查看服务器的输出,我们看到服务器使用了三个线程,它们是同时运行的。
solaris % server
thread 1 started,arg = 55
thread 4 started,arg = 77
thread 6 started,arg = 66
thread 6 done
thread 1 done
thread 4 done
按照多线程化的要求修改源代码有一个不幸的副作用,那就是并非所有系统都支持这个特性。举例来说,Digital Unix 4.0B和BSD/OS 3.1提供的都是不支持多线程化的较老的RPC系统。这意味着如果我们想在这两种类型的系统上编译和运行一个程序,那么在其客户程序和服务器程序中必须加入一些#ifdef伪指令以处理在调用序列上存在的差异。当然举例来说, BSD/OS上未线程化的一个客户仍能调用运行在Solaris上的一个多线程化的服务器过程,但是如果我们有一个希望在这两种类型的系统上都能编译的RPC客户程序(或服务器程序),那就有必要修改源代码以处理这些差异。
在描述图16-5时,我们掩饰了第0步中的细节:服务器如何向它的本地端口映射器(port mapper)注册自身,客户如何发现服务器的端口值。首先应注意的是,运行RPC服务器的任何主机必须在运行端口映射器。赋给端口映射器的是TCP端口111和UDP端口111,它们是赋给Sun RPC的唯一的因特网固定端口。RPC服务器总是先捆绑一个临时端口,再向本地端口映射器注册自己的临时端口。当一个客户启动时,它必须首先跟服务器主机上的端口映射器联系,询问服务器的临时端口号,然后跟这个临时端口上的服务器联系。端口映射器提供了一个范围局限于所在系统的名字服务。
有些读者会声称NFS也有一个固定的端口号2049。尽管许多实现默认使用这个端口,而且某些较早的实现还把这个端口号硬编码到客户程序和服务器程序中,但是大多数当今的实现允许使用其他端口号。大多数NFS客户也是通过与服务器主机上的端口映射器联系来获取NFS服务器的端口号的。
随着Solaris 2.x的出现,Sun公司把端口映射器改名为RPCBIND。换名的原因在于,“端口”一词隐指因特网端口,而TI-RPC软件包能够工作在任何网络协议上,不只是工作在TCP和UDP协议上。我们还是使用传统的名字:端口映射器。另外在下面的讨论中,我们假设服务器主机只支持TCP和UDP协议。
服务器和客户是按如下的步骤执行的。
(1)当系统进入多用户模式时,端口映射器启动。其可执行文件名一般为portmap或rpcbind。
(2)当我们的服务器启动时,它的main函数(该函数属于由rpcgen产生的服务器程序存根的一部分)调用库函数svc_create。svc_create确定本主机所支持的网络协议,并为每个协议创建一个传输端点(例如套接字),给TCP和UDP端点各捆绑一个临时端口。该函数然后与本地的端口映射器联系,向它注册(TCP和UDP)这两个临时端口号以及调用程序的RPC程序号和版本号。
端口映射器本身是一个RPC程序,服务器就是使用RPC调用向端口映射器注册自身的(不过所用端口为已知的111)。RFC 1833[Srinivasan 1995c]中有端口映射器所支持过程的相关说明。这个RPC程序存在三个版本:版本2是历史久远的端口映射器,只处理TCP和UDP端口,版本3和版本4则采用较新的RPCBIND协议。
通过执行rpcinfo程序,我们可以看到已向端口映射器注册了的所有RPC程序。我们可执行该程序来验证端口映射器本身使用端口号111。
solaris % rpcinfo -p
program vers proto port service
100000 4 tcp 111 rpcbind
100000 3 tcp 111 rpcbind
100000 2 tcp 111 rpcbind
100000 4 udp 111 rpcbind
100000 3 udp 111 rpcbind
100000 2 udp 111 rpcbind
(我们已省略掉了输出中的许多额外的行。)我们看到Solaris 2.6支持所有三个版本的协议,全部在端口111上,并且或者使用TCP,或者使用UDP。从RPC程序号到服务号的映射关系通常存放在文件/etc/rpc中。在BSD/OS 3.1下执行同样的命令,结果表明它只支持版本2的端口映射器RPC程序。
bsdi % rpcinfo -p
program vers proto port
100000 2 tcp 111 portmapper
100000 2 udp 111 portmapper
Digital Unix 4.0B也只支持版本2:
alpha % rpcinfo -p
program vers proto port
100000 2 tcp 111 portmapper
100000 2 udp 111 portmapper
然后我们的服务器程序进入睡眠,等待客户请求的到达。这种请求可以是在其TCP端口上的一个新连接,也可以是在其UDP端口上的一个UDP数据报的到达。启动图16-3给出的服务器后执行rpcinfo,我们看到:
solaris % rpcinfo -p
program vers proto port service
824377344 1 udp 47972
824377344 1 tcp 40849
其中824377344等于0x31230000,它是我们在图16-1所赋的程序号。我们还在该图中赋了一个值为1的版本号。注意,服务器准备好或者使用TCP或者使用UDP接受客户请求,客户则在创建客户句柄时选择使用其中哪个协议(图16-2中clnt_cre_ate的最后一个参数)。
(3)客户启动并调用clnt_create。该函数的参数(图16-2)包括:服务器主机的主机名或IP地址、程序号、版本号及指定所用协议的字符串。客户向服务器主机的端口映射器发送一个RPC请求(这个RPC消息通常使用UDP作为所用协议),询问关于所指定的程序、版本和协议的信息。假设成功的话,作为答复的服务器端口号就保存到客户句柄中,供将来使用该句柄的所有RPC调用参考。
在图16-1中,我们给例子程序使用了值为0x31230000的程序号。这个32位的程序号是划分成组的,如图16-9所示。
图16-9 Sun RPC的程序号范围
rpcinfo程序显示本系统上当前已注册的程序。一个给定系统上所支持RPC程序的相关信息的另一个来源是目录/usr/inelude/rpcsvc中的.x文件。
inetd和RPC服务器
默认情况下,由rpcgen创建的服务器可由inetd超级服务器激活。(UNPv1的12.5节 [6] 详细讨论了inetd。)查看由rpcgen产生的服务器程序存根,可看到服务器程序main函数启动时,会检查标准输入是不是一个XTI端点,若是则假定自身是由inetd启动的。
为了支持这一特性,在创建了一个将由inetd激活的一个RPC服务器之后,必须以该服务器的信息更新/etc/inetd.conf配置文件,这些信息包括:RPC程序名、所支持的程序号、所支持的协议、服务器程序可执行文件的路径名。作为一个例子,下面是出自Solaris配置文件中的一行:
rstatd/2-4 tli rpc/datagram_v wait root
/usr/lib/netsvc/rstat/rpc.rstatd rpc.rstatd
其中第一栏是程序名(它将被映射成相应的程序号,这种映射关系存放在/etc/rpc文件中),所支持的版本为2、3和4。下一栏指定一个XTI端点(与套接字端点相对立),第三栏指定所有可见的数据报协议都受支持。查看文件/etc/neteonfig,看到这样的协议有两个:UDP和/dev/clts。(UNPv1第29章讲述该文件和XTI地址。)第四栏wait告诉inetd在监听发往相应XTI端点的下一个客户请求前,先等待本服务器当前这次激活终止。/etc/inetd.conf中的所有RPC服务器都指定wait属性。
再下一栏root指定本程序将在这个用户ID下运行,最后两栏是本程序可执行文件的路径名以及程序名,外带传递给该程序的任何命令行参数(本程序没有命令行参数)。
inetd将给所指定的程序及版本创建XTI端点,并向端口映射器登记这些端点。我们可使用rpcinfo程序验证这一点:
solaris % rpcinfo | grep statd
100001 2 udp 0.0.0.0.128.11 rstatd superuser
100001 3 udp 0.0.0.0.128.11 rstatd superuser
100001 4 udp 0.0.0.0.128.11 rstatd superuser
100001 2 ticlts \000\000\020, rstatd superuser
100001 3 ticlts \000\000\020, rstatd superuser
100001 4 ticlts \000\000\020, rstatd superuser
其中第四栏是XTI地址的可显示格式(它是逐个字节输出的),128×256+11=32779是分配给该XTI端点的UDP临时端口号。
当一个UDP数据报到达端口32779时,inetd将检测到有一个数据报已准备好被读入,于是它fork并exec程序/usr/lib/netsvc/rstat/rpc.rstatd。在fork和exec之间,该服务器的XTI端点被复制到描述符0、1和2上,inetd的所有其他描述符则都被关闭(UNPv1的图12-7 [7] )。inetd还将停止监听发往该XTI端点的新的客户请求,直到当前这次服务器激活(它是inetd的一个子进程)终止为止,这是因为该服务器在配置文件中指定了wait属性的缘故。
假设该程序是由rpcgen产生的,它将检测到标准输入是一个XTI端点,于是相应地把该端点初始化为一个RPC服务器端点。这是通过调用RPC函数svc_tli_create和svc_reg完成的,我们不再讨论它们。第二个函数并不向端口映射器登记本服务器,这步工作只由inetd在启动时做一次。该RPC服务器接着进入循环,由名为svc_run的函数读入待处理的数据报,并调用相应的服务器过程来处理这个客户请求。
通常情况下,由inetd激活的服务器处理完一个客户请求后就终止,以此允许inetd等待下一个客户请求。作为一种优化手段,由rpcgen产生的RPC服务器会等待一小段时间(默认值为2分钟),以防另一个客户请求在这段时间内到达。如果真是这样,这个已经在运行的现有服务器将读入新的数据报并处理其请求。这就避免了给短时间内相继到达的多个客户请求分别执行一次fork和一次exec的开销。过了这段小的等待期后,服务器将终止。这将给inetd产生一个SIGCHLD信号,从而导致它再次开始查看该XTI端点上有无数据报到达。
默认情况下,RPC请求中没有标识客户的信息。服务器回答客户的请求时并不关心客户是谁。这称为空认证(null authentication)或AUTH_NONE。
下一个认证级别称为Unix认证(Unix authentication)或AUTH_SYS。客户必须告诉RPC运行时系统随每个请求携带其身份信息(主机名、有效用户ID、有效组ID和可能多个辅助组ID)。我们把16.2节中的客户-服务器程序修改成包括Unix认证。图16-10给出了其中的客户程序。12~13 这两行是新的。我们首先调用auth_destroy销毁与本客户句柄关联的先前的认证,也就是默认创建的空认证。然后调用函数authsys_create_default创建相应的Unix认证结构,并把该结构存入客户句柄CLIENT结构的cl_auth成员中。客户程序的其余部分与图16-7的一样。
图16-10 提供Unix认证的客户程序
图16-11给出了新的服务器过程,它从图16-8修改而来。我们没有给出square_prog_2_freeresult函数,它没有变动。
图16-11 寻找Unix认证的服务器过程
6~8 现在使用一个指向svc_req结构的指针,它总是作为一个参数传递给服务器过程。
struct svc_req {
u_long rq_prog; /* program number */
u_long rq_vers; /* version number */
u_long rq_proc; /* procedure number */
struct opaque_auth rq_cred; /* raw credentials */
caddr_t rq_clntcred; /* cooked credentials (read-only)*/
SVCXPRT *rq_xprt; /* transport handle */
};
struct opaque_auth {
enum_t oa_flavor; /* flavor: AUTH_xxx constant */
caddr_t oa_base; /* address of more auth stuff */
u_int oa_length; /* not to exceed MAX_AUTH_BYTES */
};
其中rq_cred成员含有原始认证信息,它的oa_flavor成员是一个标识认证类型的整数。“原始(raw)”一词意味着RPC运行时系统没有处理由oa_base指向的信息。然而,如果是运行时系统支持的认证类型的话,那么由rq_clntcred指向的成熟(cooked)凭证已被运行时系统处理成某个适合那种认证类型的结构。我们输出认证类型,并检查它是否等于AUTH_SYS。
9~12 对于Unix认证,指向成熟凭证的指针(rq_clntcred)所指的是含有客户身份的一个authsys_parma结构:
struct authsys_parms {
u_long aup_time; /* credentials creation time */
char *aup_machname;/* hostname where client is located */
uid_t aup_uid; /* effective user ID */
gid_t aup_gid; /* effective group ID */
u_int aup_len; /* #elements in aup_gids[] */
gid_t *aup_gids; /* supplementary group IDs */
};
我们取得指向这个结构的指针后,输出客户的主机名、有效用户ID和有效组ID。
启动我们的服务器,然后运行客户程序一次,我们从服务器得到如下的输出:
solaris % server
thread 1 started,arg = 44,auth = 1
AUTH_SYS: host solaris.kohala.com,uid 765,gid 870
thread 1 done
Unix认证很少使用,因为它极易被攻破。我们可以很容易地自行构造含有Unix认证信息的RPC分组,把其中的用户ID和组ID设置成我们想要的任何值,然后发送给服务器。服务器没有办法验证我们就是所声称的客户。
实际上NFS默认使用Unix认证,然而NFS请求通常是由NFS客户主机的内核发出的,而且通常使用一个保留端口(UNPv1的2.7节)。有些NFS服务器配置成只响应从一个保留端口到达的客户请求。如果你信任想要往其上安装你的文件系统的客户主机,那么你也在信任该客户主机的内核能正确地标识自己的用户。要是服务器不要求客户使用一个保留端口,黑客们就能够自行编写出向NFS服务器发送NFS请求的程序,其中的Unix认证ID可设置成任意想要的值。即使服务器要求客户使用一个保留端口,如果你拥有一个自己具有超级用户特权的系统,而且你能够把自己的系统接入网络中,那么你仍可以向服务器发送自己的NFS请求。
不论是请求还是应答,一个RPC分组中实际都含有两个与认证相关的字段:凭证(credential)和验证器(verifier)(图16-30和图16-32)。一个常用的类比是带相片的身份证件(护照、驾驶执照等)。凭证是印制的信息(姓名、住址、出生日期等),验证器则是相片。验证器还有其他的形式,不过相片的效果要比列出身高、体重、性别等更好。如果我们有一个没有任何形式的识别信息的身份证件(图书馆借书卡往往是这样的例子),那么我们是光有凭证而没有验证器,因而任何人都可以使用它并声称是它的主人。
在空认证的情况下,凭证和验证器都是空的。使用Unix认证时,凭证中含有主机名、用户ID和组ID,但是验证器是空的。RPC还支持其他形式的认证,它们的凭证和验证器含有的信息也不一样。
AUTH_SHORT 另一种形式的Unix认证,在从服务器返回客户的RPC应答的验证器字段中发送。它的信息量比完整的Unix认证少,而且客户可以在后续的请求中把它作为凭证发送回服务器。这种认证类型的意图在于节省网络带宽和服务器主机的CPU时间。
AUTH_DES DES是数据加密标准(Data Encryption Standard)的首字母缩写,这种认证形式基于私钥和公钥加密机制。这种方案也称为安全的RPC(secure RPC),当用作NFS的基础时,这样的NFS称为安全的NFS(secure NFS)。
AUTH_KERB 这种方案基于MIT的Kerberos认证系统。
[Garfinkel and Spafford 1996]第19章详细讨论了后两种形式的认证,包括它们的设置和使用。
现在查看Sun RPC使用的超时和重传策略。Sun RPC使用了两个超时值。
(1)总超时(total timeout):一个客户等待其服务器的应答的总时间量。TCP和UDP都使用该值。
(2)重试超时(retry timeout):只用于UDP,是一个客户在等待其服务器的应答期间每次重传请求的间隔时间。
首先应注意使用TCP不需要重试超时,因为TCP是一个可靠的协议。如果服务器主机没有接收到客户的请求,客户主机的TCP就会超时并重传该请求。当服务器主机接收到客户的请求时,服务器主机的TCP会向客户主机的TCP确认这次收到。如果服务器的确认是数据丢失,导致客户主机的TCP重传该请求,那么当服务器主机的TCP接收到这个重复的数据时,它将丢弃该数据,并再次发出一个确认。有了可靠的协议后,可靠性(超时、重传、对重复数据或重复确认的处理)就由传输层提供,RPC运行时系统不再关心它。由客户主机RPC层发出的一个请求将由服务器主机RPC层作为一个请求接收(如果这个请求从未得到过确认,那么客户主机RPC层将得到一个出错指示),而不管在网络层和传输层上发生什么。
创建一个客户句柄后,我们可以调用clnt_control查询或设置影响该句柄的选项。这类似于给一个描述符调用fcntl,或者给一个套接字调用getsockopt和setsockopt。
#include <rpc/rpc.h>
bool_t clnt_control(CLIENT *cl,unsigned int request,char *ptr);
返回:若成功则为TRUE,若出错则为FALSE
其中cl是客户句柄,由ptr指向的内容则取决于request。
我们把图16-2给出的客户程序修改为调用该函数并输出RPC的两个超时值。图16-12给出了这个新的客户程序。
图16-12 查询并输出两个RPC超时值的客户程序
协议是一个命令行选项
10~12 现在作为另一个命令行选项来指定协议,并把它用作clnt_create的最后一个参数。
取得总超时
13~14 clnt_control的第一个参数是客户句柄,第二个参数是请求,第三个参数则通常是指向一个缓冲区的指针。我们的第一个请求是CLGET_TIMEOUT,它在其地址为第三个参数的timeval结构中返回总超时值。该请求对所有协议都有效。
尝试取得重试超时
15~16 我们的下一个请求是获取重试超时的CLGET_RETRY_TIMEOUT,不过这个超时只对UDP有效。因此,如果返回值为FALSE,我们就什么都不输出。
我们还把图16-6给出的服务器程序修改为睡眠1000秒而不是5秒,以保证客户的请求超时。在主机bsdi上启动服务器后运行客户程序两次,一次指定TCP协议,一次指定UDP协议,然而结果并不是我们所预期的:
solaris % date ; client bsdi 44 tcp ; date
Web Apr 22 14:46:57 MST 1998
timeout = 30 sec,0 usec 超时值说是30秒
bsdi: RPC: Timed out
Web Apr 22 14:47:22 MST 1998 但这是在25秒后输出的
solaris % date ; client bsdi 55 udp ; date
Web Apr 22 14:48:05 MST 1998
timeout = -1 sec,-1 usec 奇怪
retry timeout = 15 sec,0 usec 这倒是正确的
bsdi: RPC: Timed out
Web Apr 22 14:48:31 MST 1998 大约25秒之后
在使用TCP的情况下,由cntl_control返回的总超时值为30秒,但是我们的测量给出一个25秒的超时值。至于使用UDP的情况,所返回的总超时值为-1。
为查看究竟发生了什么,我们分析由rpcgen产生的客户程序存根文件square_clnt.c中的squareproc_1函数。该函数调用clnt_call,传递给它的最后一个参数是名为TIMEOUT的一个timeval结构,这个变量在该文件中声明,它的初始值为25秒。传递给clnt_call的这个参数覆盖了用于TCP的30秒默认超时值和用于UDP的默认值-1。这个参数一直沿用到客户以一个CLSET_TIMEOUT请求调用clnt_control显式地设置总超时值为止。如果我们想改变总超时值,那就应该调用clnt_control,而不应该修改客户程序存根中的timeval结构变量TIMEOUT。
验证UDP重试超时的唯一办法是使用tcpdump观察分组。这种观察表明,第一个数据报是在客户一启动后就发送的,下一个数据报的发送则在约15秒之后。
16.5.1 TCP连接管理
使用tcpdump观察刚才描述的客户-服务器程序的运行情况,我们首先看到TCP的三路握手,然后是客户发送其请求,服务器确认这个请求。大约25秒后,客户发送一个FIN分节,它是由客户进程即将终止引起的,接着是TCP连接终止序列的其余三个分节。UNPv1的2.5节详细讲述了这些分节。
我们想要展示Sun RPC在使用TCP连接上的以下特征:客户通过调用clnt_create建立一个新的TCP连接,这个连接由与所指定的程序和版本相关联的所有过程调用来使用。一个客户的TCP连接或者通过调用clnt_destroy显式地终止,或者由客户进程的终止隐式地终止。
#include <rpc/rpc.h>
void clnt_destroy(CLIENT *cl);
我们从图16-2的客户程序着手,把它修改成调用服务器过程两次,然后调用clnt_destroy,接着pause。图16-13给出了这个新的客户程序。
图16-13 检查TCP连接使用情况的客户程序
运行这个程序得到了预期的输出:
solaris % client kalae 5
result: 25
result: 100
程序只是等待,直到我们杀死它为止
不过验证我们早先给出的声明只能通过观察tcpdump的输出。这个输出表明,有一个TCP连接被创建了(通过调用clnt_create),而且两个客户请求都使用这个连接。该连接然后由clnt_destroy调用终止,尽管当时客户进程还没有终止。
16.5.2 事务ID
超时和重传策略的另一部分是使用事务ID(transaction ID)即XID来标识客户请求和服务器应答的。当一个客户发出一个RPC调用时,RPC运行时系统给这个调用赋一个32位整数XID,该值伴随RPC消息发送。服务器必须伴随其应答返回这个XID。RPC运行时系统重传一个请求时, XID并不改变。使用XID的目的有两个。
(1)客户验证应答的XID等于早先随请求发送的XID,否则的话客户忽略这个应答。如果使用的是TCP协议,那么客户收到XID不正确的应答的机会非常罕见,然而如果使用的是UDP协议,而且存在重传请求的可能,网络也易于丢失分组,那么接收到XID不正确的应答是绝对可能的。
(2)服务器允许维护一个存放已发送应答的高速缓存(cache),而用于确定一个请求是否为一个重复请求的条目之一是XID。我们稍后讨论这个高速缓存。
TI_RPC软件包使用以下算法来给一个新请求选择一个XID,其中^运算符是C的按位异或:
struct timeval now;
gettimeofday(&now,NULL);
xid = getpid()^ now.tv_sec ^ now.tv_usec;
16.5.3 服务器重复请求高速缓存
为使能RPC运行时系统维护一个重复请求高速缓存,服务器必须调用svc_dg_enablecache。一旦启用了这个高速缓存,就没有办法关掉它(除非服务器进程终止)。
#include <rpc/rpc.h>
int svc_dg_enablecache(SVCXPRT *xprt,unsigned long size);
返回:若成功则为0,若出错则为−1
其中xprt是一个传输句柄,该指针是svc_req结构的一个成员(16.4节)。而该结构的地址是作为一个参数传递给服务器过程的。size是需为之分配内存空间的高速缓存项数。
启用该高速缓存后,服务器便为它所发送的全部应答维护一个FIFO(先进先出)高速缓存。每个应答是由如下信息唯一标识的:
程序号;
版本号;
过程号;
XID;
客户地址(IP地址和UDP端口号)。
每当服务器中的RPC运行时系统接收到一个客户请求时,首先会搜索重复请求高速缓存,看其中是否已有该请求的一个应答。如果有的话,这个高速缓存的应答就返回给客户,而不再调用相应的服务器过程。
重复请求高速缓存的目的是:当接收到对某个服务器过程的多个重复请求时,避免多次调用该服务器过程,因为该过程也许不是等势的。在网络中接收到重复请求的可能原因是应答丢失或者客户重传请求超前于应答的接收。注意,这种重复请求高速缓存只适用于像UDP这样的数据报协议,因为使用TCP协议时应用绝对看不到重复的请求,请求的重复问题是由TCP处理的(见习题16.6)。
在图15-29给出的门客户程序中,当客户的door_call调用被一个捕获的信号所中断时,客户向服务器重传其请求。然而我们接下去展示出这将导致相应的服务器过程被调用两次,而不是一次。我们随后把服务器过程划分成等势(能够任意多次无差错地调用)和非等势(例如从某个银行账户上减去一笔费用)两大类。
过程调用可划分成以下三个类别。
(1)正好一次(exactly once):过程只能不多不少地执行一次。这类操作难于实现,因为服
务器存在崩溃的可能。
(2)最多一次(at most once):过程根本不执行或只执行一次。如果正常地返回到调用者,我们就知道该过程执行了一次。然而如果是出错返回,我们就不能肯定该过程执行了一次还是根本没有执行。
(3)最少一次(at least once):过程至少执行一次,不过有可能是多次。这对于等势过程没有问题,客户只需一直传送其请求,直到接收到一个有效的响应为止。然而如果客户非得不止一次地发送其请求以接收一个有效的响应,那么该过程执行一次以上的可能是存在的。
对于一个本地过程调用,如果它返回,我们就知道它正好执行了一次,但是如果当前进程在调用该过程后崩溃,我们就不知道它到底执行了一次还是根本没有执行。对于一个远程过程调用,我们必须考虑如下各种情形。
如果使用的是TCP协议,而且接收到了一个应答,我们就知道该远程过程正好被调用了一次。但是如果没有接收到应答(譬如说服务器主机崩溃了),我们就不知道该服务器过程已在其主机崩溃之前执行完毕,还是尚未被调用(最多一次的语义)。在服务器主机可能崩溃,而且网络存在停止运作可能的前提下,提供正好一次的语义需要一个事务处理系统,它已超出了RPC软件包的能力。
如果使用的是UDP协议,而且服务器主机没有崩溃,应答也接收到了,我们就知道该服务器过程至少被调用了一次,不过也可能是多次(最少一次语义)。
如果使用的是UDP协议,而且启用了一个服务器高速缓存,应答也接收到了,我们就知道该服务器过程正好被调用了一次。然而如果没有接收到应答,那就具有最多一次的语义,这跟TCP情形类似。
给定如下三种选择:
(1)TCP;
(2)UDP,带有一个服务器高速缓存;
(3)UDP,没有任何服务器高速缓存。
我们的建议如下所述。
总是使用TCP,除非TCP连接的开销对于应用来说过分昂贵。
给正确执行的意义相当重大的非等势过程(例如银行账户、机票预订等)使用一个事务处理系统。
对于非等势过程,使用TCP要比使用带有一个服务器高速缓存的UDP更为可取。TCP一开始就设计成可靠的,而往一个UDP应用中添加可靠性很少能达到使用TCP的效果(例如UNPv1的20.5节)。
对等势过程使用不带服务器高速缓存的UDP不成问题。
? 对非等势过程使用不带服务器高速缓存的UDP则是危险的。
我们将在下一节讨论使用TCP的其他优势。
现在考虑客户或服务器之一过早终止,而且使用TCP作为传输协议时会发生什么情况。既然UDP是无连接的,因而打开着某个UDP端点的一个进程终止时,不会有任何信息发送给对方。在使用UDP的情形下,当有一方崩溃时所发生的全部情况为:对方将超时,可能会重传,最终放弃,这是上一节中讨论过的。然而,当具有某个打开着的TCP连接的一个进程终止时,该连接也终止,从而向对方发送一个FIN分节(UNPv1第36~37页),我们就想看一看当RPC运行时系统在接收到来自对方的这个出乎意料的FIN时会做些什么。
16.7.1 服务器的过早终止
我们首先在服务器仍在处理一个客户请求时过早地终止它。对客户程序所作的唯一变动是:把图16-2中clnt_create调用的“tcp”参数挪走,改成作为一个命令行参数指定所用的传输协议,就像图16-12中的那样。在服务器过程中,我们增加一个abort函数调用。该调用会终止服务器进程,导致服务器主机的TCP向客户主机的TCP发送一个FIN,这一点可使用tcpdump验证。
我们首先对BSD/OS系统上的服务器运行Solaris系统上的客户程序:
solaris % client bsdi 22 tcp
bsdi: RPC: Unable to receive; An event requires attention
当客户主机接收到服务器主机的FIN时,其RPC运行时系统正在等待服务器的应答。它检测到这个非预期的应答后,从我们的squareproc_1调用返回一个错误。该错误(RPC_CANTRECV)由运行时系统保存在客户句柄中,(从我们的Clnt_create包裹函数中)调用clnt_sperror将把该错误输出成“Unable to receive(无法接收)”。该出错消息的其余部分“Anevent requires attention(有一个事件需要留意)”对应于由运行时系统保存的XTI错误,它也由clnt_sperror输出。一个客户调用一个远程过程时能够返回大约30个不同的RPC_xxx错误,它们列在<rpc/clnt_star.h>头文件中。
对换客户和服务器的运行主机,我们看到同样的情形,由RPC运行时系统返回同样的错误(RPC_CANTRECV),不过最后输出的消息不一样。
bsdi % client solaris 11 tcp
solaris: RPC: Unable to receive; errno = Connection reset by peer
上面我们中止的Solaris服务器不是作为一个多线程化的服务器程序编译的,因此当我们调用abort时,整个进程被终止。如果我们运行一个多线程化的服务器程序,事情就有变化,也就是说只有为客户的调用提供服务的线程才终止。为迫使这种情形出现,我们把abort调用替换成pthread_exit调用,就像我们在图15-25中对使用门的例子所做的那样。然后在BSD/OS系统上运行客户程序,在Solaris系统上运行多线程化的服务器程序。
bsdi % client solaris 33 tcp
solaris: RPC: Timed out
当服务器线程终止时,与客户的TCP连接并未关闭,也就是说它仍然在服务器进程中保持打开。因此,服务器主机没有给客户发送FIN,于是客户仅仅超时。在客户请求已发送给服务器,并且服务器主机的TCP已确认该请求后,如果服务器主机崩溃,那么我们将看到同样的情况。
16.7.2 客户的过早终止
当一个RPC客户在其使用TCP的某个RPC过程调用仍在进展期间终止时,客户主机的TCP将向服务器主机的TCP发送一个FIN。我们的问题是:服务器的RPC运行时系统是否检测到了这个条件,从而可能向服务器过程发出通知(回想15.11节,当客户过早终止时,门服务器线程被取消)。
为产生这个条件,我们的客户程序在调用服务器过程的紧前调用alarm(3),我们的服务器过程则调用sleep(6)。(图15-30和图15-31中使用门的例子就是这么做的。由于客户没有捕获SIGALRM,其进程将在服务器的应答返回前约3秒时由内核终止。)我们在BSD/OS系统上运行客户程序,在Solaris系统上运行服务器程序。
bsdi % client solaris 44 tcp
Alarm call
在客户方发生的情况是我们预期的,但在服务器方没有发生任何特殊之事。服务器过程结束其6秒钟的睡眠后返回。用tcpdump查看所发生的情况,我们看到以下情况。
当客户终止时(在启动后约3秒时),客户主机的TCP向服务器主机的TCP发送一个FIN,服务器主机的TCP对它作了确认。按照TCP的术语,这个过程称为半关闭(half_close, TCPv1的18.5节)。
客户和服务器启动后约6秒时,服务器发送其应答,该应答由服务器主机的TCP发送给客户。(正如UNPv1第130~132页中讲述的那样,接收到一个FIN后通过同一TCP连接发送数据没有问题,因为TCP连接是全双工的。)客户主机的TCP响应以一个RST分节(复位),因为客户进程已经终止。服务器下一次在这个连接上读或写时将认识到这个现状,然而目前什么都不发生。
我们汇总一下本节探讨的几个关键点。
使用UDP的RPC客户和服务器永远不知道对方是否过早终止。当接收不到响应时,它们可能超时,不过无法分辨错误类型:进程过早终止、对方主机崩溃、网络不可达,等等。
使用TCP的客户和服务器检测出对方所存在问题的机会要大得多,因为对方进程的过早终止自动导致对方主机的TCP关闭其所在端的连接。但是如果对方是一个线程化的服务器,这一点就不起作用,因为对方线程的终止并不会关闭其所在端的连接。另外这一点也无助于检测对方主机的崩溃,因为发生这种情况时,对方主机的TCP并没关闭它的打开着的连接。为处理所有这些情形,超时机制仍然是必需的。
使用前一章中讲述的门从一个进程调用另一个进程中的某个过程时,这两个进程处于同一台主机上,因而没有数据转换问题。但是对于不同主机间的RPC,各种各样的主机可能使用不同的数据格式。首先,各个基本的C数据类型可能有不同的大小(例如某些系统上long数据类型占据32位,其他系统上却占据64位)。其次,各个位真正的先后顺序可能不一样(也就是大端字节序和小端字节序的差异,我们在UNPv1第66~69页和第137~140页 [8] 中讨论过)。我们已随图16-3碰到过这个问题,那时我们在小端字节序的x86系统上运行服务器程序,在大端字节序的Sparc系统上运行客户程序,然而这样的两台主机之间仍能正确地交换长整数。
Sun RPC使用XDR即外部数据表示(External Data Representation)标准来描述和编码数据(RFC 1832[Srinivasan 1995b])。XDR既是一种用于描述数据的语言,又是一组用于编码数据的规则。XDR使用隐式类型指定(implicit typing)方式,它意味着发送者和接收者都得知道数据的类型和字节序:例如两个32位整数值后跟一个单精度浮点数值,再跟一个字符串。
作为比较,在OSI领域中ASN.1(抽象语法表示1,Abstract Syntax Notation one)是描述数据的通常方式,BER(基本编码规则,Basic Encoding Rules)是一种编码数据的常用方式。这种方案还使用显式类型指定(explicit typing)方式,它意味着每个数据值之前冠以描述所跟数据之类型的某个值(称为“指定符(specifier)”)。对应刚才这个例子的字节流按顺序含有以下各个字段:说下一个值是一个整数的指定符、整数值、说下一个值是一个整数的指定符、整数值、说下一个值是一个浮点数的指定符、浮点数值、说下一个值是一个字符串的指定符、字符串。
所有数据类型的XDR表示都需要4的倍数的字节数,这些字节总是以大端字节序传送的。带符号整数值使用二进制补码(two’s complement)记法存放,浮点数值则使用IEEE格式存放。可变长度字段总是在其末端含有最多3个字节的填充,这样下一个条目总是落在某个4字节的边界。例如一个5字节的ASCII字符串将作为12个字节来传送:
一个4字节的整数计数,其值为5;
5字节的字符串本身;
3个字节的值为0的填充。
在讲述XDR和它支持的数据类型时,我们考虑以下三个问题。
(1)如何在RPC说明书文件(.x文件)中给rpcgen声明各种类型的变量?到此为止的唯一一个例子(图16-1)只使用一个长整数。
(2)rpcgen把定义在.x文件中的变量转换成自己产生的.h头文件中的哪一种C数据类型?
(3)所传送数据的真正格式是什么?
图16-14回答了前两个问题。为产生这张表格,我们创建了一个RPC说明书文件,它用到了所有受支持的XDR数据类型。该文件通过rpcgen运行后,我们查看所产生的C头文件从而构造出该表格。
图16-14 XDR和rpcgen支持的数据类型的汇总
我们现在详细描述各个表项,并以第一栏中给出的顺序号(1~15)指称它们。
(1)const声明转换成C的#define。
(2)typedef声明转换成C的typedef。
(3)这些是总共5个的带符号整数数据类型。其中前4个是由XDR作为32位值传送的,最后一个是由XDR作为64位值传送的。
对于许多C编译器来说,64位整数的类型为long long int或long long。然而不是所有的编译器和操作系统都支持它们。既然所生成的.h文件声明这样的C变量的类型为longlong_t,因此某个头文件中必须有如下的定义:
typedef long long longlong_t;
XDR的long类型占据32位,但是64位Unix系统上的C语言long类型占据64位(例如UNPv1第27页 [9] 描述的LP64模型)。这些10年前的XDR名字在当今的世界中确实是不幸的。更好的名字可能是int8_t、int16_t、int32_t、int64_t等。
(4)这些是总共5个的无符号整数数据类型。其中前4个是由XDR作为32位值传送的,最后一个是由XDR作为64位值传送的。
(5)这些是总共3个的浮点数数据类型。其中第一个作为32位值传送,第二个作为64位值传送,第三个作为128位值传送。
四精度浮点数在C语言中的类型为long double。然而不是所有的编译器和操作系统都支持它们。(你的编译器也许允许long double,但只是把它作为double类型处理。)由于所生成的.h文件声明这样的C变量的类型为quadruple,因此某个头文件中必须有如下的定义:
typedef long double quadruple;
举例来说,Solaris 2.6下我们必须在.x文件的开始处包含如下的行:
%#include <floatingpoint.h>
因为该头文件包含了所需的定义。该行开头的百分号告诉rpcgen把本行剩余部分原封不动地放入所产生的.h头文件中。
(6)布尔(boolean)数据类型与一个带符号整数等效。RPC头文件同时定义常值TRUE为1,常值FALSE为0。
(7)一个枚举(enumeration)数据类型与一个带符号整数等效,且跟C语言的enum数据类型一样。rpcgen还给所指定的变量名产生一个typedef定义。
(8)固定长度不透明数据(fixed-length opaque data)是作为8位值传送的确定数目(n)的字节,运行时函数库不解释它。
(9)可变长度不透明数据(variable-length opaque data)也是作为8位值传送的不作解释的一个字节序列,不过真正的字节数是作为一个无符号整数先于数据传送的。发送这种类型的数据时(例如先于某个RPC调用填写传递给它的参数时),应在发出调用之前设置长度。接收这种类型的数据时,必须检查其长度以确定后跟多少数据。
声明中的最大长度m可被忽略。但是如果编译时指定了长度,那么运行时函数库将检查真正的长度(我们作为相应C结构的var_len成员给出的内容)没有超过m的值。
(10)一个字符串(string)是一个ASCII字符序列。字符串在内存中是作为一个普通的以空字符结尾的C字符串存放的,但在传输中却冠以一个指定后跟字符实际数目(不包括结尾的空字符)的无符号整数。发送这种类型的数据时,运行时系统通过调用strlen确定字符数。接收这种类型的数据时,它是作为一个以空字符结尾的C字符串存放的。
声明中的最大长度m可被忽略。但是如果编译时指定了长度,那么运行时函数库将检查真正的长度没有超过m的值。
(11)一个任意数据类型的固定长度数组(fixed-length array)是作为该数据类型的一个n个元素的序列传送的。
(12)一个任意数据类型的可变长度数组(variable-length array)作为指定该数组中实际元素数目的一个无符号整数以及后跟的各个数组元素传送。
声明中的最大长度m可被忽略。但是如果编译时指定了长度,那么运行时函数库将检查真正的长度没有超过m的值。
(13)一个结构(structure)是通过轮流传送其各个成员来传送的。rpcgen还给所指定的变量名产生一个typedef定义。
(14)一个带判别式的联合(discriminated union)由一个整数判别式后跟基于该判别式的值的一组数据类型(称为分支(arm))构成。在图16-14中给出的判别式是一个int,但它也可以是一个unsigned int、一个enum或一个bool(所有这些判别式都作为一个32位整数值传送)。传送一个带判别式的联合时,其判别式的32位值首先传送,然后传送对应于该判别式的值的唯一一个分支值。这种联合的default声明往往是void,它的意思是在判别式的32位值之后不跟任何数据。我们稍后给出这样的一个例子。
(15)可选数据(optional data)是一种特殊类型的联合,我们将随图16-24中给出的一个例子描述它。这种数据类型的XDR声明看着像是一个C指针声明,它就是所生成的.h文件所包含的内容。
图16-15汇总了XDR给它的各种数据类型采用的编码格式。
16.8.1 例子:不涉及RPC使用XDR
现在给出一个不涉及RPC使用XDR的例子。也就是说,我们将使用XDR把一个二进制数据的结构编码成一种可在其他系统上加以处理的机器无关表示。这种技巧可用于以一种与机器无关的格式书写文件,或者以一种与机器无关的格式通过网络向另一台计算机发送数据。图16-16给出了我们的RPC说明书文件data.x,它实际上只是一个XDR说明书文件,因为我们没有声明任何RPC过程。
文件名后缀.x来自“XDR说明书文件”一词。RPC规范(RFC 1831)中说,有时称为RPCL的RPC语言与XDR语言(在RFC 1832中定义)基本相同,差别只是后者增加了程序定义(用于描述程序、版本和过程)。
声明枚举和带判别式的联合
1~11 声明一个共有两个值的枚举数据类型,后跟一个把该枚举类型作为判别式的带判别式联合。如果该判别式的值为RESULT_INT,那么在该判别式的值之后传送的是一个整数值。如果该判别式的值为RESULT_DOUBLE,那么在该判别式的值之后传送的是一个双精度浮点数值。否则的话,在该判别式的值之后不传送任何数据。
声明结构
12~21 声明一个包含多个XDR数据类型的结构。
图16-15 XDR给它的各种数据类型采用的编码格式
图16-16 XDR说明书文件
既然data.x没有声明任何RPC过程,当查看图16-4中由rpcgen产生的所有文件时,我们看到rpcgen并没有产生客户程序存根和服务器程序存根。不过它仍然产生了data.h头文件和data_xdr.c文件,其中data_xdr.c含有用于编码或解码在我们的data.x文件中所声明数据条目的XDR函数。
图16-17给出了所产生的data.h头文件。按照图16-14中给出的转换规则,该头文件的内容正是我们所预期的。
图16-17 由rpcgen从图16-16产生的头文件
图16-17(续)
在data_xdr.c文件中定义了一个名为xdr_data的函数,我们可调用它来编码或解码已定义的data结构的内容。(函数名后缀_data来自图16-16中所定义结构的名字。)我们编写的第一个程序的文件名为write.c,它设置data结构中所有变量的值,调用xdr_data函数把所有字段编码成XDR格式,然后把结果写往标准输出。
图16-18给出了这个程序。
把结构成员设置成某个非零值
12~32 首先把data结构的所有成员设置成某个非零值。对于可变长度成员,我们必须设置一个计数以及这个数目的值。对于带判别式的联合,我们将判别式的值设置为RESULT_INT,对应的结果整数值为123。
分配适当地对齐的缓冲区
33 调用malloc分配XDR例程将往其中存入数据的缓冲区空间。由于该缓冲区必须在某个
4字节的边界上对齐,因此简单地静态分配一个char数组不能保证这种对齐要求。
创建XDR内存流
34 运行时库函数xdrmem_create把由buff指向的缓冲区初始化成供XDR用作一个内存流。我们分配一个名为xhandle的XDR类型的变量,并把该变量的地址作为第一个参数传递给该函数。XDR运行时系统在这个变量中维护相关信息(缓冲区指针、缓冲区中的当前位置等)。最后一个参数是XDR_ENCODE,它告诉XDR我们需从主机格式(我们的out结构)转换成XDR格式。
编码结构
35~36 调用由rpcgen在data_xdr.c文件中生成的xdr_data函数,它把out结构编码成XDR格式。返回值为TRUE表示成功。
图16-18 初始化data结构并以XDR格式将它写出
获取编码后数据的大小并write
37~38 函数xdr_getpos返回XDR运行时系统在输出缓冲区中的当前位置(也就是待存入的下一个字节的字节偏移),我们用它作为write调用的长度参数。
图16-19给出了我们的read程序,它读入由前一个程序写出的文件,输出data结构所有成员的值。
图16-19 读入XDR格式的data结构并输出其值
分配适当对齐过的缓冲区
11~13 调用malloc分配一个适当对齐过的缓冲区,把由前一个程序生成的文件读入该缓冲区。
创建XDR内存流,初始化缓冲区,然后解码
14~17 初始化一个XDR内存流,这次指定XDR_DECODE以指示我们希望从XDR格式转换成主机格式。把我们的in结构初始化为0后调用xdr_data,从而把缓冲区buff中的数据解码到in结构中。我们必须把XDR目的地(in结构)初始化为0,因为有些XDR例程(例如xdr_string)需要这样做。xdr_data与我们从图16-18中调用的同名函数是一样的,有变化的是xdrmem_create的最后一个参数:前一个程序中指定的是XDR_ENCODE,本程序中指定的是XDR_DECODE。该值由xdrmem_create保存在XDR句柄(xhandle)中, XDR运行时系统就用它来确定是编码数据还是解码数据。
输出结构的值
18~42 输出我们的data结构的所有成员的值。
释放由XDR分配的任何内存空间
43 调用xdr_free释放XDR运行时系统可能已动态分配的内存空间(参见习题16.10)。
我们在一台Sparc主机上运行write程序,并把标准输出重新定向到一个名为data的文件:
solaris % write > data
solaris % ls -l data
-rw-rw-r-- 1 rstevens other1 76 Apr 23 12:32 data
我们看到文件大小为76字节,这跟图16-20是对应的,该图详细展示了数据的存放情况(19个4字节值)。
图16-20 由图16-18写出的XDR流的格式
在BSD/OS或Digital Unix下读这个二进制文件,结果跟我们预料的一致:
bsdi % read < data
read 76 bytes
short_arg = 1,long_arg = 2,vstring_arg = 'hello,world'
fopaque[] = 99,88,77
vopaque<> = 33 44
fshort_arg[] = 9999,8888,7777,6666
vlong<> = 123456 234567 345678
uarg (int)= 123
alpha % read < data
read 76 bytes
short_arg = 1,long_arg = 2,vstring_arg = 'hello,world'
fopaque[] = 99,88,77
vopaque<> = 33 44
fshort_arg[] = 9999,8888,7777,6666
vlong<> = 123456 234567 345678
uarg (int)= 123
16.8.2 例子:计算缓冲区的大小
在前一个例子中我们分配了一个长度为BUFFSIZE(该常值在图C-1给出的unpipc.h头文件中定义为8192)的缓冲区,它的大小足够了。不幸的是,没有一种简单方法来计算XDR为一个给定的结构编码所需的总大小。只计算该结构的sizeof值是不对的,因为该结构的每个成员由XDR分别编码。我们必须逐个成员地遍历这个结构,把XDR将用于编码各个成员的大小加在一起。举例来说,图16-21给出了一个有3个成员的结构。
图16-21 一个简单结构的XDR说明书文件
图16-22给出的程序计算出XDR编码这个结构所需的字节数为28。8~9 宏RNDUP定义在<rpc/xdr.h>头文件中,它把它的参数向上舍入到下一个BYTES_PER_XDR_UNIT(4)的倍数。对于一个固定长度的数组,使用该宏计算出每个元素的大小后乘以元素数即可。
图16-22 计算XDR编码所需字节数的程序
这种技巧的问题出在可变长度数据类型上。如果我们声明string d<10>,那么所需的最大字节数为RNDUP(sizeof(int))(用于存放长度)加上RNDUP(sizeof(char)*10)(用于存放字符)。但是我们无法计算不带最大值的可变长度数据类型的大小,例如float e<>。最简单的办法是分配一个应该足够大的缓冲区,并检查XDR例程的失败情况(参见习题16.5)。
16.8.3 例子:可选数据
XDR说明书文件中有三种指定可选数据的方式,图16-23给出了所有这三种方式。
图16-23 展示三种指定可选数据的方式的XDR说明书文件
声明一个带布尔型判别式的联合
1~8 定义一个带TRUE和FALSE两个分支的联合,并把某个结构成员定义为该类型。当判别式flag为TRUE时,后跟的是一个long类型的值,否则什么都不跟。XDR运行时系统编码该参数时,它将被编码成以下两种格式之一:
? 值为1(TRUE)的4字节标志后跟一个4字节的值;
? 值为0(FALSE)的4字节标志。
声明可变长度数组
9 指定一个最多一个元素的可变长度数组时,它将被编码成以下两种格式之一:
值为1的4字节长度后跟一个4字节的值;
值为0的4字节长度。
声明XDR指针
10 成员arg3展示了指定可选数据的一种新方式(它对应于图16-14中的最后一行)。该参数将被编码成以下两种格式之一:
值为1的4字节标志后跟一个4字节值;
? 值为0的4字节标志。
这具体取决于编码数据时相应的C指针的值。如果该指针非空,那就使用第一种编码格式(8个字节),否则使用第二种编码格式(4个字节的0)。当一个可选数据在代码中是通过指针来访问时,这是编码该数据的较为便利的方式。
使得前两个声明产生同样的编码格式的一个实现上的细节是TRUE的值为1,它恰好是只有一个元素的可变长度数组的长度。
图16-24给出了由rpcgen为这个说明书文件产生的.h文件。
14~21 尽管所有三个参数都将由XDR运行时系统编码成同样的格式,在C代码中设置和取出它们的值的方法却各不相同。
图16-25是一个简单的程序,它设置上述所有三个参数的值,使得其编码中没有一个long类型的值出现。
图16-24 由rpcgen给图16-23产生的C头文件
图16-25 使三个参数都不编码long类型值的程序
设置各个值
12~14 把对应第一个参数的联合中的判别式设置为FALSE,把对应第二个参数的可变长度数组的长度设置为0,把对应第三个参数的指针设置为NULL。
分配适当对齐过的缓冲区并编码
15~19 分配一个缓冲区,把我们的out结构编码到一个XDR内存流中。
输出XDR缓冲区
20~22 使用ntohl函数(网络到主机长整数转换函数)把对应于该内存流的缓冲区中的数据从XDR使用的大端字节序转换成当前主机的字节序,然后每个4字节值一次地输出。
该输出准确地展示了由XDR运行时系统编码到该缓冲区中的数据。
solaris % opt1z
0
0
0
正如我们预期的那样,每个参数作为值全为0的4个字节编码,指示后面不跟任何值。
图16-26是对前一个程序的修改,它给所有三个参数赋值,把它们编码到一个XDR内存流中,然后输出这个流。
图16-26 给来自图16-23的所有三个参数赋值
设置各个值
12~18 为给对应第一个参数的联合赋一个值,我们把它的判别式设置成TRUE,然后设置它的值。为给对应第二个参数的可变长度数据赋一个值,我们把它的长度设置为1,并把与它关联的指针设置成指向它的值。为给对应第三个参数的指针赋一个值,我们把它设置成存放其值的变量的地址。
运行这个程序,输出的是预期的6个4字节值:
solaris % opt1
1 判别式值为TRUE
1 可变长度数组的长度
1 非空指针变量的标志
5
9876
123
16.8.4 例子:链表处理
有了前面的例子所介绍的编码可选数据的能力后,我们就可以对XDR的指针表示进行扩充,用它来编码和解码含有可变数目元素的链表。我们的例子是一个名-值对(name-value pair)链表,图16-27给出了它的XDR说明书文件。
图16-27 名-值对链表的XDR说明书文件
1~5 我们的mylist结构含有一个名-值对和一个指向下一个结构的指针。该链表中的最后一个结构将有一个值为null的next指针。
图16-28给出了由rpcgen根据图16-27产生的.h文件。
图16-28 对应于图16-27的C声明
图16-29给出的程序先初始化一个含有3个名-值对的链表,然后调用XDR运行时系统对它进行编码。
初始化链表
11~22 分配4个链表项的空间,但只初始化其中3个。第一项为nameval[2],第二项为nameval[1],第三项为nameval[0]。链表的头(out.list)设置成&nameval[2]。
以这样的顺序初始化该链表是为了展示XDR运行时系统依循指针规则,而且所编码的链表项顺序跟所用的数组元素没有关系。我们还把各个链表项的值初始化成十六进制值,因为长整数值是以十六进制输出的,这使得查看每个字节中的各个ASCII值更为容易。
图16-29 初始化一个链表,对它编码后输出结果
程序的输出表明,前三个链表项之前有一个为1的4字节值(我们既可以认为它是一个可变长度数组值为1的长度,也可以认为它是布尔值TRUE),第四个表项仅由一个为0的4字节值构成,指示链表的结尾。
solaris % opt2
1 后跟一个元素
5 字符串长度
6e616d65 n a m e
31000000 1,3个填充字节
1111 相应的值
1 后跟一个元素
6 字符串长度
6e616d65 n a m e
65320000 e 2,2个填充字节
2222 相应的值
1 后跟一个元素
7 字符串长度
6e616d65 n a m e
65653300 e e 3,1个填充字节
3333 相应的值
0 后面不跟元素:链表尾
当XDR给这种格式的链表解码时,它将给链表项和指针动态分配内存空间,并把各个指针链接起来,以允许我们在C中方便地遍历整个链表。
图16-30展示了封装在一个TCP分节中的一个RPC请求的格式。
图16-30 封装在一个TCP分节中的RPC请求
既然TCP是一个字节流,不提供消息边界,因此应用程序必须提供界定各个消息的某种方法。Sun RPC定义了既可作为请求也可作为应答的记录(record),每个记录由一个或多个片段(fragment)构成。每个片段以一个4字节值开头:其中最高位是最终片段的标志,低序31位是计数。如果最终片段标志位为0,那么构成当前记录的还有别的片段。
这个4字节值跟所有的4字节XDR整数一样,是以大端字节序传送的,但是本字段却不在标准的XDR格式中,因为XDR并不传送位字段。
如果所用的是UDP而不是TCP,那么紧跟在UDP首部之后的第一个字段是XID,如图16-32所示。
使用TCP时,RPC请求和应答的大小几乎不存在限制,因为可使用任意数目的片段,而每个片段又有一个31位的长度字段。然而使用UDP时,请求和应答都必须适合单个UDP数据报,而一个数据报能容纳的最大数据量是65507字节(假设网络协议为IPv4)。先于TI-RPC软件包的许多实现还把请求或应答的大小进一步限制到8192字节左右,因此如果请求或应答需要多于约8000字节的话,那就得改用TCP。
我们现在给出取自RFC 1831的一个RPC请求的真正XDR说明书。图16-30中给出的名字就出自该说明书。
enum auth_flavor {
AUTH_NONE = 0;
AUTH_SYS = 1;
AUTH_SHORT = 2
/* and more to be defined */
};
struct opaque_auth {
auth_flavor flavor;
opaque body<400>;
};
enum msg_type {
CALL = 0;
RELAY = 1
};
struct call_body {
unsigned intrpcvers; /* RPC version: must be 2 */
unsigned intprog; /* program number */
unsigned intvers; /* version number */
unsigned intproc; /* procedure number */
opaque_auth cred; /* caller's credentials */
opaque_auth verf; /* caller's verifier */
/* procedure-specific parameters start here */
};
struct rpc_msg {
unsigned int xid;
union switch (msg_type mtype){
case CALL:
call_body cbody;
case REPLY:
reply_body rbody;
} body;
};
含有凭证和验证器的可变长度不透明数据的内容取决于认证形式。对于空认证(默认形式)来说,该不透明数据的长度应为0。对于Unix认证来说,该不透明数据含有如下信息:
struct authsys_parms {
unsigned int stamp;
string machinename<255>;
unsigned int uid;
unsigned int gid;
unsigned int gids<16>;
};
当凭证的认证形式为AUTH_SYS时,验证器的认证形式应为AUTH_NONE。
RPC应答的格式比请求的格式复杂,因为请求中可能发生错误。图16-31展示了RPC应答的各种可能。
图16-31 可能的RPC应答
图16-32展示了一个成功的RPC应答的格式,不过这一次封装在一个UDP数据报中。
图16-32 封装为一个UDP数据报的成功的RPC应答
我们现在给出取自RFC 1831的一个RPC应答的真正XDR说明书。
enum reply_stat {
MSG_ACCEPTED = 0;
MSG_DENIED = 0
};
enum accept_stat {
SUCCESS = 0, /* RPC executed successfully */
PROG_UNAVAIL = 1, /* program # unavailable */
PROG_MISMATCH = 2, /* version # unavailable */
PROC_UNAVAIL = 3, /* procedure # unavailable */
GARBAGE_ARGS = 4, /* cannot decode arguments */
SYSTEM_ERR ..5 ./.memor.allocatio.failure,etc.*/
};
struct accepted_reply {
opaque_auth verf;
union switch (accept_stat stat){
case SUCCESS:
opaque results[0]; /* procedure-specific results start here */
case PROG_MISMATCH:
struct {
unsigned int low; /* lowest version # supported */
unsigned int high; /* highest version # supported */
} mismatch_info;
default: /* PROG_UNAVAIL,PROC_UNAVAIL,GARBAGE_ARGS,SYSTEM_ERR */
void;
} reply_data;
};
union reply_body switch (reply_stat stat){
case MSG_ACCEPTED:
accepted_reply areply;
case MSG_DENIED:
rejected_reply rreply;
} reply;
如果RPC版本号有误,或者发生认证错误,服务器就可能拒绝调用请求。
enum reject_stat {
RPC_MISMATCH = 0, /* RPC version number not 2 */
AUTH_ERROR = 1 /* authentication error */
};
enum auth_stat {
AUTH_OK = 0, /* success */
/* following are failures at server end */
AUTH_BADCRED = 1, /* bad credential (seal broken)*/
AUTH_REJECTEDCRED = 2, /* client must begin new session */
AUTH_BADVERF = 3, /* bad verifier (seal broken)*/
AUTH_REJECTEDVERF = 4, /* verifier expired or replayed */
AUTH_TOOWEAK = 5, /* rejected for security reasons */
/* following are failures at client end */
AUTH_INVALIDRESP = 6, /* bogus response verifier */
AUTH_FAILED = 7 /* reason unknown */
};
union rejected_reply switch (reject_stat stat){
case RPC_MISMATCH:
struct {
unsigned int low; /* lowest RPC version # supported */
unsigned int high; /* highest RPC version # supported */
} mismatch_info;
case AUTH_ERROR:
auth_stat stat;
};
Sun RPC允许我们编写分布式应用程序,让客户运行在一台主机上,服务器运行在另一台主机上。我们首先定义了客户能够调用的服务器过程,然后编写了一个描述这些过程的参数和返回值的RPC说明书文件。我们接着编写了调用服务器过程的客户程序main函数以及服务器过程本身。客户程序的代码看起来只是简单地调用服务器过程,但在其背后,各种各样的RPC运行时例程隐藏了网络通信正在发生的事实。
rpcgen程序是构建使用RPC的应用程序的一个基本工具。它读入我们的说明书文件,产生客户程序存根和服务器程序存根,同时产生调用所需XDR运行时例程以处理所有数据转换的函数。XDR运行时系统也是构建使用RPC的应用程序过程中的一个基本部件。XDR定义了在不同的系统间交换各种数据格式的一种标准方法,这些系统可能具有不同的整数大小、不同的字节序、不同的浮点数格式等。正如我们所示的那样,XDR可独立于RPC软件包单独使用,其目的纯粹是为了以一种标准的格式交换数据,而数据的交换可以使用任意形式的真正传送数据的通信手段(例如使用套接字或XTI编写的程序、软盘、CD-ROM等)。
Sun RPC提供了自己的命名形式,这种命名使用32位程序号、32位版本号和32位过程号。运行一个RPC服务器的每台主机必须运行一个名为端口映射器(现在称为RPCBIND)的程序。RPC服务器捆绑临时的TCP和UDP端口后向端口映射器注册,从而把这些临时端口与由服务器提供的程序号和版本号关联起来。当一个RPC客户启动时,它首先跟服务器主机上的端口映射器联系以获取所需的端口号,然后跟服务器本身联系,通常情况下要么使用TCP,要么使用UDP。
默认情况下,RPC客户不提供任何认证信息,RPC服务器则处理所收到的任何客户请求。这跟我们使用套接字或XTI编写自己的客户-服务器程序一样。Sun RPC提供了另外三种认证形式:Unix认证(提供客户的主机名、用户ID和组ID)、DES认证(基于私钥和公钥加密技术)和Kerberos认证。
理解作为底层支撑的RPC软件包中的超时和重传策略对于使用RPC(或进行任何形式的网络编程)至关重要。当使用诸如TCP这样的可靠传输层时,RPC客户只需要一个总超时,因为任何丢失或重复的分组都是由传输层完全处理的。然而当使用诸如UDP这样的不可靠传输层时,除总超时外,RPC软件包还有一个重试超时。RPC客户使用事务ID来验证某个接收到的应答是所期望的应答。
任何过程调用可划归为具有正好一次语义、最多一次语义或最少一次语义。对于本地过程调用,我们通常忽略这些问题,但是对于RPC,我们必须清楚这几种语义的差异,并理解等势过程(能够不出问题地调用任意多次的过程)和非等势过程(必须只调用一次的过程)之间的差别。
Sun RPC是一个庞大的软件包,而我们只是触及了它的皮毛而已。不过有了本章中讨论的基本知识后,就能编写出完整的应用程序。rpcgen的使用隐藏了许多细节,并简化了代码编写工作。Sun的手册中把使用RPC的代码编写工作划分成多个级别——简化的接口、顶级、中间级、专家级和底级,不过这样的划分毫无意义。RPC运行时系统总共提供164个函数,划分成以下六类:
11个auth_函数(认证);
26个clnt_函数(客户方);
5个pmap_函数(端口映射器访问);
24个rpc_函数(一般性);
44个svc_函数(服务器方);
54个xdr函数(XDR转换)。
比较一下,套接字API和XTI API分别有约25个函数,门API、Posix和System V各自的消息队列API、信号量API和共享内存区API分别有少于10个的函数。处理Posix线程的函数有15个,处理Posix条件变量的函数有10个,处理Posix读写锁的函数有11个,处理fcntl记录上锁的函数只有1个。
16.1 当启动某个RPC服务器时,它向端口映射器注册其自身。如果终止该服务器(譬如说使用终端中断键),那么它的注册会有什么变化?如果发往该服务器的某个客户请求在此后某个时刻到达,那会发生什么?
16.2 假设有一个基于UDP使用RPC的客户-服务器系统,它没有服务器应答高速缓存。客户发送一个请求给服务器,但是服务器将其应答发回要花20秒。客户在15秒后超时,导致服务器过程被再次调用。
该服务器的第二个应答将发生什么?
16.3 XDR的string数据类型总是编码成在一个长度之后跟以其各个字符。如果我们想要定长的字符串,譬如说以char c[10]代替string s<10>,那么需做哪些变动?
16.4 把图16-16中string的最大大小由128改为10,然后运行write程序,发生了什么?现在把最大长度指定符从string声明中去掉,也就是说写成string vstring_arg<>,比较修改前后分别产生的data_xdr.c文件,有什么变化?
16.5 把图16-18中xdrmem_create的第三个参数(缓冲区大小)改为50,看发生了什么。
16.6 在16.5节中我们讲述了当使用UDP时可被启用的重复请求高速缓存。我们可以说TCP维护着自己的重复请求高速缓存。这具体指什么,另外这个TCP重复请求高速缓存有多大?(提示:TCP是如何检测收到重复数据的?)
16.7 给定唯一标识服务器重复请求高速缓存中每一项的那5个元素,当比较某个新的请求和高速缓存中各个项时,这5个值以怎样的顺序进行比较所需比较次数最少?
16.8 观察16.5节中使用TCP的客户-服务器系统的真正分组传递,可看到请求分节的大小为48字节,应答分节的大小为32字节(忽略IPv4首部和TCP首部)。分析这两个大小(例如如图16-30和图16-32那样)。
如果改用UDP代替TCP,那么这两个大小是多少?
16.9 在不支持线程的系统上的RPC客户能调用编译成支持线程的服务器过程吗?我们在16.2节中叙述的调用参数上的差异情况怎么样?
16.10 在图16-19的read程序中,我们分配读入文件用的缓冲区空间,而该缓冲区中含有指针vstring_arg。请问由vstring_arg所指的字符串存放在哪儿?修改该程序以验证你的假设。
16.11 Sun RPC把空过程(null procedure)定义为过程号为0的过程(这就是我们总是以1开始过程编号的原因,如图16-1所示)。另外,由rpcgen生成的每个服务器存根自动定义该过程(这一点只需查看由本章中的例子生成的任何服务器存根就能轻而易举地验证)。空过程不需要参数,也不返回东西,往往用于验证给定服务器正在运行,或者测量到服务器的往返时间。然而要是我们查看客户存根,
那么会发现rpcgen并没有给空过程产生存根。查看clnt_call函数的手册页面,并用它来对本章中给出的任意一个服务器调用空过程。
16.12 图A-2中对于使用UDP的Sun RPC来说,为什么不存在消息大小为65536的项?图A-4中对于使用UDP的Sun RPC来说,为什么不存在消息大小为16384和32768的项?
16.13 验证在图16-19中省略xdr_free调用将引入内存空间泄漏(memory leak)。在调用xdrmem_create的紧前插入以下语句:
for ( ; ; ){
再把配对的右花括弧放在调用xdr_free的紧前。运行该程序,使用ps观察它的内存空间大小。然后把配对的右花括弧改放到调用xdr_free之后,再运行该程序,观察它的内存空间大小。
[1]. Linux上门接口实现的URL已挪到了http://www.rampant.org/doors/中。
[2]. SUID是可执行文件的一个权限位。设置了该位的可执行文件运行时,相应进程的有效用户ID就设置成该文件的属主(本例子中就是root),从而可达到获取原本没有的特权之目的。典型的SUID程序是修改口令用的passwd (路径名通常为/bin/passwd或/usr/bin/passwd,注意与/etc/passwd区分开),该文件的属主为root。当普通用户执行passwd程序时,实际上暂时取得了超级用户的特权,能够修改原本只能读的/etc/passwd文件,不过程序代码控制着具体的操作,使得有恶意的用户不能胡作非为。——译者注
[3]. 此处为UNPv1第2版英文原版书节号,第3版为15.7节。——编者注
[4]. 这里涉及有关线程的不少概念。首先,我们必须区分并发(concurrency)和并行(parallelism)。一个多处理机应用程序运行时的并行度是实际达到的并行执行程度,因此受限于其进程可用的物理处理器数。该应用程序的并发度则是在处理器数无限的理想前提下所能达到的最大并行度。其次,并发既可在系统级提供,也可在用户级提供。内核通过认知一个进程内的多个线程(也称为热线程)并独立地调度它们来提供系统级并发。内核然后把这些线程复用到可用的处理器上。用户级并发则由应用程序通过用户级线程库提供。内核不认得这样的用户线程(也称为冷线程),因而必须由用户级线程库管理和调度。许多系统(包括提供门API的Solaris 2.6)实现了把两者结合起来的双并发模型:内核认得一个进程内的多个线程,线程库则支持不为内核所见的用户线程。再次,Solaris 2.x支持内核线程(kernelthread)、轻权进程(lightweight process)和用户线程(userthread)。内核线程是能够被独立地调度和派遣到某个系统处理器上运行的基本轻权对象。它不必与一个用户进程相关联,是由内核根据内部需要创建、运行或毁除以执行指定的功能的。内核线程对于应用程序不可见。轻权进程是内核支持的用户可见线程,属于热线程。它基于内核线程,实际上每个轻权进程绑定在各自的内核线程上。用户线程是由线程库实现的内核不可见的更高级对象,属于冷线程。这是用户直接看到的线程。最后,一个进程内有两类用户线程;一类是绑定在某个轻权进程上的线程,一类是共享公共的轻权进程池的未绑定线程。竞用范围为PTHREAD_SCOPE_SYSTEM的线程属于绑定的线程,为PTHREAD_SCOPE_PROCESS的线程属于未绑定的线程。——译者注
[5]. 此处为UNPv1第2版英文原版书节号,第3版为12.4节。——编者注
[6]. 此处为UNPv1第2版英文原版书节号,第3版为13.5节。——编者注
[7]. 此处为UNPv1第2版英文原版书图号,第3版为图13-7。——编者注
[8]. 此处为UNPv1第2版英文原版书页码,第3版为第77~80页和第147~150页。——编者注
[9]. 此处为UNPv1第2版英文原版书页码,第3版为第28~29页。——编者注