UNIX环境高级编程 您所在的位置:网站首页 linux消息队列msgrcv UNIX环境高级编程

UNIX环境高级编程

2023-03-22 12:53| 来源: 网络整理| 查看: 265

过去,UNIX系统IPC是各种进程通信方式的统称,但是,这些通信方式中极少有能在所有UNIX系统实现中进行移植的。随着POSIX和The Open Group(以前是X/Open)标准化的推进和影响的扩大,情况已得到改善,但差别仍然存在。

UNIX系统IPC摘要

注意,虽然Single UNIX Specification(“SUS”列)要求的是半双工管道,但允许实现支持全双工管道。即使应用程序在编写时假定基础操作系统只支持半双工管道,支持全双工管道的实现也能用这种应用程序正常工作。图中使用“(全)”表示用全双工管道支持半双工管道的实现。

在图中,我们在支持基本功能的位置处标注了一个黑点。对于全双工管道,如果该特征是经由UNIX域套接字(UNIX domain socket)支持的,则在相应列中标注“UDS”。某些实现用管道和UNIX域套接字来支持该特征,所以这些位置上标有“•、UDS”。

图中前10种IPC形式通常限于同一台主机的两个进程之间的IPC。最后两行(套接字和STREAMS)是仅有的支持不同主机上两个进程之间IPC的两种形式。

管道

管道是UNIX系统IPC的最古老形式,所有UNIX系统都提供此种通信机制。管道有以下两种局限性。

历史上,它们是半双工的(即数据只能在一个方向上流动)。现在,某些系统提供全双工管道,但是为了最佳的可移植性,我们决不应预先假定系统支持全双工管道。管道只能在具有公共祖先的两个进程之间使用。通常,一个管道由一个进程创建,在进程调用fork之后,这个管道就能在父进程和子进程之间使用了。

FIFO没有第二种局限性,UNIX域套接字没有这两种局限性。

尽管有这两种局限性,半双工管道仍是最常用的IPC形式。每当在管道中键入一个命令序列,让 shell 执行时,shell 都会为每一条命令单独创建一个进程,然后用管道将前一条命令进程的标准输出与后一条命令的标准输入相连接。

管道是通过调用pipe函数创建的。

#include int pipe(int fd[2]); // 返回值:若成功,返回0,若出错,返回-1

经由参数 fd 返回两个文件描述符:fd[0]为读而打开,fd[1]为写而打开。fd[1]的输出是fd[0]的输入。

最初在4.3BSD和4.4BSD中,管道是用UNIX域套接字实现的。虽然UNIX域套接字默认是全双工的,但这些操作系统阻碍了用于管道的套接字,以至于这些管道只能以半双工模式操作。

POSIX.1允许实现支持全双工管道。对于这些实现,fd[0]和fd[1]以读/写方式打开。

描绘半双工管道的两种方法

上图给出了两种描绘半双工管道的方法。左图显示管道的两端在一个进程中相互连接,右图则强调数据需要通过内核在管道中流动。

fstat函数对管道的每一端都返回一个FIFO类型的文件描述符。可以用S_ISFIFO宏来测试管道。

POSIX.1规定stat结构的st_size成员对于管道是未定义的。但是当fstat函数应用于管道读端的文件描述符时,很多系统在st_size中存储管道中可用于读的字节数。但是,这是不可移植的。

单个进程中的管道几乎没有任何用处。通常,进程会先调用pipe,接着调用fork,从而创建从父进程到子进程的IPC通道。

fork之后的半双工管道

fork 之后做什么取决于我们想要的数据流的方向。对于从父进程到子进程的管道,父进程关闭管道的读端(fd[0]),子进程关闭写端(fd[1])。

从父进程到子进程的管道

对于一个从子进程到父进程的管道,父进程关闭fd[1],子进程关闭fd[0]。

当管道的一端被关闭后,下列两条规则起作用。

当读(read)一个写端已被关闭的管道时,在所有数据都被读取后,read返回0,表示文件结束。(从技术上来讲,如果管道的写端还有进程,就不会产生文件的结束。可以复制一个管道的描述符,使得有多个进程对它具有写打开文件描述符。但是,通常一个管道只有一个读进程和一个写进程。介绍FIFO时,会看到对于单个的FIFO常常有多个写进程。)如果写(write)一个读端已被关闭的管道,则产生信号SIGPIPE。如果忽略该信号或者捕捉该信号并从其处理程序返回,则write返回−1,errno设置为EPIPE。

在写管道(或 FIFO)时,常量 PIPE_BUF 规定了内核的管道缓冲区大小。如果对管道调用write,而且要求写的字节数小于等于 PIPE_BUF,则此操作不会与其他进程对同一管道(或FIFO)的 write 操作交叉进行。但是,若有多个进程同时写一个管道(或 FIFO),而且我们要求写的字节数超过PIPE_BUF,那么我们所写的数据可能会与其他进程所写的数据相互交叉。用pathconf或fpathconf函数可以确定PIPE_BUF的值。

函数popen和pclose

常见的操作是创建一个连接到另一个进程的管道,然后读其输出或向其输入端发送数据,为此,标准I/O库提供了两个函数popen和pclose。这两个函数实现的操作是:创建一个管道, fork一个子进程,关闭未使用的管道端,执行一个shell运行命令,然后等待命令终止。

#include FILE *popen(const char *cmdstring, const char *type); // 返回值:若成功,返回文件指针;若出错,返回NULL int pclose(FILE *fp); // 返回值:若成功,返回cmdstring的终止状态;若出错,返回-1

函数popen先执行fork,然后调用exec执行cmdstring,并且返回一个标准I/O文件指针。如果type是"r",则文件指针连接到cmdstring的标准输出。如果type是"w",则文件指针连接到cmdstring的标准输入。

pclose函数关闭标准I/O流,等待命令终止,然后返回shell的终止状态。如果 shell 不能被执行,则pclose返回的终止状态与shell已执行exit(127)一样。

cmdstring由Bourne shell以下列方式执行:

sh -c cmdstring

这表示shell将扩展cmdstring中的任何特殊字符。例如,可以使用:

fp = popen("ls *.c" , "r");

或者

fp = popen("cmd 2>&1" , "r");协同进程

UNIX系统过滤程序从标准输入读取数据,向标准输出写数据。几个过滤程序通常在shell管道中线性连接。当一个过滤程序既产生某个过滤程序的输入,又读取该过滤程序的输出时,它就变成了协同进程(coprocess)。

协同进程通常在shell的后台运行,其标准输入和标准输出通过管道连接到另一个程序。虽然初始化一个协同进程,并将其输入和输出连接到另一个进程的shell语法是十分奇特的,但是协同进程的工作方式在C程序中也是非常有用的。

popen 只提供连接到另一个进程的标准输入或标准输出的一个单向管道,而协同进程则有连接到另一个进程的两个单向管道:一个接到其标准输入,另一个则来自其标准输出。我们想将数据写到其标准输入,经其处理后,再从其标准输出读取数据。

通过写协同进程的标准输入和读取它的标准输出来驱动协同进程FIFO

FIFO有时被称为命名管道。未命名的管道只能在两个相关的进程之间使用,而且这两个相关的进程还要有一个共同的创建了它们的祖先进程。但是,通过FIFO,不相关的进程也能交换数据。

FIFO是一种文件类型。通过stat结构的st_mode成员的编码可以知道文件是否是FIFO类型。可以用S_ISFIFO宏对此进行测试。

创建FIFO类似于创建文件。确实,FIFO的路径名存在于文件系统中。

#include int mkfifo(const char *path, mode_t mode); int mkfifoat(int fd, const char *path, mode_t mode); // 两个函数的返回值:若成功,返回0;若出错,返回−1

mkfifo函数中mode参数的规格说明与open函数中mode的相同。

mkfifoat函数和mkfifo函数相似,但是mkfifoat函数可以被用来在fd文件描述符表示的目录相关的位置创建一个FIFO。像其他*at函数一样,这里有3种情形:

如果path参数指定的是绝对路径名,则fd参数会被忽略掉,并且mkfifoat函数的行为和mkfifo类似。如果path参数指定的是相对路径名,则fd参数是一个打开目录的有效文件描述符,路径名和目录有关。如果path参数指定的是相对路径名,并且fd参数有一个特殊值AT_FDCWD,则路径名以当前目录开始,mkfifoat和mkfifo类似。

当我们用mkfifo或者mkfifoat创建FIFO时,要用open来打开它。确实,正常的文件I/O函数(如close、read、write和unlink)都需要FIFO。

当open一个FIFO时,非阻塞标志(O_NONBLOCK)会产生下列影响。

在一般情况下(没有指定O_NONBLOCK),只读 open要阻塞到某个其他进程为写而打开这个FIFO为止。类似地,只写open要阻塞到某个其他进程为读而打开它为止。如果指定了 O_NONBLOCK,则只读 open 立即返回。但是,如果没有进程为读而打开一个FIFO,那么只写open将返回−1,并将errno设置成ENXIO。

类似于管道,若 write 一个尚无进程为读而打开的 FIFO,则产生信号 SIGPIPE。若某个FIFO的最后一个写进程关闭了该FIFO,则将为该FIFO的读进程产生一个文件结束标志。

一个给定的 FIFO 有多个写进程是常见的。这就意味着,如果不希望多个进程所写的数据交叉,则必须考虑原子写操作。和管道一样,常量PIPE_BUF说明了可被原子地写到FIFO的最大数据量。

FIFO有以下两种用途。

shell命令使用FIFO将数据从一条管道传送到另一条时,无需创建中间临时文件。客户进程-服务器进程应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程二者之间传递数据。

FIFO可用于复制一系列sell命令中的输出流。这就防止了将数据写向中间磁盘文件(类似于使用管道来避免中间磁盘文件)。但是不同的是,管道只能用于两个进程之间的线性连接,而FIFO是有名字的,因此它可用于非线性连接。

XSI IPC

有3种称作XSI IPC的IPC:消息队列、信号量以及共享存储器。

标识符和键

每个内核中的 IPC 结构(消息队列、信号量或共享存储段)都用一个非负整数的标识符(identifier)加以引用。例如,要向一个消息队列发送消息或者从一个消息队列取消息,只需要知道其队列标识符。与文件描述符不同,IPC标识符不是小的整数。当一个IPC结构被创建,然后又被删除时,与这种结构相关的标识符连续加1,直至达到一个整型数的最大正值,然后又回转到0。

标识符是IPC对象的内部名。为使多个合作进程能够在同一IPC对象上汇聚,需要提供一个外部命名方案。为此,每个IPC对象都与一个键(key)相关联,将这个键作为该对象的外部名。

无论何时创建IPC结构(通过调用msgget、semget或shmget创建),都应指定一个键。这个键的数据类型是基本系统数据类型key_t,通常在头文件中被定义为长整型。这个键由内核变换成标识符。

有多种方法使客户进程和服务器进程在同一IPC结构上汇聚。

服务器进程可以指定键IPC_PRIVATE创建一个新IPC结构,将返回的标识符存放在某处(如一个文件)以便客户进程取用。键IPC_PRIVATE保证服务器进程创建一个新IPC结构。这种技术的缺点是:文件系统操作需要服务器进程将整型标识符写到文件中,此后客户进程又要读这个文件取得此标识符。IPC_PRIVATE键也可用于父进程子关系。父进程指定IPC_PRIVATE创建一个新IPC结构,所返回的标识符可供fork后的子进程使用。接着,子进程又可将此标识符作为exec函数的一个参数传给一个新程序。可以在一个公用头文件中定义一个客户进程和服务器进程都认可的键。然后服务器进程指定此键创建一个新的IPC结构。这种方法的问题是该键可能已与一个IPC结构相结合,在此情况下,get函数(msgget、semget或shmget)出错返回。服务器进程必须处理这一错误,删除已存在的IPC结构,然后试着再创建它。 客户进程和服务器进程认同一个路径名和项目ID(项目ID是0~255之间的字符值),接着,调用函数ftok将这两个值变换为一个键。然后在方法2中使用此键。ftok提供的唯一服务就是由一个路径名和项目ID产生一个键。#include key_t ftok(const char *path, int id); // 返回值:若成功,返回键;若出错,返回(key_t)−1

path参数必须引用一个现有的文件。当产生键时,只使用id参数的低8位。

ftok创建的键通常是用下列方式构成的:按给定的路径名取得其stat结构中的部分st_dev和st_ino字段,然后再将它们与项目ID组合起来。如果两个路径名引用的是两个不同的文件,那么ftok通常会为这两个路径名返回不同的键。但是,因为i节点编号和键通常都存放在长整型中,所以创建键时可能会丢失信息。这意味着,对于不同文件的两个路径名,如果使用同一项目ID,那么可能产生相同的键。

3个get函数(msgget、semget和shmget)都有两个类似的参数:一个key和一个整型flag。在创建新的IPC结构(通常由服务器进程创建)时,如果key是IPC_PRIVATE或者和当前某种类型的IPC结构无关,则需要指明flag的IPC_CREAT标志位。为了引用一个现有队列(通常由客户进程创建),key必须等于队列创建时指明的key的值,并且IPC_CREAT必须不被指明。

注意,决不能指定 IPC_PRIVATE 作为键来引用一个现有队列,因为这个特殊的键值总是用于创建一个新队列。为了引用一个用 IPC_PRIVATE 键创建的现有队列,一定要知道这个相关的标识符,然后在其他 IPC 调用中(如 msgsnd、msgrcv)使用该标识符,这样可以绕过get函数。

如果希望创建一个新的IPC结构,而且要确保没有引用具有同一标识符的一个现有IPC结构,那么必须在flag中同时指定IPC_CREAT和IPC_EXCL位。这样做了以后,如果IPC结构已经存在就会造成出错,返回EEXIST(这与指定了O_CREAT和O_EXCL标志的open相类似)。

权限结构

XSI IPC为每一个IPC结构关联了一个ipc_perm结构。该结构规定了权限和所有者,它至少包括下列成员:

struct ipc_perm { uid_t uid; /* owner's effective user id */ gid_t gid; /* owner's effective group id */ uid_t cuid; /* creator's effective user id */ gid_t cgid; /* creator's effective group id */ mode_t mode; /* access modes */ };

在创建IPC结构时,对所有字段都赋初值。以后,可以调用msgctl、semctl或shmctl修改uid、gid和mode字段。为了修改这些值,调用进程必须是IPC结构的创建者或超级用户。修改这些字段类似于对文件调用chown和chmod。

mode字段的值类似于文件权限中的值,但是对于任何IPC结构都不存在执行权限。另外,消息队列和共享存储使用术语“读”和“写”,而信号量则用术语“读”和“更改”(alter)。

XSI IPC权限结构限制

所有3种形式的XSI IPC都有内置限制。大多数限制可以通过重新配置内核来改变。在对这3种形式的IPC中的每一种进行描述时,我们都会指出它的限制。

在报告和修改限制方面,每种平台都有自己的方法。FreeBSD 8.0、Linux 3.2.0和Mac OS X 10.6.8提供了sysctl命令来观察和修改内核配置参数。在Solaris 10中,可以用prctl命令来改变内核IPC的限制。

在Linux中,可以运行ipcs –l来显示IPC相关的限制。在FreeBSD中,等效的命令是ipcs-T。在Solaris中,可以通过运行sysdef –y来找到可调节参数。

优点和缺点

XSI IPC 的一个基本问题是:IPC 结构是在系统范围内起作用的,没有引用计数。例如,如果进程创建了一个消息队列,并且在该队列中放入了几则消息,然后终止,那么该消息队列及其内容不会被删除。它们会一直留在系统中直至发生下列动作为止:由某个进程调用 msgrcv 或msgctl读消息或删除消息队列;或某个进程执行ipcrm(1)命令删除消息队列;或正在自举的系统删除消息队列。将此与管道相比,当最后一个引用管道的进程终止时,管道就被完全地删除了。对于FIFO而言,在最后一个引用FIFO的进程终止时,虽然FIFO的名字仍保留在系统中,直至被显式地删除,但是留在FIFO中的数据已被删除了。

XSI IPC的另一个问题是:这些IPC结构在文件系统中没有名字。我们不能用文件系统部分所述的函数来访问它们或修改它们的属性。为了支持这些IPC对象,内核中增加了十几个全新的系统调用(msgget、semop、shmat等)。我们不能用ls命令查看IPC对象,不能用rm命令删除它们,也不能用chmod命令修改它们的访问权限。于是,又增加了两个新命令ipcs(1)和ipcrm(1)。

因为这些形式的 IPC 不使用文件描述符,所以不能对它们使用多路转接 I/O 函数(select和poll)。这使得它很难一次使用一个以上这样的IPC结构,或者在文件或设备I/O中使用这样的IPC结构。例如,如果没有某种形式的忙等循环(busy-wait loop),就不能使一个服务器进程等待将要放在两个消息队列中任意一个中的消息。

消息队列的其他优点是:它们是可靠的、流控制的以及面向记录的;它们可以用非先进先出次序处理。

不同形式IPC之间的特征比较

“无连接”指的是无需先调用某种形式的打开函数就能发送消息的能力。如前所述,因为需要有某种技术来获得队列标识符,所以我们并不认为消息队列是无连接的。因为所有这些形式的IPC 被限制在一台主机上,所以它们都是可靠的。当消息通过网络传送时,就要考虑丢失消息的可能性。“流控制”的意思是:如果系统资源(缓冲区)短缺,或者如果接收进程不能再接收更多消息,则发送进程就要休眠。当流控制条件消失时,发送进程应自动唤醒。

图中没有显示的一个特征是:IPC 设施能否自动地为每个客户进程创建一个到服务器进程的唯一连接。UNIX流套接字可以提供这种能力。

消息队列

消息队列是消息的链接表,存储在内核中,由消息队列标识符标识。

msgget 用于创建一个新队列或打开一个现有队列。msgsnd 将新消息添加到队列尾端。每个消息包含一个正的长整型类型的字段、一个非负的长度以及实际数据字节数(对应于长度),所有这些都在将消息添加到队列时,传送给 msgsnd。msgrcv 用于从队列中取消息。我们并不一定要以先进先出次序取消息,也可以按消息的类型字段取消息。

每个队列都有一个msqid_ds结构与其相关联:

struct msqid_ds { struct ipc_perm   msg_perm;     /* see Section 15.6.2 */ msgqnum_t      msg_qnum;     /* # of messages on queue */ msglen_t       msg_qbytes;    /* max # of bytes on queue */ pid_t        msg_lspid;    /* pid of last msgsnd() */ pid_t        msg_lrpid;    /* pid of last msgrcv() */ time_t        msg_stime;    /* last-msgsnd() time */ time_t        msg_rtime;    /* last-msgrcv() time */ time_t        msg_ctime;    /* last-change time */ };

调用的第一个函数通常是msgget,其功能是打开一个现有队列或创建一个新队列。

#include int msgget(key_t key, int flag); // 返回值:若成功,返回消息队列ID;若出错,返回−1

在创建新队列时,要初始化msqid_ds结构的下列成员。

ipc_perm结构中的mode成员按flag中的相应权限位设置。msg_qnum、msg_lspid、msg_lrpid、msg_stime和msg_rtime都设置为0。msg_ctime设置为当前时间。msg_qbytes设置为系统限制值。

若执行成功,msgget返回非负队列ID。此后,该值就可被用于其他3个消息队列函数。

msgctl函数对队列执行多种操作。它和另外两个与信号量及共享存储有关的函数(semctl和shmctl)都是XSI IPC的类似于ioctl的函数(亦即垃圾桶函数)。

#include int msgctl(int msqid, int cmd, struct msqid_ds *buf); // 返回值:若成功,返回0;若出错,返回−1

cmd参数指定对msqid指定的队列要执行的命令。

IPC_STAT 取此队列的msqid_ds结构,并将它存放在buf指向的结构中。IPC_SET 将字段 msg_perm.uid、msg_perm.gid、msg_perm.mode 和 msg_qbytes从buf指向的结构复制到与这个队列相关的msqid_ds结构中。此命令只能由下列两种进程执行:一种是其有效用户ID等于msg_perm.cuid或msg_perm.uid,另一种是具有超级用户特权的进程。只有超级用户才能增加msg_qbytes的值。IPC_RMID 从系统中删除该消息队列以及仍在该队列中的所有数据。这种删除立即生效。仍在使用这一消息队列的其他进程在它们下一次试图对此队列进行操作时,将得到EIDRM错误。此命令只能由下列两种进程执行:一种是其有效用户ID等于msg_perm.cuid或msg_perm.uid;另一种是具有超级用户特权的进程。

这3条命令(IPC_STAT、IPC_SET和IPC_RMID)也可用于信号量和共享存储。

调用msgsnd将数据放到消息队列中。

#include int msgsnd(int msqid, const void *ptr, size_t nbytes, int flag); // 返回值:若成功,返回0;若出错,返回−1

正如前面提及的,每个消息都由3部分组成:一个正的长整型类型的字段、一个非负的长度(nbytes)以及实际数据字节数(对应于长度)。消息总是放在队列尾端。

ptr参数指向一个长整型数,它包含了正的整型消息类型,其后紧接着的是消息数据(若nbytes是0,则无消息数据)。若发送的最长消息是512字节的,则可定义下列结构:

struct mymesg { long mtype;    /* positive message type */ char mtext[512]; /* message data, of length nbytes */ };

ptr就是一个指向mymesg结构的指针。接收者可以使用消息类型以非先进先出的次序取消息。

参数flag的值可以指定为IPC_NOWAIT。这类似于文件I/O的非阻塞I/O标志。若消息队列已满(或者是队列中的消息总数等于系统限制值,或队列中的字节总数等于系统限制值),则指定IPC_NOWAIT使得msgsnd立即出错返回EAGAIN。如果没有指定IPC_NOWAIT,则进程会一直阻塞到:有空间可以容纳要发送的消息;或者从系统中删除了此队列;或者捕捉到一个信号,并从信号处理程序返回。在第二种情况下,会返回EIDRM错误(“标识符被删除”)。最后一种情况则返回EINTR错误。

注意,对删除消息队列的处理不是很完善。因为每个消息队列没有维护引用计数器(打开文件有这种计数器),所以在队列被删除以后,仍在使用这一队列的进程在下次对队列进行操作时会出错返回。信号量机构也以同样方式处理其删除。相反,删除一个文件时,要等到使用该文件的最后一个进程关闭了它的文件描述符以后,才能删除文件中的内容。

当msgsnd返回成功时,消息队列相关的msqid_ds结构会随之更新,表明调用的进程ID (msg_lspid)、调用的时间(msg_stime)以及队列中新增的消息(msg_qnum)。

msgrcv从队列中取用消息。

#include ssize_t msgrcv(int msqid, void *ptr, size_t nbytes, long type, int flag); // 返回值:若成功,返回消息数据部分的长度;若出错,返回-1

和msgsnd一样,ptr参数指向一个长整型数(其中存储的是返回的消息类型),其后跟随的是存储实际消息数据的缓冲区。nbytes 指定数据缓冲区的长度。若返回的消息长度大于 nbytes,而且在flag中设置了MSG_NOERROR位,则该消息会被截断(在这种情况下,没有通知告诉我们消息截断了,消息被截去的部分被丢弃)。如果没有设置这一标志,而消息又太长,则出错返回E2BIG(消息仍留在队列中)。

参数type可以指定想要哪一种消息。

type == 0 返回队列中的第一个消息。type > 0 返回队列中消息类型为type的第一个消息。type < 0 返回队列中消息类型值小于等于 type 绝对值的消息,如果这种消息有若干个,则取类型值最小的消息。

type值非0用于以非先进先出次序读消息。例如,若应用程序对消息赋予优先权,那么type就可以是优先权值。如果一个消息队列由多个客户进程和一个服务器进程使用,那么type字段可以用来包含客户进程的进程ID(只要进程ID可以存放在长整型中)。

可以将flag值指定为IPC_NOWAIT,使操作不阻塞,这样,如果没有所指定类型的消息可用,则msgrcv返回−1,error设置为ENOMSG。如果没有指定IPC_NOWAIT,则进程会一直阻塞到有了指定类型的消息可用,或者从系统中删除了此队列(返回−1,error设置为EIDRM),或 者捕捉到一个信号并从信号处理程序返回(这会导致msgrcv返回−1,errno设置为EINTR)。

msgrcv成功执行时,内核会更新与该消息队列相关联的msgid_ds结构,以指示调用者的进程ID(msg_lrpid)和调用时间(msg_rtime),并指示队列中的消息数减少了1个(msg_qnum)。

信号量

信号量与已经介绍过的 IPC 机构(管道、FIFO 以及消息列队)不同。它是一个计数器,用于为多个进程提供对共享数据对象的访问。

为了获得共享资源,进程需要执行下列操作。

测试控制该资源的信号量。若此信号量的值为正,则进程可以使用该资源。在这种情况下,进程会将信号量值减1,表示它使用了一个资源单位。否则,若此信号量的值为 0,则进程进入休眠状态,直至信号量值大于 0。进程被唤醒后,它返回至步骤(1)。

当进程不再使用由一个信号量控制的共享资源时,该信号量值增 1。如果有进程正在休眠等待此信号量,则唤醒它们。

为了正确地实现信号量,信号量值的测试及减1操作应当是原子操作。为此,信号量通常是在内核中实现的。

常用的信号量形式被称为二元信号量(binary semaphore)。它控制单个资源,其初始值为1。但是,一般而言,信号量的初值可以是任意一个正值,该值表明有多少个共享资源单位可供共享应用。

遗憾的是,XSI信号量与此相比要复杂得多。以下3种特性造成了这种不必要的复杂性。

信号量并非是单个非负值,而必需定义为含有一个或多个信号量值的集合。当创建信号量时,要指定集合中信号量值的数量。信号量的创建(semget)是独立于它的初始化(semctl)的。这是一个致命的缺点,因为不能原子地创建一个信号量集合,并且对该集合中的各个信号量值赋初值。即使没有进程正在使用各种形式的XSI IPC,它们仍然是存在的。有的程序在终止时并没有释放已经分配给它的信号量,所以我们不得不为这种程序担心。后面将要说明的 undo 功能就是处理这种情况的。

内核为每个信号量集合维护着一个semid_ds结构:

struct semid_ds { struct ipc_perm sem_perm;  /* see Section 15.6.2 */ unsigned short sem_nsems; /* # of semaphores in set */ time_t      sem_otime; /* last-semop() time */ time_t      sem_ctime; /* last-change time */ };

每个信号量由一个无名结构表示,它至少包含下列成员:

struct { unsigned short semval;   /* semaphore value, always >= 0 */ pid_t      sempid;   /* pid for last operation */ unsigned short semncnt;   /* # processes awaiting semval>curval */ unsigned short semzcnt;   /* # processes awaiting semval==0 */ };

当我们想使用XSI信号量时,首先需要通过调用函数semget来获得一个信号量ID。

#include int semget(key_t key, int nsems, int flag); // 返回值:若成功,返回信号量ID;若出错,返回−1

创建一个新集合时,要对semid_ds结构的下列成员赋初值。

初始化ipc_perm结构。该结构中的mode成员被设置为flag中的相应权限位。sem_otime设置为0。sem_ctime设置为当前时间。sem_nsems设置为nsems。

nsems是该集合中的信号量数。如果是创建新集合(一般在服务器进程中),则必须指定nsems。如果是引用现有集合(一个客户进程),则将nsems指定为0。

semctl函数包含了多种信号量操作。

#include int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);

第4个参数是可选的,是否使用取决于所请求的命令,如果使用该参数,则其类型是semun,它是多个命令特定参数的联合(union):

union semun { int       val;  /* for SETVAL */ struct semid_ds *buf; /* for IPC_STAT and IPC_SET */ unsigned short *array; /* for GETALL and SETALL */ };

注意,这个选项参数是一个联合,而非指向联合的指针。

cmd参数指定下列10种命令中的一种,这些命令是运行在semid指定的信号量集合上的。其中有5种命令是针对一个特定的信号量值的,它们用semnum指定该信号量集合中的一个成员。semnum值在0和nsems−1之间,包括0和nsems−1。

IPC_STAT 对此集合取semid_ds结构,并存储在由arg.buf指向的结构中。IPC_SET 按arg.buf指向的结构中的值,设置与此集合相关的结构中的sem_perm.uid、sem_perm.gid和sem_perm.mode字段。此命令只能由两种进程执行:一种是其有效用户ID等于sem_perm.cuid或sem_perm.uid的进程;另一种是具有超级用户特权的进程。IPC_RMID 从系统中删除该信号量集合。这种删除是立即发生的。删除时仍在使用此信号量集合的其他进程,在它们下次试图对此信号量集合进行操作时,将出错返回EIDRM。此命令只能由两种进程执行:一种是其有效用户ID等于sem_perm.cuid或sem_perm.uid的进程;另一种是具有超级用户特权的进程。GETVAL 返回成员semnum的semval值。SETVAL 设置成员semnum的semval值。该值由arg.val指定。GETPID 返回成员semnum的sempid值。GETNCNT 返回成员semnum的semncnt值。GETZCNT 返回成员semnum的semzcnt值。GETALL 取该集合中所有的信号量值。这些值存储在arg.array指向的数组中。SETALL 将该集合中所有的信号量值设置成arg.array指向的数组中的值。

对于除GETALL以外的所有GET命令,semctl函数都返回相应值。对于其他命令,若成功则返回值为0,若出错,则设置errno并返回−1。

函数semop自动执行信号量集合上的操作数组。

#include int semop(int semid, struct sembuf semoparray[], size_t nops); // 返回值:若成功,返回0;若出错,返回−1

参数semoparray是一个指针,它指向一个由sembuf结构表示的信号量操作数组:

struct sembuf { unsigned short   sem_num;   /* member # in set (0, 1, ..., nsems-1) */ short        sem_op;    /* operation(negative, 0,or pasitive) */ short        sem_flg;   /* IPC_NOWAIT, SEM_UNDO */ };

参数nops规定该数组中操作的数量(元素数)。

对集合中每个成员的操作由相应的 sem_op 值规定。此值可以是负值、0或正值。(信号量的“undo”标志对应于相应的sem_flg成员的SEM_UNDO位。)

最易于处理的情况是 sem_op 为正值。这对应于进程释放的占用的资源数。sem_op 值会加到信号量的值上。如果指定了undo标志,则也从该进程的此信号量调整值中减去sem_op。若sem_op为负值,则表示要获取由该信号量控制的资源。

如若该信号量的值大于等于 sem_op 的绝对值(具有所需的资源),则从信号量值中减去 sem_op的绝对值。这能保证信号量的结果值大于等于0。如果指定了 undo 标志,则 sem_op 的绝对值也加到该进程的此信号量调整值上。

如果信号量值小于sem_op的绝对值(资源不能满足要求),则适用下列条件。

若指定了IPC_NOWAIT,则semop出错返回EAGAIN。若未指定IPC_NOWAIT,则该信号量的semncnt值加1(因为调用进程将进入休眠状态),然后调用进程被挂起直至下列事件之一发生。此信号量值变成大于等于sem_op的绝对值(即某个进程已释放了某些资源)。此信号量的semncnt值减1(因为已结束等待),并且从信号量值中减去sem_op的绝对值。如果指定了undo标志,则sem_op的绝对值也加到该进程的此信号量调整值上。从系统中删除了此信号量。在这种情况下,函数出错返回EIDRM。进程捕捉到一个信号,并从信号处理程序返回,在这种情况下,此信号量的semncnt值减1(因为调用进程不再等待),并且函数出错返回EINTR。若sem_op为0,这表示调用进程希望等待到该信号量值变成0。

如果信号量值当前是0,则此函数立即返回。

如果信号量值非0,则适用下列条件。

若指定了 IPC_NOWAIT,则出错返回EAGAIN。若未指定 IPC_NOWAIT,则该信号量的 semzcnt 值加 1(因为调用进程将进入休眠状态),然后调用进程被挂起,直至下列的一个事件发生。此信号量值变成0。此信号量的semzcnt值减1(因为调用进程已结束等待)。从系统中删除了此信号量。在这种情况下,函数出错返回EIDRM。进程捕捉到一个信号,并从信号处理程序返回。在这种情况下,此信号量的semzcnt值减1(因为调用进程不再等待),并且函数出错返回EINTR。

semop函数具有原子性,它或者执行数组中的所有操作,或者一个也不做。

如果在进程终止时,它占用了经由信号量分配的资源,那么就会成为一个问题。无论何时只要为信号量操作指定了SEM_UNDO标志,然后分配资源(sem_op值小于0),那么内核就会记住对于该特定信号量,分配给调用进程多少资源(sem_op的绝对值)。当该进程终止时,不论自愿或者不自愿,内核都将检验该进程是否还有尚未处理的信号量调整值,如果有,则按调整值对相应信号量值进行处理。

如果用带SETVAL或SETALL命令的semctl设置一个信号量的值,则在所有进程中,该信号量的调整值都将设置为0。

如果在多个进程间共享一个资源,则可使用这3种技术中的一种来协调访问。我们可以使用映射到两个进程地址空间中的信号量、记录锁或者互斥量

若使用信号量,则先创建一个包含一个成员的信号量集合,然后将该信号量值初始化为 1。为了分配资源,以 sem_op 为−1调用 semop。为了释放资源,以sem_op为+1调用semop。对每个操作都指定SEM_UNDO,以处理在未释放资源条件下进程终止的情况。若使用记录锁,则先创建一个空文件,并且用该文件的第一个字节(无需存在)作为锁字节。为了分配资源,先对该字节获得一个写锁。释放该资源时,则对该字节解锁。记录锁的性质确保了当一个锁的持有者进程终止时,内核会自动释放该锁。若使用互斥量,需要所有的进程将相同的文件映射到它们的地址空间里,并且使用 PTHREAD_PROCESS_SHARED互斥量属性在文件的相同偏移处初始化互斥量。为了分配资源,我们对互斥量加锁。为了释放锁,我们解锁互斥量。如果一个进程没有释放互斥量而终止,恢复将是非常困难的,除非我们使用鲁棒互斥量(pthread_mutex_consistent函数)。

在Linux上,记录锁比信号量快,但是共享存储中的互斥量的性能比信号量和记录锁的都要优越。如果我们能单一资源加锁,并且不需要XSI信号量的所有花哨功能,那么记录锁将比信号量要好。原因是它使用起来更简单、速度更快(在这个平台上),当进程终止时系统会管理遗留下来的锁。尽管对于Linux来说,在共享存储中使用互斥量是一个更快的选择,但是我们依然喜欢使用记录锁,除非要特别考虑性能。这样做有两个原因。首先,在多个进程间共享的内存中使用互斥量来恢复一个终止的进程更难。其次,进程共享的互斥量属性还没有得到普遍支持。

共享存储

共享存储允许两个或多个进程共享一个给定的存储区。因为数据不需要在客户进程和服务器进程之间复制,所以这是最快的一种 IPC。使用共享存储时要掌握的唯一窍门是,在多个进程之间同步访问一个给定的存储区。若服务器进程正在将数据放入共享存储区,则在它做完这一操作之前,客户进程不应当去取这些数据。通常,信号量用于同步共享存储访问。(不过正如前节最后部分所述,也可以用记录锁或互斥量。)

我们已经看到了共享存储的一种形式,就是在多个进程将同一个文件映射到它们的地址空间的时候。XSI 共享存储和内存映射的文件的不同之处在于,前者没有相关的文件。XSI 共享存储段是内存的匿名段。

内核为每个共享存储段维护着一个结构,该结构至少要为每个共享存储段包含以下成员:

struct shmid_ds { struct ipc_perm shm_perm; /* see Section 15.6.2 */ size_t     shm_segsz;  /* size of segment in bytes */ pid_t      shm_lpid;  /* pid of last shmop() */ pid_t      shm_cpid;  /* pid of creator */ shmatt_t    shm_nattch; /* number of current attaches */ time_t     shm_atime;  /* last-attach time */ time_t     shm_dtime;  /* last-detach time */ time_t     shm_ctime;  /* last-change time */┇ };

(按照支持共享存储段的需要,每种实现会增加其他结构成员。)

shmatt_t类型定义为无符号整型,它至少与unsigned short一样大。

调用的第一个函数通常是shmget,它获得一个共享存储标识符。

#include int shmget(key_t key, size_t size, int flag); // 返回值:若成功,返回共享存储ID;若出错,返回−1

当创建一个新段时,初始化shmid_ds结构的下列成员。

ipc_perm结构初始化。该结构中的mode按flag中的相应权限位设置。shm_lpid、shm_nattch、shm_atime和shm_dtime都设置为0。shm_ctime设置为当前时间。shm_segsz设置为请求的size。

参数size是该共享存储段的长度,以字节为单位。实现通常将其向上取为系统页长的整倍数。但是,若应用指定的size值并非系统页长的整倍数,那么最后一页的余下部分是不可使用的。如果正在创建一个新段(通常在服务器进程中),则必须指定其size。如果正在引用一个现存的段(一个客户进程),则将size指定为0。当创建一个新段时,段内的内容初始化为0。

shmctl函数对共享存储段执行多种操作。

#include int shmctl(int shmid, int cmd, struct shmid_ds *buf); // 返回值:若成功,返回0;若出错,返回−1

cmd参数指定下列5种命令中的一种,使其在shmid指定的段上执行。

IPC_STAT 取此段的shmid_ds结构,并将它存储在由buf指向的结构中。IPC_SET 按buf指向的结构中的值设置与此共享存储段相关的shmid_ds 结构中的下列3个字段:shm_perm.uid、shm_perm.gid和shm_perm.mode。此命令只能由下列两种进程执行:一种是其有效用户ID等于shm_perm.cuid或shm_perm.uid的进程;另一种是具有超级用户特权的进程。IPC_RMID 从系统中删除该共享存储段。因为每个共享存储段维护着一个连接计数(shmid_ds结构中的shm_nattch字段),所以除非使用该段的最后一个进程终止或与该段分离,否则不会实际上删除该存储段。不管此段是否仍在使用,该段标识符都会被立即删除,所以不能再用 shmat 与该段连接。此命令只能由下列两种进程执行:一种是其有效用户 ID 等于 shm_perm.cuid 或shm_perm.uid的进程;另一种是具有超级用户特权的进程。

Linux和Solaris提供了另外两种命令,但它们并非Single UNIX Specification的组成部分。

SHM_LOCK 在内存中对共享存储段加锁。此命令只能由超级用户执行。SHM_UNLOCK 解锁共享存储段。此命令只能由超级用户执行。

一旦创建了一个共享存储段,进程就可调用shmat将其连接到它的地址空间中。

#include void *shmat(int shmid, const void *addr, int flag); // 返回值:若成功,返回指向共享存储段的指针;若出错,返回-1

共享存储段连接到调用进程的哪个地址上与addr参数以及flag中是否指定SHM_RND位有关。

如果addr为0,则此段连接到由内核选择的第一个可用地址上。这是推荐的使用方式。如果addr非0,并且没有指定SHM_RND,则此段连接到addr所指定的地址上。如果addr非0,并且指定了SHM_RND,则此段连接到(addr−(addr mod SHMLBA))所表示的地址上。

SHM_RND命令的意思是“取整”。SHMLBA的意思是“低边界地址倍数”,它总是2的乘方。该算式是将地址向下取最近1个SHMLBA的倍数。

除非只计划在一种硬件上运行应用程序(这在当今是不大可能的),否则不应指定共享存储段所连接到的地址。而是应当指定addr为0,以便由系统选择地址。

如果在flag中指定了SHM_RDONLY位,则以只读方式连接此段,否则以读写方式连接此段。

shmat的返回值是该段所连接的实际地址,如果出错则返回−1。如果shmat成功执行,那么内核将使与该共享存储段相关的shmid_ds结构中的shm_nattch计数器值加1

当对共享存储段的操作已经结束时,则调用 shmdt 与该段分离。注意,这并不从系统中删除其标识符以及其相关的数据结构。该标识符仍然存在,直至某个进程(一般是服务器进程)带IPC_RMID命令的调用shmctl特地删除它为止。

#include int shmdt(const void *addr); // 返回值:若成功,返回0;若出错,返回-1

addr参数是以前调用shmat时的返回值。如果成功,shmdt将使相关shmid_ds结构中的shm_nattch计数器值减1。

在基于Intel的Linux系统上的存储区布局

在读设备/dev/zero时,该设备是0字节的无限资源。它也接收写向它的任何数据,但又忽略这些数据。我们对此设备作为 IPC 的兴趣在于,当对其进行存储映射时,它具有一些特殊性质。

创建一个未命名的存储区,其长度是mmap的第二个参数,将其向上取整为系统的最近页长。存储区都初始化为0。如果多个进程的共同祖先进程对mmap指定了MAP_SHARED标志,则这些进程可共享此存储区。

很多实现提供了一种类似于/dev/zero 的设施,称为匿名存储映射。为了使用这种功能,要在调用mmap时指定MAP_ANON标志,并将文件描述符指定为−1。结果得到的区域是匿名的(因为它并不通过一个文件描述符与一个路径名相结合),并且创建了一个可与后代进程共享的存储区。

注意,Linux 为此设备定义了 MAP_ANONYMOUS标志,并将MAP_ANON标志定义为与它相同的值以改善应用的可移植性。

如果在两个无关进程之间要使用共享存储段,那么有两种替代的方法。一种是应用程序使用XSI共享存储函数,另一种是使用mmap将同一文件映射至它们的地址空间,为此使用MAP_SHARED标志。

POSIX信号量

POSIX信号量接口意在解决XSI信号量接口的几个缺陷。

相比于XSI接口,POSIX信号量接口考虑到了更高性能的实现。POSIX 信号量接口使用更简单:没有信号量集,在熟悉的文件系统操作后一些接口被模式化了。POSIX信号量在删除时表现更完美。当一个XSI信号量被删除时,使用这个信号量标识符的操作会失败,并将errno设置成EIDRM。使用POSIX信号量时,操作能继续正常工作直到该信号量的最后一次引用被释放。

POSIX信号量有两种形式:命名的和未命名的。它们的差异在于创建和销毁的形式上,但其他工作一样。未命名信号量只存在于内存中,并要求能使用信号量的进程必须可以访问内存。这意味着它们只能应用在同一进程中的线程,或者不同进程中已经映射相同内存内容到它们的地址空间中的线程。相反,命名信号量可以通过名字访问,因此可以被任何已知它们名字的进程中的线程使用。

我们可以调用sem_open函数来创建一个新的命名信号量或者使用一个现有信号量。

#include sem_t *sem_open(const char *name, int oflag, ... /* mode_t mode, unsigned int value */ ); // 返回值:若成功,返回指向信号量的指针;若出错,返回SEM_FAILED

当使用一个现有的命名信号量时,我们仅仅指定两个参数:信号量的名字和 oflag 参数的 0值。当这个oflag参数有O_CREAT标志集时,如果命名信号量不存在,则创建一个新的。如果它已经存在,则会被使用,但是不会有额外的初始化发生。

当我们指定O_CREAT标志时,需要提供两个额外的参数。mode参数指定谁可以访问信号量。mode的取值和打开文件的权限位相同:用户读、用户写、用户执行、组读、组写、组执行、其他读、其他写和其他执行。赋值给信号量的权限可以被调用者的文件创建屏蔽字修改。注意,只有读和写访问要紧,但是当我们打开一个现有信号量时接口不允许指定模式。实现经常为读和写打开信号量。

在创建信号量时,value参数用来指定信号量的初始值。它的取值是0~SEM_VALUE_MAX。

如果我们想确保创建的是信号量,可以设置oflag参数为O_CREAT|O_EXCL。如果信号量已经存在,会导致sem_open失败。

为了增加可移植性,在选择信号量命名时必须遵循一定的规则。

名字的第一个字符应该为斜杠(/)。尽管没有要求POSIX信号量的实现要使用文件系统,但是如果使用了文件系统,我们就要在名字被解释时消除二义性。名字不应包含其他斜杠以此避免实现定义的行为。例如,如果文件系统被使用了,那么名字/mysem和//mysem会被认定为是同一个文件名,但是如果实现没有使用文件系统,那么这两种命名可以被认为是不同的(考虑下如果实现把名字哈希运算转换成一个用来识别信号量的整数值会发生什么)。信号量名字的最大长度是实现定义的。名字不应该长于_POSIX_NAME_MAX个字符长度。因为这是使用文件系统的实现能允许的最大名字长度的限制。

如果想在信号量上进行操作,sem_open函数会为我们返回一个信号量指针,用于传递到其他信号量函数上。当完成信号量操作时,可以调用sem_close函数来释放任何信号量相关的资源。

#include int sem_close(sem_t *sem); // 返回值:若成功,返回0;若出错,返回-1

如果进程没有首先调用sem_close而退出,那么内核将自动关闭任何打开的信号量。注意,这不会影响信号量值的状态—如果已经对它进行了增1操作,这并不会仅因为退出而改变。类似地,如果调用sem_close,信号量值也不会受到影响。在XSI信号量中没有类似SEM_UNDO标志的机制。

可以使用sem_unlink函数来销毁一个命名信号量。

#include int sem_unlink(const char *name); // 返回值:若成功,返回0;若出错,返回-1

sem_unlink函数删除信号量的名字。如果没有打开的信号量引用,则该信号量会被销毁。否则,销毁将延迟到最后一个打开的引用关闭。

不像XSI信号量,我们只能通过一个函数调用来调节POSIX信号量的值。计数减1和对一个二进制信号量加锁或者获取计数信号量的相关资源是相类似的。

注意,信号量和POSIX信号量之间是没有差别的。是采用二进制信号量还是用计数信号量取决于如何初始化和使用信号量。如果一个信号量只是有值 0 或者 1,那么它就是二进制信号量。当二进制信号量是1时,它就是“解锁的”,如果它的值是0,那就是“加锁的”。

可以使用sem_wait或者sem_trywait函数来实现信号量的减1操作。

#include int sem_trywait(sem_t *sem); int sem_wait(sem_t *sem); // 两个函数的返回值:若成功,返回0;若出错则,返回−1

使用sem_wait函数时,如果信号量计数是0就会发生阻塞。直到成功使信号量减1或者被信号中断时才返回。可以使用sem_trywait函数来避免阻塞。调用sem_trywait时,如果信号量是0,则不会阻塞,而是会返回−1并且将errno置为EAGAIN。

第三个选择是阻塞一段确定的时间。为此,可以使用sem_timewait函数。

#include #include int sem_timedwait(sem_t *restrict sem, const struct timespec *restrict tsptr); // 返回值:若成功,返回0;若出错,返回−1

想要放弃等待信号量的时候,可以用tsptr参数指定绝对时间。超时是基于CLOCK_REALTIME时钟的。如果信号量可以立即减1,那么超时值就不重要了,尽管指定的可能是过去的某个时间,信号量的减 1 操作依然会成功。如果超时到期并且信号量计数没能减 1, sem_timedwait将返回-1且将errno设置为ETIMEDOUT。

可以调用sem_post函数使信号量值增1。这和解锁一个二进制信号量或者释放一个计数信号量相关的资源的过程是类似的。

#include int sem_post(sem_t *sem); // 返回值:若成功,返回0;若出错,返回−1

调用sem_post时,如果在调用sem_wait(或者sem_timedwait)中发生进程阻塞,那么进程会被唤醒并且被sem_post增1的信号量计数会再次被sem_wait(或者sem_timedwait)减1。

当我们想在单个进程中使用POSIX信号量时,使用未命名信号量更容易。这仅仅改变创建和销毁信号量的方式。可以调用sem_init函数来创建一个未命名的信号量。

#include int sem_init(sem_t *sem, int pshared, unsigned int value); // 返回值:若成功,返回0;若出错,返回−1

pshared参数表明是否在多个进程中使用信号量。如果是,将其设置成一个非0值。value参数指定了信号量的初始值。

需要声明一个sem_t类型的变量并把它的地址传递给sem_init来实现初始化,而不是像sem_open函数那样返回一个指向信号量的指针。如果要在两个进程之间使用信号量,需要确保sem参数指向两个进程之间共享的内存范围。

对未命名信号量的使用已经完成时,可以调用sem_destroy函数丢弃它。

#include int sem_destroy(sem_t *sem); // 返回值:若成功,返回0;若出错,返回−1

调用sem_destroy后,不能再使用任何带有 sem 的信号量函数,除非通过调用 sem_init重新初始化它。

sem_getvalue函数可以用来检索信号量值。

#include int sem_getvalue(sem_t *restrict sem, int *restrict valp); // 返回值:若成功,返回0;若出错,返回−1

成功后,valp指向的整数值将包含信号量值。但是请注意,我们试图要使用我们刚读出来的值的时候,信号量的值可能已经变了。除非使用额外的同步机制来避免这种竞争,否则 sem_getvalue函数只能用于调试。

客户进度-服务端进程属性

下面详细说明客户进程和服务器进程的某些属性,这些属性受到它们之间所使用的各种 IPC类型的影响。

最简单的关系类型是使客户进程 fork 然后 exec 所希望的服务器进程。如下图所示,在 fork之前先创建两个半双工管道使数据可在两个方向传输。所执行的服务器进程可能是一个设置用户 ID 的程序,这使它具有了特权。另外,服务器进程查看客户进程的实际用户ID就可以决定客户进程的真实身份。(回忆8.10节,从中可了解到在exec前后实际用户ID和实际组ID并没有改变。)

通过写协同进程的标准输入和读取它的标准输出来驱动协同进程

在这种安排下,可以构建一个open服务器进程(open server)。(17.5节提供了这种客户进程-服务器进程机制的一种实现。)它为客户进程打开文件而不是客户进程自己调用 open 函数。这样就可以在正常的UNIX用户权限、组权限以及其他权限之上或之外,增加附加的权限检查。假定服务器进程执行的是设置用户ID程序,这给予了它附加的权限(很可能是root权限)。服务器进程用客户进程的实际用户 ID 来决定是否给予它对所请求文件的访问权限。使用这种方式,可以构建一个服务器进程,它允许某些用户获得通常没有的访问权限。

在此例子中,因为服务器进程是父进程的子进程,所以它所能做的就是将文件内容传送给父进程。尽管这种方式对普通文件工作得很好,但是对有些文件却不能工作,如特殊设备文件。我们希望能做的是使服务器进程打开所要求的文件,并传回文件描述符。但是实际情况却是父进程可向子进程传送打开文件描述符,而子进程却不能向父进程传回文件描述符(除非使用专门的编程技术,这将在第17章介绍)。

下图展示了另一种类型的服务器进程。这种服务器进程是一个守护进程,所有客户进程用某种形式的 IPC 与其联系。对于这种形式的客户进程-服务器进程关系,不能使用管道。需要使用一种形式的命名IPC,如FIFO或消息队列。使用FIFO时,如果服务器进程必需将数据送回客户进程,则对每个客户进程都要有单独使用的 FIFO。如果客户进程-服务器进程应用程序只有客户进程向服务器进程发送数据,则只需要一个众所周知的FIFO。(System V行式打印机假脱机程序使用这种形式的客户进程-服务器进程。客户进程是 lp(1)命令,服务器进程是 lpsched守护进程。因为只有从客户进程到服务器进程的数据流,所有只需使用一个FIFO。没有需要送回客户进程的数据。)

用FIFO进行客户进程-服务器进程通信

使用消息队列则存在多种可能性。

在服务器进程和所有客户进程之间只使用一个队列,使用每个消息的类型字段指明谁是消息的接受者。例如,客户进程可以用设置为1的类型字段来发送它们的消息。在请求之中应包括客户进程的进程ID。此后,服务器进程在发送响应消息时,将类型字段设置为客户进程的进程ID。服务器进程只接受类型字段为1的消息(msgrcv的第4个参数),客户进程则只接受类型字段等于它们进程ID的消息。另一种方法是每个客户进程使用一个单独的消息队列。在向服务器进程发送第一个请求之前,每个客户进程先使用键IPC_PRIVATE创建它自己的消息队列。服务器进程也有它自己的队列,其键或标识符是所有客户进程都知道的。客户进程将其第一个请求发送到服务器进程的众所周知的队列上,该请求中应包含其客户进程消息队列的队列ID。服务器进程将其第一个响应发送到此客户进程队列,此后的所有请求和响应都在此队列上交换。

使用消息队列的这两种技术都可以用共享内存段和同步方法(信号量或记录锁)来实现。

使用这种类型的客户进程-服务器进程关系(客户进程和服务器进程是无关进程)的问题是服务器进程如何准确地标识客户进程。除非服务器进程正在执行一种非特权操作,否则服务器进程知道客户进程的身份是很重要的。例如,若服务器进程是一个设置用户 ID 程序,就有这种要求。虽然所有这几种形式的IPC都经由内核,但是它们并未提供任何设施使内核能够标识发送者。

对于消息队列,如果在客户进程和服务器进程之间使用一个专用队列(于是一次只有一个消息在该队列上),那么队列的 msg_lspid 包含了对方进程的进程 ID。但是当客户进程将请求发送给服务器进程时,我们想要的是客户进程的有效用户 ID,而不是它的进程 ID。现在还没有一种可移植的方法,在已知进程ID情况下可以得到有效用户ID。(自然地,内核在进程表项中保持有这两种值,但是除非彻底检查内核存储空间,否则已知一个,无法得到另一个。)

我们将在17.2节中使用下列技术,使服务器进程可以标识客户进程。这一技术可使用FIFO、消息队列、信号量以及共享存储。在下面的说明中假定使用了FIFO。客户进程必须创建它自己的FIFO,并且设置该FIFO的文件访问权限,使得只允许用户读和用户写。假定服务器进程具有超级用户特权(或者它很可能并不关心客户进程的真实标识),那么服务器进程仍可读、写此FIFO。当服务器进程在众所周知的FIFO上接收到客户进程的第一个请求时(它应当包含客户进程专用FIFO的标识),服务器进程调用针对客户进程专用FIFO的stat或fstat。服务器进程假设:客户进程的有效用户ID是FIFO的所有者(stat结构的st_uid字段)。服务器进程验证该FIFO只有用户读和用户写权限。服务器进程还应检查与该 FIFO 有关的 3 个时间量(stat 结构的 st_atime、st_mtime和st_ctime字段),要检查它们与当前时间是否很接近(如不早于当前时间15秒或30秒)。如果一个恶意客户进程可以创建一个FIFO,使另一个用户成为其所有者,并且设置该文件的权限位为用户读和用户写,那么在系统中就存在了其他基础性的安全问题。

为了用XSI IPC实现这种技术,回想一下与每个消息队列、信号量以及共享存储段相关的ipc_perm结构,它标识了IPC结构的创建者(cuid和cgid字段)。和使用FIFO的实例一样,服务器进程应当要求客户进程创建该IPC结构,并使客户进程将访问权设置为只允许用户读和用户写。服务器进程也应检验与该IPC相关的时间值与当前时间是否很接近(因为这些IPC结构在显式地删除之前一直存在)。

在17.3节中,将会看到进行这种身份验证的一种更好的方法,就是内核提供客户进程的有效用户ID和有效组ID。套接字子系统在两个进程之间传送文件描述符时可以做到这一点。

小结

经过分别对消息队列与全双工管道的时间以及信号量与记录锁的时间进行比较,提出了下列建议:要学会使用管道和FIFO,因为这两种基本技术仍可有效地应用于大量的应用程序。在新的应用程序中,要尽可能避免使用消息队列以及信号量,而应当考虑全双工管道和记录锁,它们使用起来会简单得多。共享存储仍然有它的用途,虽然通过mmap函数也能提供同样的功能。

习题

15.1 在图15-6的程序中,在父进程代码的末尾删除waitpid前的close,结果将如何?

答:如果父进程的写通道一直不关闭,则子进程的分页程序一直无法读到EOF,子进程会一直阻塞在读标准输入中。

15.2 在图15-6的程序中,在父进程代码的末尾删除waitpid,结果将如何?

答:父进程向管道写完最后一行以后就终止,当父进程终止时管道的读端自动关闭。但是由于子进程(分页程序)要等待输出的页,所以父进程可能比子进程领先一个管道缓冲区。如果正在运行的是一个可对命令行进行编辑的交互式shell,如Korn shell,那么当父进程终止时,shell 多半会改变终端的模式并打印一个提示。这个无疑会影响已经对终端模式进行修改的分页程序(由于大部分分页程序在等待处理下一个页面时将终端置为非正规模式)。

15.3 如果 popen 函数的参数是一个不存在的命令,会造成什么结果?编写一段小程序对此进行测试。

答:p15_3.c。

15.4 在图15-18 的程序中,删除信号处理程序,执行该程序,然后终止子进程。输入一行输入后,怎样才能说明父进程是由SIGPIPE终止的?

答:

[root@2010201-0062 Chapter-15]# ./a.out 1 2 [root@2010201-0062 Chapter-15]# echo $? 141 [root@2010201-0062 Chapter-15]# kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8 43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX

执行结果如上所示,父进程的退出码为141(128+信号编码),所以信号编码为13,使用kill -l打印了所有信号编码,证实了父进程是由SIGPIPE终止的。

15.5 在图15-18的程序中,用标准I/O库代替进行管道读、写的read和write。

答:p15_5.c。

15.6 POSIX.1加入waitpid函数的理由之一是,POSIX.1之前的大多数系统不能处理下面的代码。

if ( (fp = popen("/bin/true", "r")) == NULL ) ... if ( (rc = system("sleep 100")) == -1) ... if (pclose(fp) == -1) ...

若在这段代码中不使用waitpid函数会如何?用wait代替呢?

答:通过查看源码,popen、system、pclose函数的实现都调用了wait,如果没有使用waitpid函数,那么popen创建的子进程会在system中被获取终止状态。因为该子进程不是system创建的,所以它将再次调用wait并一直阻塞到sleep完成。然后system返回。当pclose调用wait时,由于没有子进程可等待所以返回出错,导致pclose也返回出错。

15.7 当一个管道被写者关闭后,解释 select 和 poll 是如何处理该管道的输入描述符的。为了确定答案是否正确,编两个小测试程序,一个用select,另一个用poll。当一个管道的读端被关闭时,请重做此习题以查看该管道的输出描述符。

答:暂无。

15.8 如果popen以type为"r"执行cmdstring,并将结果写到标准错误输出,结果会如何?

答:暂无。

15.9 既然popen函数能使shell执行它的cmdstring参数,那么cmdstring终止时会产生什么结果?(提示:画出与此相关的所有进程。)

答:暂无。

15.10 POSIX.1特别声明没有定义为读写而打开FIFO。虽然大多数UNIX系统允许读写FIFO,但是请用非阻塞方法实现为读写而打开FIFO。

答:暂无。

15.11 除非文件包含敏感数据或机密数据,否则允许其他用户读文件不会造成损害。但是,如果一个恶意进程读取了被一个服务器进程和几个客户进程使用的消息队列中的一条消息后,会产生什么后果?恶意进程需要知道哪些信息就可以读消息队列?

答:暂无。

15.12 编写一段程序完成下面的工作。执行一个循环5次,在每次循环中,创建一个消息队列,打印该队列的标识符,然后删除队列。接着再循环5次,在每次循环中利用键IPC_PRIVATE创建消息队列,并将一条消息放在队列中。程序终止后用 ipcs(1)查看消息队列。解释队列标识符的变化。

答:暂无。

15.13 描述如何在共享存储段中建立一个数据对象的链接列表。列表指针如何存储?

答:暂无。

15.14 画出图15-33 中的程序运行时下列值随时间变化的曲线图:父进程和子进程中的变量 i、共享存储区中的长整型值以及update函数的返回值。假设子进程在fork后先运行。

答:暂无。

15.15 使用15.9节中的XSI共享存储函数代替共享存储映射区,改写图15-33中的程序。

答:暂无。

15.16 使用15.8节中的XSI信号量函数改写图15-33中的程序,实现父进程与子进程间的交替。

答:暂无。

Tipssembuf结构的sem_flg成员为SEM_UNDO时,它将使操作系统跟踪当前进程对这个信号量的修改情况,如果这个进程在没有释放该信号量的情况下终止,操作系统将自动释放该进程持有的信号量。防止其他进程因为得不到信号量而发生死锁现象。信号量实际上是同步原语而不是 IPC,常用于共享资源(如共享存储段)的同步访问。

随书练习源码地址

https://github.com/Johncdy/BookSource/tree/main/Advanced_Programming_in_the_UNIX_Environment


【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有