前面两章讨论了 UNIX 系统提供的各种 IPC,其中包括管道和套接字。本章介绍一种高级IPC—UNIX域套接字机制,并说明它的应用方法。这种形式的IPC可以在同一计算机系统上运行的两个进程之间传送打开文件描述符。服务进程可以使它们的打开文件描述符与指定的名字相关联,同一系统上运行的客户进程可以使用这些名字与服务器进程汇聚。我们还会了解到操作系统如何为每一个客户进程提供一个独用的IPC通道。
UNIX 域套接字用于在同一台计算机上运行的进程之间的通信。虽然因特网域套接字可用于同一目的,但 UNIX 域套接字的效率更高。UNIX 域套接字仅仅复制数据,它们并不执行协议处理,不需要添加或删除网络报头,无需计算校验和,不要产生顺序号,无需发送确认报文。
UNIX 域套接字提供流和数据报两种接口。UNIX 域数据报服务是可靠的,既不会丢失报文也不会传递出错。UNIX 域套接字就像是套接字和管道的混合。可以使用它们面向网络的域套接字接口或者使用socketpair函数来创建一对无命名的、相互连接的UNIX域套接字。
#include <sys/socket.h>
int socketpair(int domain, int type, int protocol, int sockfd[2]);
返回值:若成功,返回0;若出错,返回-1
虽然接口足够通用,允许socketpair用于其他域,但一般来说操作系统仅对UNIX域提供支持。
一对相互连接的UNIX域套接字可以起到全双工管道的作用:两端对读和写开放(见图17-1)。我们将其称为 fd 管道(fd-pipe),以便与普通的半双工管道区分开来。
图17-1 套接字对
实例:fd_pipe函数
图17-2展示了fd_pipe函数,它使用socketpair函数来创建一对相互连接的UNIX域流套接字。
图17-2 创建一个全双工管道
某些基于BSD的系统使用UNIX域套接字来实现管道。但当调用pipe时,第一描述符的写端和第二描述符的读端都是关闭的。为了得到全双工管道,必须直接调用socketpair。
实例:借助UNIX域套接字轮询XSI消息队列
15.6.4节曾经提到XSI消息队列的使用存在一个问题,即不能将它们和poll或者select一起使用,这是因为它们不能关联到文件描述符。然而,套接字是和文件描述符相关联的,消息到达时,可以用套接字来通知。对每个消息队列使用一个线程。每个线程都会在msgrcv调用中阻塞。当消息到达时,线程会把它写入一个UNIX域套接字的一端。当poll指示套接字可以读取数据时,应用程序会使用这个套接字的另外一端来接收这个消息。
图17-3中的程序说明了这个技术。main函数中创建了一些消息队列和UNIX域套接字,并为每个消息队列开启了一个新线程。然后它在一个无限循环中用poll来轮询选择一个套接字端点。当某个套接字可读时,程序可以从套接字中读取数据并把消息打印到标准输出上。
图17-3 使用UNIX域套接字轮询XSI消息队列
注意,我们使用的是数据报(SOCK_DGRAM)套接字而不是流套接字。这样做可以保持消息边界,以保证从套接字里一次只读取一条消息。
这种技术可以(非直接地)在消息队列中运用poll或者select。只要为每个队列分配一个线程的开销以及每个消息额外复制两次(一次写入套接字,另一次从套接字里读取出来)的开销是可接受的,这种技术就会使XSI消息队列的使用更加容易。
使用图17-4中所示的程序给图17-3中所示的测试程序发送消息。
图17-4 给XSI消息队列发送消息
这个程序需要两个参数:消息队列关联的键值以及一个包含消息主体的字符串。发送消息到服务器端时,它会打印如下信息:
$ ./pollmsg & 在后台运行服务器[1] 12814
$ queue ID 0 is 196608
queue ID 1 is 196609
queue ID 2 is 196610
$ ./sendmsg 0x123 "hello, world" 给第一个队列发送一条消息
queue id 196608, message hello, world
$ ./sendmsg 0x124 "just a test" 给第二个队列发送一条消息
queue id 196609, message just a test
$ ./sendmsg 0x125 "bye" 给第三个队列发送一条消息
queue id 196610, message bye
命名UNIX域套接字
虽然 socketpair 函数能创建一对相互连接的套接字,但是每一个套接字都没有名字。这意味着无关进程不能使用它们。
在16.3.4节中学习了如何将一个地址绑定到一个因特网域套接字上。恰如因特网域套接字一样,可以命名UNIX域套接字,并可将其用于告示服务。但是要注意,UNIX域套接字使用的地址格式不同于因特网域套接字。
回忆 16.3 节,套接字地址格式会随实现而变。UNIX 域套接字的地址由sockaddr_un结构表示。在Linux 3.2.0和Solaris 10中,sockaddr_un结构在头文件<sys/un.h>中的定义如下:
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* pathname */
};
但是在FreeBSD 8.0和Mac OS X 10.6.8中,sockaddr_un结构的定义如下:
struct sockaddr_un {
unsigned char sun_len; /* sockaddr length */
sa_family_t sun_family; /* AF_UNIX */
char sun_path[104]; /* pathname */
};
sockaddr_un结构的sun_path成员包含一个路径名。当我们将一个地址绑定到一个UNIX域套接字时,系统会用该路径名创建一个S_IFSOCK类型的文件。
该文件仅用于向客户进程告示套接字名字。该文件无法打开,也不能由应用程序用于通信。
如果我们试图绑定同一地址时,该文件已经存在,那么bind请求会失败。当关闭套接字时,并不自动删除该文件,所以必须确保在应用程序退出前,对该文件执行解除链接操作。
实例
图17-5所示的程序是一个将地址绑定到UNIX域套接字的例子。
运行此程序时,bind 请求成功执行。但是,若第二次运行该程序,则出错返回,其原因是该文件已经存在。在删除该文件之前,该程序不会再成功运行。
$ ./a.out 运行该程序
UNIX domain socket bound
$ ls -l foo.socket 查看套接字文件
srwxrw-xr-x 1 sar 0 May 18 00:44 foo.socket
$ ./a.out 试图再次运行该程序
bind failed: Address already in use
$ rm foo.socket 刪除该套接字文件
$ ./a.out 第三次运行该程序
UNIX domain socket bound 现在成功啦
图17-5 将地址绑定到UNIX域套接字
确定绑定地址长度的方法是,先计算sun_path成员在sockaddr_un结构中的偏移量,然后将结果与路径名长度(不包括终止null字符)相加。因为sockaddr_un结构中sun_path之前的成员与实现相关,所以我们使用<stddef.h>头文件(包括在apue.h中)中的offsetof宏计算sun_path成员从结构开始处的偏移量。如果查看<stddef.h>,则可见到类似于下列形式的定义:
#define offsetof(TYPE, MEMBER) ((int)&((TYPE *)0)->MEMBER)
假定该结构从地址0开始,此表达式求得成员起始地址的整型值。
服务器进程可以使用标准bind、listen和accept函数,为客户进程安排一个唯一UNIX域连接。客户进程使用connect与服务器进程联系。在服务器进程接受了connect请求后,在服务器进程和客户进程之间就存在了唯一连接。这种风格的操作与我们在图16-16和图16-17中所示的对因特网域套接字的操作相同。
图17-6展示了客户进程和服务器进程存在连接之前二者的情形。服务器端把它的套接字绑定到sockaddr_un的地址并监听新的连接请求。图17-7展示了在服务器端接受客户端连接请求后,客户端和服务器端之间建立的唯一的连接。
现在,我们将开发3个函数,使用这些函数可以在运行于同一台计算机上的两个无关进程之间创建唯一连接。这些函数模仿了在 16.4 节中讨论过的面向连接的套接字函数。这里,我们将UNIX域套接字应用于底层通信机制。
图17-6 connect之前的客户端
套接字和服务器端套接字
图17-7 connect之后的客户端
套接字和服务器端套接字
#include "apue.h"
int serv_listen(const char *name);
返回值:若成功,返回要监听的文件描述符;若出错,返回负值
int serv_accept(int listenfd, uid_t *uidptr);
int cli_conn(const char *name);
返回值:若成功,返回新文件描述符;若出错,返回负值
返回值:若成功,返回文件描述符;若出错,返回负值
服务器进程可以调用serv_listen函数(见图17-8)声明它要在一个众所周知的名字(文件系统中的某个路径名)上监听客户进程的连接请求。当客户进程想要连接至服务器进程时,它们将使用该名字。serv_listen函数的返回值是用于接收客户进程连接请求的服务器UNIX域套接字。
服务器进程可以使用serv_accept函数(见图17-9)等待客户进程连接请求的到达。当一个请求到达时,系统自动创建一个新的UNIX域套接字,并将它与客户端套接字连接,最后将这个新套接字返回给服务器。此外,客户进程的有效用户ID存放在uidptr指向的存储区中。
客户进程调用cli_conn函数(见图17-10)连接至服务器进程。客户进程指定的name参数必须与服务器进程调用serv_listen函数时所用的名字相同。函数返回时,客户进程得到接连至服务器进程的文件描述符。
图17-8给出了serv_listen函数。
图17-8 serv_listen函数
首先,调用socket创建一个UNIX域套接字。然后将欲赋给套接字的众所周知的路径名填入sockaddr_un结构。该结构是调用bind的参数。注意,不需要设置某些平台提供的sun_len字段,因为操作系统会用传送给bind函数的地址长度设置该字段。
最后,调用listen函数(见16.4节)来通知内核该进程将作为服务器进程等待客户进程的连接请求。当收到一个客户进程的连接请求后,服务器进程调用serv_accept函数(见图17-9)。
图17-9 serv_accept函数
服务器进程在调用serv_accept中阻塞,等待一个客户进程调用cli_conn。从accept返回时,返回值是连接到客户进程的崭新的描述符。另外,accept函数也经由其第二个参数(指向sockaddr_un结构的指针)返回客户进程赋给其套接字的路径名(包含客户进程ID的名字)。接着,程序复制这个路径名,并确保它是以null终止的(如果路径名占用了sockaddr_un结构里的sun_path成员所有的可用空间,那就没有空间存放终止null字符)。然后,调用stat函数验证:该路径名确实是一个套接字;其权限仅允许用户读、用户写以及用户执行。还要验证与套接字相关联的3个时间参数不比当前时间早30秒。(回忆6.10节,time函数返回当前时间和日期,用公元1970年1月1日00:00:00以来经过的秒数表示。)
如若通过了所有这些检验,则可认为客户进程的身份(其有效用户ID)是该套接字的所有者。虽然这种检验并不完善,但这是对当前系统所能做到的最佳方案。(如若内核能通过 accept 的参数返回有效用户ID,则会更好一些。)
客户进程调用cli_conn函数(见图17-10)对连到服务器进程的连接进行初始化。
图17-10 cli_conn函数
调用 socket 函数创建 UNIX 域套接字的客户进程端,然后用客户进程专有的名字填入sockaddr_un结构。
此例中没让系统选择默认地址,其原因是,如果这样处理,服务器进程将不能区分各个客户进程(如果不为UNIX域套接字显式地绑定名字,内核会代表我们隐式地绑定一个地址且不会在文件系统创建文件来表示这个套接字)。于是,我们绑定自己的地址,但在开发使用套接字的客户端程序时通常并不采用这一步骤。
绑定的路径名的最后5 个字符来自客户进程ID。仅在该路径名已存在时调用unlink。然后,调用bind将名字赋给客户进程套接字。这在文件系统中创建了一个套接字文件,所用的名字与被绑定的路径名一样。接着,调用chmod 关闭除用户读、用户写以及用户执行以外的其他权限。在serv_accept中,服务器进程检验这些权限以及套接字用户ID以验证客户进程的身份。
然后,必须填充另一个sockaddr_un结构,这次用的是服务进程众所周知的路径名。最后,调用connect函数初始化与服务进程的连接。
在两个进程之间传送打开文件描述符的技术是非常有用的。因此可以对客户进程-服务器进程应用进行不同的设计。它使一个进程(通常是服务器进程)能够处理打开一个文件所要做的一切操作(包括将网络名翻译为网络地址、拨号调制解调器、协商文件锁等)以及向调用进程送回一个描述符,该描述符可被用于以后的所有I/O函数。涉及打开文件或设备的所有细节对客户进程而言都是透明的。
下面进一步说明从一个进程向另一个进程“传送一个打开文件描述符”的含义。回忆图 3-8,其中显示了两个进程,它们打开了同一文件。虽然它们共享同一个v节点,但每个进程都有它自己的文件表项。
当一个进程向另一个进程传送一个打开文件描述符时,我们想让发送进程和接收进程共享同一文件表项。图17-11显示了所期望的安排。
图17-11 从顶部进程传送一个打开文件至底部进程
在技术上,我们是将指向一个打开文件表项的指针从一个进程发送到另外一个进程。该指针被分配存放在接收进程的第一个可用描述符项中。(注意,不要造成错觉,以为发送进程和接收进程中的描述符编号是相同的,它们通常是不同的。)两个进程共享同一个打开文件表,这与fork之后的父进程和子进程共享打开文件表的情况完全相同(见图8-2)。
当发送进程将描述符传送给接收进程后,通常会关闭该描述符。发送进程关闭该描述符并不会真的关闭该文件或设备,其原因是该描述符仍被视为由接收进程打开(即使接收进程尚未接收到该描述符)。
下面定义本章用以发送和接收文件描述符的3个函数。本节后面会给出这3个函数的代码。
#include "apue.h"
int send_fd(int fd, int fd_to_send);
int send_err(int fd, int status, const char *errmsg);
两个函数的返回值:若成功,返回0;若出错,返回-1
int recv_fd(int fd, ssize_t (*userfunc)(int, const void *, size_t));
返回值:若成功,返回文件描述符;若出错,返回负值
当一个进程(通常是服务器进程)想将一个描述符传送给另一个进程时,可以调用send_fd或send_err。等待接收描述符的进程(客户进程)调用recv_fd。
send_fd 使用fd代表的 UNIX 域套接字发送描述符fd_to_send。send_err 使用fd发送errmsg以及后随的status字节。status的值应在-1~-255。
客户进程调用 recv_fd 接收描述符。如果一切正常(发送者调用了 send_fd),则函数返回值为非负描述符。否则,返回值是由send_err发送的status(-1~-255 的一个负值)。另外,如果服务器进程发送了一条出错消息,则客户进程调用它自己的userfunc 函数处理该消息。userfunc的第一个参数是常量 STDERR_FILENO,然后是指向出错消息的指针及其长度。userfunc函数的返回值是已写的字节数或负的出错编号值。客户进程常将普通的write函数指定为userfunc。
我们实现用于这 3 个函数的我们自己制定的协议。为发送一个描述符,send_fd先发送2字节0,然后是实际描述符。为了发送一条出错消息,send_err发送errmsg,然后是1字节0,最后是status字节的绝对值(1~255)。recv_fd函数读取套接字中所有字节直至遇到null字符。null字符之前的所有字符都传送给调用者的userfunc。recv_fd读取的下一个字节是状态(status)字节。若状态字节为0,则表示一个描述符已传送过来,否则表示没有描述符可接收。
send_err函数在将出错消息写到套接字后,即调用send_fd函数,如图17-12所示。
图17-12 send_err函数
为了用UNIX域套接字交换文件描述符,调用sendmsg(2)和recvmsg(2)函数(见16.5节)。这两个函数的参数中都有一个指向msghdr结构的指针,该结构包含了所有关于要发送或要接收的消息的信息。该结构的定义大致如下:
struct msghdr {
void *msg_name; /* optional address */
socklen_t msg_namelen; /* address size in bytes */
struct iovec *msg_iov; /* array of I/O buffers */
int msg_iovlen; /* number of elements in array */
void *msg_control; /* ancillary data */
socklen_t msg_controllen; /* number of ancillary bytes */
int msg_flags; /* flags for received message */
};
前两个元素通常用于在网络连接上发送数据报,其中目的地址可以由每个数据报指定。接下来的两个元素使我们可以指定一个由多个缓冲区构成的数组(散布读和聚集写),这与对 readv和writev函数(见14.6节)的说明一样。 msg_flags字段包含了描述接收到的消息的标志,图16-15总结了这些标志。
两个元素处理控制信息的传送和接收。msg_control字段指向cmsghdr(控制信息头)结构,msg_controllen字段包含控制信息的字节数。
struct cmsghdr {
socklen_t cmsg_len; /* data byte count, including header */
int cmsg_level; /* originating protocol */
int cmsg_type; /* protocol-specific type */
/* followed by the actual control message data */
};
为了发送文件描述符,将cmsg_len设置为cmsghdr结构的长度加一个整型的长度(描述符的长度),cmg_level字段设置为SOL_SOCKET,cmsg_type字段设置为SCM_RIGHTS,用以表明在传送访问权。(SCM是Socket-level Control Message的缩写,即套接字级控制消息。)访问权仅能通过UNIX域套接字传送。描述符紧随cmsg_type字段之后存储,用CMSG_DATA宏获得该整型量的指针。
在此定义3个宏,用于访问控制数据,一个宏用于帮助计算cmsg_len所使用的值。
#include <sys/socket.h>
unsigned char *CMSG_DATA(struct cmsghdr *cp);
返回值:返回一个指针,指向与cmsghdr结构相关联的数据
struct cmsghdr *CMSG_FIRSTHDR(struct msghdr *mp);
返回值:返回一个指针,指向与msghdr结构相关联的第一个cmsghdr结构;
若无这样的结构,返回NULL
struct cmsghdr *CMSG_NXTHDR(struct msghdr *mp,
struct cmsghdr *cp);
返回值:返回一个指针,指向与msghdr结构相关联的下一个cmsghdr结构,该msghdr结构给出了当前的cmsghdr结构;若当前cmsghdr结构已是最后一个,返回NULL
unsigned int CMSG_LEN(unsigned int nbytes);
返回值:返回为nbytes长的数据对象分配的长度
Single UNIX Specification定义了前3个宏,但没有定义CMSG_LEN。
CMSG_LEN宏返回存储nbytes长的数据对象所需的字节数,它先将nbytes加上cmsghdr结构的长度,然后按处理器体系结构的对齐要求进行调整,最后再向上取整。
图17-13中的程序是UNIX域套接字的send_fd函数,它通过UNIX域套接字传递文件描述符。sendmsg调用被用来传送协议数据(包括null字节和状态字节)以及描述符。
图17-13 通过UNIX域套接字发送文件描述符
为了接收一个文件描述符(见图17-14),我们为cmsghdr结构和描述符分配了足够大的空间,设置msg_control指向该分配到的存储区,然后调用了recvmsg。使用CMSG_LEN宏计算所需的空间总量。
读取UNIX域套接字,直至读到null字节,它位于最后的状态字节之前。null字节之前是一条来自发送者的出错消息。
图17-14 通过UNIX域套接字接收文件描述符
注意,该程序总是准备接收一个描述符(在每次调用 recvmsg 之前,设置 msg_control和msg_controllen),但是仅当msg_controllen返回的是非0值时,才确实接收到描述符。
回忆serv_accept函数(见图17-9)确定调用者身份的步骤。如果内核能够把调用者的证书在调用accept之后返回给调用处会更好。某些UNIX域套接字的实现提供类似的功能,但它们的接口不同。
FreeBSD 8.0和Linux 3.2.0都支持通过UNIX域套接字发送证书,但它们的实现方式不同。Mac OS X 10.6.8是部分从FreeBSD派生出来的,但禁止传送证书。Solaris 10不支持通过UNIX域套接字传送证书,然而它支持从一个通过STREAMS管道传输文件描述符的进程中获得证书,这里我们不讨论它的细节。
在FreeBSD中,将证书作为cmsgcred结构传送。
#define CMGROUP_MAX 16
struct cmsgcred {
pid_t cmcred_pid; /* sender's process ID */
uid_t cmcred_uid; /* sender's real UID */
uid_t cmcred_euid; /* sender's effective UID */
gid_t cmcred_gid; /* sender's real GID */
short cmcred_ngroups; /* number of groups */
gid_t cmcred_groups[CMGROUP_MAX]; /* groups */
};
在传送证书时,仅需为cmsgcred结构保留存储空间。内核将填充该结构以防止应用程序伪装成具有另一种身份。
在Linux中,将证书作为ucred结构传送。
struct ucred {
pid_t pid; /* sender's process ID */
uid_t uid; /* sender's user ID */* sender's group ID */
gid_t gid;
};
与FreeBSD不同,Linux需要在传输前初始化这个结构。内核会确保应用程序要么能够使用对应调用者的值,要么有使用其他值的合适权限。
图17-15显示了更新过后的send_fd函数,它包含了发送进程的证书。
图17-15 通过UNIX域套接字发送证书
注意,只有在Linux上才需要初始化证书结构。
图17-16中的recv_ufd函数是recv_fd的修改版,它通过一个引用参数返回发送者的用户ID。
图17-16 通过UNIX域套接字接收证书
在FreeBSD中,指定SCM_CREDS表示要传送证书。在Linux中,则使用SCM_CREDENTIALS。
使用文件描述符传送技术开发一个 open 服务器进程—一个由一个进程执行以打开一个或多个文件。该服务器进程不是将文件内容送回调用进程,而是送回一个打开文件描述符。这使该服务器进程对任何类型的文件(如设备或套接字)而不单是普通文件都能起作用。客户进程和服务器进程用IPC交换最小量的信息:从客户进程到服务器进程传送文件名和打开模式,而从服务器进程到客户进程返回描述符。文件内容不需通过IPC交换。
将服务器进程设计成一个单独的可执行程序(或者是由客户进程执行的,这正是本节所说明的;或者是由守护服务器进程执行的,将在下一节进行说明)有很多优点。
•任何客户进程都能很容易地和服务器进程联系,这类似于客户进程调用一个库函数。我们没有将特定服务硬编码在应用程序中,而是设计了一种可供重用的设施。
•如若需要更改服务器进程,那么也只影响一个程序。相反,更新一个库函数可能需要更新调用此库函数的所有程序(即用连接编辑器重新连接)。共享库函数可以简化这种更新(见7.7节)。
•服务器进程可以是一个设置用户ID 程序,于是使其具有客户进程没有的附加权限。注意,库函数(或共享库函数)不能提供这种能力。
客户进程创建一个fd管道,然后调用fork和exec来调用服务器进程。客户进程使用一端经fd管道发送请求,服务器进程使用另一端经fd管道回送响应。
定义客户进程和服务器进程间的应用程序协议如下。
(1)客户进程通过fd管道向服务器进程发送“open <pathname> <openmode>\0”形式的请求。<openmode>是数值,以ASCII十进制数表示,是open函数的第二个参数。该请求字符串以null字符终止。
(2)服务器进程调用send_fd或send_err回送打开描述符或出错消息。
这是一个进程向其父进程发送打开描述符的实例。17.6节将修改此实例来使用一个守护服务器进程,它的服务器进程将一个描述符发送给一个完全无关的进程。
首先要有一个头文件open.h(见图17-17),它包括标准头文件,并且定义了函数原型。
图17-17 open.h头文件
main函数(见图17-18)是一个循环,它先从标准输入读一个路径名,然后将该文件复制到标准输出。它调用csopen函数来联系open服务器进程,从其返回一个打开描述符。
图17-18 main函数
函数csopen(见图17-19)在创建了fd管道之后,进行了服务器进程的fork和exec操作。
图17-19 csopen函数
子进程关闭fd管道的一端,父进程关闭另一端。作为服务器进程,子进程也将fd管道的一端复制到其标准输入和标准输出。(另一种可选择的方案是,将描述符fd[1]的ASCII表示形式作为一个参数传送给服务器进程。)
父进程将包含路径名和打开模式的请求发送给服务器进程。最后,父进程调用recv_fd返回描述符或出错消息。如果服务器进程返回出错消息,那么父进程调用write,向标准错误输出该消息。
现在,让我们来看看open服务器进程。其程序是opend,由图17-19中的子进程执行。首先,要有一个opend.h头文件(见图17-20),它包括标准头文件,并且声明了全局变量和函数原型。
图17-20 opend.h头文件
main 函数(见图17-21)经fd 管道(它的标准输入)读来自客户进程的请求,然后调用函数handle_request。
图17-21 服务器进程main函数第1版
图17-22中的handle_request函数承担了全部工作。它调用函数buf_args将客户进程请求分解成标准argv型的参数表,然后调用函数cli_args处理客户进程的参数。如果一切正常,则调用open打开相应文件,接着调用send_fd,经由fd管道(它的标准输出)将描述符回送给客户进程。如果出错则调用send_err回送一则出错消息,其中使用了前面说明的客户进程-服务器进程协议。
图17-22 handle_request函数第1版
客户进程请求是一个以 null 终止的字符串,它包含由空格分隔的参数。图 17-23 中的 buf_args函数将字符串分解成标准argv型参数表,并调用用户函数处理参数。我们使用ISO C函数strtok将字符串分割成独立的参数。
图17-23 buf_args函数
buf_args调用的服务器进程函数是cli_args(见图17-24)。它验证客户进程发送的参数个数是否正确,然后将路径名和打开模式存储在全局变量中。
图17-24 cli_args函数
这样也就完成了open服务器进程,它由客户进程执行fork和exec来调用。在fork之前创建了一个fd管道,然后客户进程和服务器进程用其进行通信。在这种安排下,每个客户进程都有一个服务器进程。
在上一节中,我们开发了一个open服务器进程,由客户进程执行fork和exec调用,它说明了如何从子程序向父程序传送文件描述符。本节将开发一个守护进程方式的 open 服务器进程。一个服务器进程处理所有客户进程的请求。由于避免了使用 fork 和 exec,我们期望这个设计会更有效。在客户进程和服务器进程之间仍使用UNIX域套接字连接,并用实例说明在两个无关进程之间如何传送文件描述符。我们将使用 17.3 节引入的 3 个函数:serv_listen、serv_accept和cli_conn。这个服务器进程还将演示一个服务器进程如何处理多个客户进程,为此要用到14.4节中说明的select和poll函数。
本节所述的客户进程类似于17.5节中的客户进程。实际上,文件main.c是完全相同的(见图17-18)。我们将在open.h头文件(见图17-17)中加入下面这行:
#define CS_OPEN "/tmp/opend.socket" /* server's well-known name */
因为在此例中调用的是cli_conn而非fork和exec,所以文件open.c与图17-19中的不同。修改后如图17-25所示。
图17-25 csopen函数第2版
客户进程与服务器进程之间使用的协议仍然相同。
接下来再看服务器进程。头文件opend.h(见图17-26)包括了标准头文件,并且声明了全局变量和函数原型。
图17-26 opend.h头文件第2版
因为此服务器进程处理所有客户进程,所以它必须保存每个客户进程连接的状态。这是用在opend.h头文件中声明的client数组实现的。图17-27定义了3个处理此数组的函数。
图17-27 处理client数组的3个函数
第一次调用client_add时,它调用client_alloc,client_alloc又调用malloc为该数组的10个登记项分配空间。在这10个登记项全部用完后,如若再调用client_add,那么client_alloc函数将调用realloc来分配附加空间。依靠这种动态空间分配,我们无需在编译时将估计的数组长度值放入头文件中从而限制client数组的长度。如果出错,这些函数将调用log_函数(见附录B),因为我们假定服务器进程是守护进程。
通常服务器进程会作为守护进程运行,但我们想提供一个让其前台运行的选项,同时能够把分析信息发送到标准错误输出。这应该能使服务器更容易评测和调试,特别是当用户没有权限读取那些分析信息经常写入的日志文件时。可以使用一个命令行选项来控制服务器是否在前台运行或者作为守护进程在后台运行。
一个系统的所有命令遵循相同的约定是非常重要的,因为这会提高它的易用性。如果有人熟悉某条命令的选项风格,那么若后面的命令使用了其他的风格,他就很容易犯错。
处理命令行空格就很容易发生这样的问题。有些命令需要它的选项和其参数以空格隔开,而另一些则希望它的参数直接跟在它的选项之后。如果没有遵循一个一致的规则,用户就得记住所有命令的语法,或者在尝试和调错中调用这些命令。
Single UNIX Specification包括了一系列的约定和规范来保证命令行语法的一致性,其中包括一些建议,如“限制每个命令行选项为一个单一的阿拉伯字符”以及“所有选项必须以‘−’作为开头字符”。
幸运的是,getopt函数能够帮助命令开发者以一致的方式处理命令行选项。
#include <unistd.h>
int getopt(int argc, char * const argv[], const char *options);
extern int optind, opterr, optopt;
extern char *optarg;
返回值:若所有选项被处理完,返回-1;否则,返回下一个选项字符
参数argc和argv与传入main函数的一样。options参数是一个包含该命令支持的选项字符的字符串。如果一个选项字符后面接了一个冒号,则表示该选项需要参数;否则,该选项不需要额外参数。举例来说,如果一条命令的用法说明如下:
command [-i] [-u username] [-z] filename
则我们可以给getopt传送一个"iu:z"作为options字符串。
函数getopt一般用在循环体内,循环直到getopt返回-1时退出。每次迭代中,getopt会返回下一个选项。应用程序负责筛选这些选项,判断是否有冲突,getopt 仅负责解释选项并保证一个标准的格式。
当遇到无效的选项时,getopt返回一个问题标记(question mark)而不是这个字符。如果选项缺少参数,getopt也会返回一个问题标记,但如果选项字符串的第一个字符是冒号,getopt会直接返回冒号。而特殊的“--”格式则会导致getopt停止处理选项并返回-1。这允许用户传递以“-”开头但不是选项的参数。例如,如果有一个名字为“-bar”的文件,下面的命令行是无法删除这个文件的:
rm –bar
因为rm会试图把-bar解释为选项。正确的删除文件的命令应该是:
rm -- -bar
getopt函数支持以下4个外部变量。
optarg 如果一个选项需要参数,在处理该选项时,getopt会设置optarg指向该选项的参数字符串。
opterr 如果一个选项发生了错误,getopt会默认打印一条出错消息。应用程序可以通过设置opterr参数为0来禁止这个行为。
optind 用来存放下一个要处理的字符串在argv数组里的下标。它从1开始,每处理一个参数,getopt都会对其递增1。
optopt 如果处理选项时发生了错误,getopt会设置optopt指向导致出错的选项字符串。
open服务器进程的main函数(见图17-28)定义全局变量,处理命令行选项,并且调用loop函数。如果以-d选项调用服务器进程,则服务器进程将以交互方式运行而非守护进程方式。测试服务器进程时会用到这个选项。
图17-28 服务器进程main函数第2版
loop函数是服务器进程的无限循环。我们将给出该函数的两种版本。图17-29是使用select的一种版本。图17-30所示的程序是使用poll的另一种版本。
图17-29 使用select的loop函数
此函数调用serv_listen(见图17-8)创建服务器进程与客户进程连接的端点。此函数的其余部分是一个循环,它从 select 调用开始。在 select 返回后,可能会发生下面两种情况。
(1)描述符listenfd可以随时读取,这意味着一个新客户进程已调用了cli_conn。为了处理这种情况,我们将调用serv_accept(见图17-9),然后为新客户进程更新client数组以及与该新客户进程相关的簿记信息。(我们要跟踪 select 的第一个参数的最高描述符编号,还要跟踪使用中的client数组的最高下标。)
(2)一个现有的客户进程的连接可以随时读取。这意味着该客户进程已经终止,或者该客户进程已发送一个新请求。如果read返回0(文件结束),则表示客户进程已终止。如果read返回的值大于0,则表示有一个新请求需处理,可以调用request来处理。
用allset描述符集跟踪当前使用的描述符。当新客户进程连接至服务器进程时,会打开此描述符集的相应位。当该客户进程终止时,会关闭相应位。
因为客户进程的所有描述符都由内核自动关闭(包括与服务器进程的连接),所以我们总能知道什么时候客户进程终止了,该终止是否是自愿的。这与XSI IPC机制不同。
使用poll函数的loop函数如图17-30所示。
图17-30 使用poll的loop函数
为使打开描述符的数量能与客户进程数量相当,我们动态地为pollfd结构的数字分配空间,所使用的策略与client_alloc函数分配client数组(见图17-27)时所使用的相同。
pollfd数组中的第一个登记项(下标号为0)用于listenfd描述符。新客户进程连接的到达由listenfd描述符中的POLLIN指示。如同前述,调用serv_accept来接受该连接。
对于一个现有的客户进程,应当处理来自poll的两个不同事件:由POLLHUP指示的客户进程终止,由POLLIN指示的来自现有客户进程的一个新请求。即使连接的服务器端还在读取数据,客户端也能够关闭它这端的连接。即使连接的一端已经被标记为挂起状态,服务器仍然可以读取在它那端队列里的数据。当然,服务器在收到客户端的挂起消息时用close关闭到客户端的连接,可有效地抛弃所有队列里的数据。剩下的请求也没必要处理,因为我们已经无法发回响应的信息。
如同此函数的select版本,调用request函数(见图17-31)处理来自客户进程的新请求。此函数类似于其早期版本(见图17-22)。它调用同一函数buf_args(见图17-23),buf_args又调用cli_args(见图17-24),但是,因为它是在一个守护进程中运行的,所以它在日志文件中记录出错消息,而不是在标准错误上打印它们。
图17-31 request函数
这就完成了open服务器进程第2版,它仅使用一个守护进程就处理了所有的客户进程请求。
本章的关键点是如何在两个进程之间传送文件描述符,以及服务器进程如何接受来自客户进程的唯一连接。虽然所有平台都支持UNIX域套接字(见图15-1),但是各种实现都有不同之处,这使我们很难开发可移植的应用程序。
整章都使用了UNIX域套接字。我们了解了如何用它们来实现一个全双工的管道以及如何利用它们来适应14.4节的I/O多路转接函数以间接地用于XSI消息队列中。
本章给出了open服务器进程的两个版本。一个版本由客户进程用fork和exec直接调用,另一版本是一个守护服务器进程处理所有客户进程请求。这两个版本均采用文件描述符传送和接收函数。
我们还展示了如何使用getopt 函数来保证命令行参数处理的一致性。最终的 open 服务器进程版本使用了getopt函数、17.3节中引入的客户进程-服务器进程连接函数和14.4节中的I/O多路转接函数。
17.1 我们选择使用图17-3中的UNIX域数据报套接字,因为它们能够保留消息边界。描述如果使用常规的管道实现需要哪些必要的改动。我们应当如何避免额外的两次消息复制呢?
17.2 使用本章描述的文件描述符传送函数以及8.9节中描述的父进程和子进程同步例程,编写具有下列功能的程序。该程序调用fork,子进程打开一个现有的文件并将打开文件描述符传送给父进程。然后,子进程调用lseek确定该文件的当前读、写位置,通知父进程。父进程读该文件的当前偏移量,并打印它以便验证。若此文件按上述方式从子进程传递到父进程,则父进程和子进程应共享同一个文件表项,所以当子进程每次更改该文件当前偏移量时,这种更改应该也会影响父进程的描述符。使子进程将该文件定位至一个不同偏移量,并再次通知父进程。
17.3 图17-20和图17-21中的程序分别定义和声明了全局变量,两者的区别是什么?
17.4 改写buf_args函数(见图17-23),删除其中对argv数组长度的编译时限制。请用动态存储分配。
17.5 描述优化图17-29和图17-30中的loop函数的方法,并实现之。
17.6 在serv_listen函数(见图17-8)中,如果文件已经存在,我们要先对代表UNIX域套接字的文件名解除链接。为了防止误删除不是套接字的文件,我们可以先调用stat来验证文件类型。解释这种做法存在的两个问题。
17.7 请给出两种可能的方法,使得单次调用sendmsg可以传递多个文件描述符。尝试实现你的方法并验证你的操作系统是否支持这样的方法。