第15章 进程间通信

15.1 引言

第8章说明了进程控制原语,并且观察了如何调用多个进程。但是这些进程之间交换信息的唯一途径就是传送打开的文件,可以经由fork或exec来传送,也可以通过文件系统来传送。本章将说明进程之间相互通信的其他技术—进程间通信(InterProcess Communication,IPC)。

过去,UNIX系统IPC是各种进程通信方式的统称,但是,这些通信方式中极少有能在所有UNIX系统实现中进行移植的。随着POSIX和The Open Group(以前是X/Open)标准化的推进和影响的扩大,情况已得到改善,但差别仍然存在。图15-1摘要列出了本书讨论的4种实现所支持的不同形式的IPC。

图15-1 UNIX系统IPC摘要

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

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

IPC接口作为POSIX.1实时扩展的一部分,也是Single UNIX Specification中的选项。在SUSv4中,信号量接口从可选规范移到了基本规范中。

虽然命名全双工管道作为被挂载的基于STREAMS的管道使用,但是Single UNIX Specification将它标记成弃用的。

尽管Linux中OpenSS7项目的“Linux Fast-STREAMS”包支持STREAMS,但是这个包最近都没有更新。从2008年以来最新的包版本只到内核版本2.6.26。

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

我们将与IPC有关的讨论分成3章。本章讨论经典的IPC:管道、FIFO、消息队列、信号量以及共享存储。下一章讨论使用套接字机制的网络IPC。第17章说明IPC的某些高级特征。

15.2 管道

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

(1)历史上,它们是半双工的(即数据只能在一个方向上流动)。现在,某些系统提供全双工管道,但是为了最佳的可移植性,我们决不应预先假定系统支持全双工管道。

(2)管道只能在具有公共祖先的两个进程之间使用。通常,一个管道由一个进程创建,在进程调用fork之后,这个管道就能在父进程和子进程之间使用了。

我们将会看到FIFO(见15.5节)没有第二种局限性,UNIX域套接字(见17.2节)没有这两种局限性。

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

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

#include <unistd.h>

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]以读/写方式打开。

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

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

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

图15-2 描绘半双工管道的两种方法

单个进程中的管道几乎没有任何用处。通常,进程会先调用pipe,接着调用fork,从而创建从父进程到子进程的IPC通道,反之亦然。图15-3显示了这种情况。

图15-3 fork之后的半双工管道

fork 之后做什么取决于我们想要的数据流的方向。对于从父进程到子进程的管道,父进程关闭管道的读端(fd[0]),子进程关闭写端(fd[1])。图15-4显示了在此之后描述符的状态结果。

图15-4 从父进程到子进程的管道

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

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

(1)当读(read)一个写端已被关闭的管道时,在所有数据都被读取后,read返回0,表示文件结束。(从技术上来讲,如果管道的写端还有进程,就不会产生文件的结束。可以复制一个管道的描述符,使得有多个进程对它具有写打开文件描述符。但是,通常一个管道只有一个读进程和一个写进程。下一节介绍FIFO时,会看到对于单个的FIFO常常有多个写进程。)

(2)如果写(write)一个读端已被关闭的管道,则产生信号SIGPIPE。如果忽略该信号或者捕捉该信号并从其处理程序返回,则write返回−1,errno设置为EPIPE。

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

实例

图 15-5 程序创建了一个从父进程到子进程的管道,并且父进程经由该管道向子进程传送数据。

图15-5 经由管道从父进程向子进程传送数据

注意,这里的管道方向和图15-4中的是一致的。

在上面的例子中,直接对管道描述符调用了read和write。更有趣的是将管道描述符复制到了标准输入或标准输出上。通常,子进程会在此之后执行另一个程序,该程序或者从标准输入(已创建的管道)读数据,或者将数据写至其标准输出(该管道)。

实例

试着编写一个程序,其功能是每次一页地显示已产生的输出。已经有很多UNIX系统公用程序具有分页功能,因此无需再构造一个新的分页程序,只要调用用户最喜爱的分页程序就可以了。为了避免先将所有数据写到一个临时文件中,然后再调用系统中有关程序显示该文件,我们希望通过管道将输出直接送到分页程序。为此,先创建一个管道,fork 一个子进程,使子进程的标准输入成为管道的读端,然后调用exec,执行用的分页程序。图15-6中的程序显示了如何实现这些操作。(本例要求在命令行中有一个参数指定要显示的文件的名称。通常,这种类型的程序要求在终端上显示的数据已经在存储器中了。)

图15-6 将文件复制到分页程序

在调用fork之前,先创建一个管道。调用fork之后,父进程关闭其读端,子进程关闭其写端。然后子进程调用 dup2,使其标准输入成为管道的读端。当执行分页程序时,其标准输入将是管道的读端。

将一个描述符复制到另一个上(在子进程中,fd[0]复制到标准输入),在复制之前应当比较该描述符的值是否已经具有所希望的值。如果该描述符已经具有所希望的值,并且调用了dup2和close,那么该描述符的副本将关闭。(回忆3.12节中所述,当dup2中的两个参数值相等时的操作。)在本程序中,如果shell没有打开标准输入,那么程序开始处的fopen应已使用描述符0,也就是最小未使用的描述符,所以fd[0]决不会等于标准输入。尽管如此,无论何时调用dup2和 close 将一个描述符复制到另一个上,作为一种保护性的编程措施,都要先将两个描述符进行比较。

请注意,我们是如何尝试使用环境变量 PAGER 获得用户分页程序名称的。如果这种操作没有成功,则使用系统默认值。这是环境变量的常见用法。

实例

回忆8.9节中的5个函数:TELL_WAIT、TELL_PARENT、TELL_CHILD、WAIT_PARENT和WAIT_CHILD。图10-24中提供了一个使用信号的实现。图15-7则提供了一个使用管道的实现。

图15-7 让父进程和子进程同步的例程

如图15-8中所示,我们在调用fork之前创建了两个管道。父进程在调用TELL_CHILD时,经由上一个管道写一个字符“p”,子进程在调用TELL_PARENT时,经由下一个管道写一个字符“c”。相应的WAIT_XXX函数调用read读一个字符,没有读到字符时则阻塞(休眠等待)。

图15-8 用两个管道实现父进程和子进程同步

注意,每一个管道都有一个额外的读取进程,这没有关系。也就是说,除了子进程从pfd1[0]读取,父进程也有上一个管道的读端。因为父进程并没有执行对该管道的读操作,所以这不会影响我们。

15.3 函数popen和pclose

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

#include <stdio.h>

FILE *popen(const char *cmdstring, const char *type);

返回值:若成功,返回文件指针;若出错,返回NULL

int pclose(FILE *fp);

返回值:若成功,返回cmdstring的终止状态;若出错,返回-1

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

如果type是"w",则文件指针连接到cmdstring的标准输入,如图15-10所示。

图15-9 执行fp = popen

(cmdstring, "r")的结果

图15-10 执行fp = popen

(cmdstring, "w")的结果

有一种方法可以帮助我们记住popen的最后一个参数及其作用,这就是与fopen进行类比。如果type是"r",则返回的文件指针是可读的,如果type是"w",则是可写的。

pclose函数关闭标准I/O流,等待命令终止,然后返回shell的终止状态。(我们曾在8.6节中描述过终止状态,8.13 节描述的 system 函数也返回终止状态。)如果 shell 不能被执行,则pclose返回的终止状态与shell已执行exit(127)一样。

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

sh -c cmdstring

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

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

或者

fp = popen("cmd 2>&1" , "r");

实例

用popen重写图15-6中的程序,其结果如图15-11所示。

图15-11 用popen向分页程序传送文件

使用popen减少了需要编写的代码量。

shell命令${PAGER:-more}的意思是:如果shell变量PAGER已经定义,且其值非空,则使用其值,否则使用字符串more。

实例:函数popen和pclose

图15-12中的程序是我们编写的popen和pclose。

图15-12 popen函数和pclose函数

虽然 popen 的核心部分与本章中前面用过的代码类似,但是增加了很多需要考虑的细节。首先,每次调用popen时,应当记住所创建的子进程的进程ID,以及其文件描述符或FILE指针。我们选择在数组childpid中保存子进程ID,并用文件描述符作为其下标。于是,当以FILE指针作为参数调用pclose时,调用标准I/O函数fileno得到文件描述符,然后取得子进程ID,并用其作为参数调用waitpid。因为一个进程可能调用popen多次,所以在动态分配childpid数组时(第一次调用popen时),其数组长度应当是最大文件描述符数,于是该数组中可以存放与最大文件描述符数相同的子进程ID数。

注意,图2-17中的open_max函数可以返回打开文件的最大个数的近似值,如果这个值与系统不相关的话。注意不要使用那种其值大于(或等于)open_max函数返回值的管道文件描述符。对于 popen,如果 open_max 函数返回的值恰巧非常小,那我们会关闭管道文件描述符并将 errno设置为EMFILE,以此表明这里的很多文件描述符是打开的,最后返回−1。对于pclose,如果对应于文件指针参数的描述符比所期望的大,则将errno设置为EINVAL,并返回−1。

调用pipe和fork,然后为popen函数中的每个进程复制合适的描述符,这个过程和我们在本章前面所做的相类似。

POSIX.1要求popen关闭那些以前调用popen打开的、现在仍然在子进程中打开着的I/O流。为此,在子进程中从头逐个检查childpid数组的各个元素,关闭仍旧打开着的描述符。

若pclose的调用者已经为信号SIGCHLD设置了一个信号处理程序,则pclose中的waitpid调用将返回一个错误EINTR。因为允许调用者捕捉此信号(或者任何其他可能中断waitpid调用的信号),所以当waitpid被一个捕捉到的信号中断时,我们只是再次调用waitpid。

注意,如果应用程序调用waitpid,并且获得了popen创建的子进程的退出状态,那么我们会在应用程序调用pclose时调用waitpid,如果发现子进程已不再存在,将返回−1,将errno设置为ECHILD。这正是这种情况下POSIX.1所要求的。

如果一个信号中断了wait,pclose的一些早期版本会返回错误EINTR。pclose的一些早期版本在wait期间,会阻塞或忽略信号SIGINT、SIGQUIT和SIGHUP。这是POSIX.1所不允许的。

注意,popen决不应由设置用户ID或设置组ID程序调用。当它执行命令时,popen等同于:

execl("/bin/sh", "sh", "-c", command, NULL);

它在从调用者继承的环境中执行shell,并由shell解释执行command。一个恶意用户可以操控这种环境,使得shell能以设置ID文件模式所授予的提升了的权限以及非预期的方式执行命令。

popen特别适用于执行简单的过滤器程序,它变换运行命令的输入或输出。当命令希望构造它自己的管道时,就是这种情形。

实例

考虑一个应用程序,它向标准输出写一个提示,然后从标准输入读1行。使用popen,可以在应用程序和输入之间插入一个程序以便对输入进行变换处理。图15-13显示了这种情况下的进程安排。

图15-13 用popen对输入进行变换处理

对输入进行的变换可能是路径名扩充,或者是提供一种历史机制(记住以前输入的命令)。

图15-14是一个简单的用于演示这个操作的过滤程序。它将标准输入复制到标准输出,在复制时将大写字符变换为小写字符。在写完换行符之后,

要仔细冲洗(用fflush)标准输出,这样做的理由将在下一节介绍协同进程时讨论。

图15-14 将大写字符变换成小写字符的过滤程序

将这个过滤程序编译成可执行文件myuclc,然后图15-15的程序会用popen调用它。

图15-15 调用大写/小写过滤程序读取命令

因为标准输出通常是行缓冲的,而提示并不包含换行符,所以在写了提示之后,需要调用fflush。

15.4 协同进程

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

Korn shell提供了协同进程[Bolsky and Korn 1995]。Bourne shell、Bourne-again shell和C shell并没有提供将进程连接成协同进程的方法。协同进程通常在shell的后台运行,其标准输入和标准输出通过管道连接到另一个程序。虽然初始化一个协同进程,并将其输入和输出连接到另一个进程的shell语法是十分奇特的(详细情况见Bolsky和Korn[1995]中的第62~63页),但是协同进程的工作方式在C程序中也是非常有用的。

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

实例

让我们通过一个实例来观察协同进程。进程创建两个管道:一个是协同进程的标准输入,另一个是协同进程的标准输出。图15-16显示了这种安排。

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

图15-17中的程序是一个简单的协同进程,它从其标准输入读取两个数,计算它们的和,然后将和写至其标准输出。(协同进程通常会做较此更有意义的工作。设计本实例的目的是帮助了解将进程连接起来所需的各种管道设施。)

图15-17 将两个数相加的简单过滤程序

对此程序进行编译,将其可执行目标代码存入名为add2的文件。

图15-18中的程序从其标准输入读取两个数之后调用add2协同进程,并将协同进程送来的值写到其标准输出。

图15-18 驱动add2过滤程序的程序

这个程序创建了两个管道,父进程、子进程各自关闭它们不需使用的管道端。必须使用两个管道:一个用作协同进程的标准输入,另一个则用作它的标准输出。然后,子进程调用dup2使管道描述符移至其标准输入和标准输出,最后调用了execl。

若编译和运行图15-18中的程序,它会按预期工作。此外,若图15-18中的程序在等待输入的时候杀死了add2协同进程,然后又输入两个数,那么程序对没有读进程的管道进行写操作时,会调用信号处理程序(见习题15.4)。

实例

在协同进程add2(见图15-17)中,我们故意使用了底层I/O(UNIX系统调用):read和write。如果使用标准I/O来改写该协同进程,会怎么样呢?图15-19所示的程序就是改写后的版本。

图15-19 将两个数相加的过滤程序,使用标准I/O

若图15-18中的程序调用这个新的协同进程,则它不再工作。问题出在默认的标准I/O缓冲机制上。当调用图15-19中的程序时,对标准输入的第一个fgets引起标准I/O库分配一个缓冲区,并选择缓冲的类型。因为标准输入是一个管道,所以标准I/O库默认是全缓冲的。标准输出也是如此。当add2从其标准输入读取而发生阻塞时,图15-18中的程序从管道读时也发生阻塞,于是产生了死锁。

这里,可以对将要运行的这一协同进程加以控制。我们可以修改图 15-19 中的程序,在while循环之前加上下面4行:

if (setvbuf(stdin, NULL, _IOLBF, 0) != 0)

err_sys("setvbuf error");

if (setvbuf(stdout, NULL, _IOLBF, 0)!= 0)

err_sys("setvbuf error");

这些代码行使得:当有一行可用时,fgets 就返回;当输出一个换行符时,printf 立即执行fflush操作。对setvbuf进行的这些显式调用使得图15-19中的程序能正常工作了。

如果不能修改管道输出的目标程序,则需使用其他技术。例如,如果在程序中使用 awk(1)作为协同进程(代替add2程序),则下列命令行不能工作:

#!/bin/awk/ -f

{ print $1 + $2 }

不能工作的原因还是标准I/O的缓冲机制问题。但是在这种情况下,无法改变awk的工作方式(除非有awk的源代码)。我们不能修改awk的可执行代码,于是也就不能更改处理其标准I/O缓冲的方式。

对这种问题的一般解决方法是使被调用(在本例中是awk)的协同进程认为它的标准输入和输出都被连接到了一个终端。这使得协同进程中的标准I/O例程对这两个I/O流进行行缓冲,这类似于前面所做的显式调用setvbuf。第19章将用伪终端实现这种方法。

15.5 FIFO

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

第14章中已经提及FIFO是一种文件类型。通过stat结构(见4.2节)的st_mode成员的编码可以知道文件是否是FIFO类型。可以用S_ISFIFO宏对此进行测试。

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

#include <sys/stat.h>

int mkfifo(const char *path, mode_t mode);

int mkfifoat(int fd, const char *path, mode_t mode);

两个函数的返回值:若成功,返回0;若出错,返回−1

mkfifo函数中mode参数的规格说明与open函数中mode的相同(见3.3节)。新FIFO的用户和组的所有权规则与4.6节所述的相同。

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

(1)如果path参数指定的是绝对路径名,则fd参数会被忽略掉,并且mkfifoat函数的行为和mkfifo类似。

(2)如果path参数指定的是相对路径名,则fd参数是一个打开目录的有效文件描述符,路径名和目录有关。

(3)如果path参数指定的是相对路径名,并且fd参数有一个特殊值AT_FDCWD,则路径名以当前目录开始,mkfifoat和mkfifo类似。

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

应用程序可以用mknod和mknodat函数创建FIFO。因为POSIX.1原先并没有包括mknod函数,所以mkfifo是专门为POSIX.1设计的。mknod和mknodat函数现在已包括在POSIX.1的XSI扩展中。

POSIX.1也包括了对mkfifo(1)命令的支持。本书讨论的4种平台都提供此命令。因此,可以用一条shell命令创建一个FIFO,然后用一般的shell I/O重定向对其进行访问。

当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有以下两种用途。

(1)shell命令使用FIFO将数据从一条管道传送到另一条时,无需创建中间临时文件。

(2)客户进程-服务器进程应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程二者之间传递数据。

我们各用一个实例来说明这两种用途。

实例:用FIFO复制输出流

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

考虑这样一个过程,它需要对一个经过过滤的输入流进行两次处理。图15-20显示了这种安排。

图15-20 对一个经过过滤的输入流进行两次处理的过程

使用FIFO和UNIX程序tee(1)就可以实现这样的过程而无需使用临时文件。(tee 程序将其标准输入同时复制到其标准输出以及其命令行中命名的文件中。)

mkfifo fifo1

prog3 < fifo1 &

prog1 < infile | tee fifo1 | prog2

创建FIFO,然后在后台启动prog3,从FIFO读数据。然后启动progl,用tee将其输出发送到FIFO和prog2。图15-21显示了进程安排。

图15-21 使用FIFO和tee将一个流发送到两个不同的进程

实例:使用FIFO进行客户进程-服务器进程通信

FIFO 的另一个用途是在客户进程和服务器进程之间传送数据。如果有一个服务器进程,它与很多客户进程有关,每个客户进程都可将其请求写到一个该服务器进程创建的众所周知的FIFO中(“众所周知”的意思是:所有需与服务器进程联系的客户进程都知道该FIFO的路径名)。图15-22显示了这种安排。

图15-22 客户进程用FIFO向服务器进程发送请求

因为该 FIFO 有多个写进程,所以客户进程发送给服务器进程的请求的长度要小于PIPE_BUF字节。这样就能避免客户进程的多次写之间的交叉。

在这种类型的客户进程-服务器进程通信中使用FIFO的问题是:服务器进程如何将回答送回各个客户进程。不能使用单个FIFO,因为客户进程不可能知道何时去读它们的响应以及何时响应其他客户进程。一种解决方法是,每个客户进程都在其请求中包含它的进程ID。然后服务器进程为每个客户进程创建一个FIFO,所使用的路径名是以客户进程的进程ID为基础的。例如,服务器进程可以用名字/tmp/serv1.XXXXX创建FIFO,其中XXXXX被替换成客户进程的进程ID。图15-23显示了这种安排。

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

虽然这种安排可以工作,但服务器进程不能判断一个客户进程是否崩溃终止,这就使得客户进程专用FIFO会遗留在文件系统中。另外,服务器进程还必须得捕捉SIGPIPE信号,因为客户进程在发送一个请求后有可能没有读取响应就终止了,于是留下一个只有写进程(服务器进程)而无读进程的客户进程专用FIFO。

按照图15-23中的安排,如果服务器进程以只读方式打开众所周知的FIFO(因为它只需读该FIFO),则每当客户进程个数从1变成0时,服务器进程就将在FIFO中读到(read)一个文件结束标志。为使服务器进程免于处理这种情况,一种常用的技巧是使服务器进程以读-写方式打开该众所周知的FIFO(见习题15.10)。

15.6 XSI IPC

有3种称作XSI IPC的IPC:消息队列、信号量以及共享存储器。它们之间有很多相似之处。本节先介绍它们相类似的特征,后面几节将说明这些IPC各自的特殊功能。

XSI IPC函数是紧密地基于System V的IPC函数的。这3种类型的XSI IPC源自于1970年的一种称为“Columbus UNIX”的AT&T内部版本。后来它们被添加到System V上。由于XSI IPC不使用文件系统命名空间,而是构造了它们自己的命名空间,为此常常受到批评。

15.6.1 标识符和键

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

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

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

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

(1)服务器进程可以指定键IPC_PRIVATE创建一个新IPC结构,将返回的标识符存放在某处(如一个文件)以便客户进程取用。键IPC_PRIVATE保证服务器进程创建一个新IPC结构。这种技术的缺点是:文件系统操作需要服务器进程将整型标识符写到文件中,此后客户进程又要读这个文件取得此标识符。

IPC_PRIVATE键也可用于父进程子关系。父进程指定IPC_PRIVATE创建一个新IPC结构,所返回的标识符可供fork后的子进程使用。接着,子进程又可将此标识符作为exec函数的一个参数传给一个新程序。

(2)可以在一个公用头文件中定义一个客户进程和服务器进程都认可的键。然后服务器进程指定此键创建一个新的IPC结构。这种方法的问题是该键可能已与一个IPC结构相结合,在此情况下,get函数(msgget、semget或shmget)出错返回。服务器进程必须处理这一错误,删除已存在的IPC结构,然后试着再创建它。

(3)客户进程和服务器进程认同一个路径名和项目ID(项目ID是0~255之间的字符值),接着,调用函数ftok将这两个值变换为一个键。然后在方法(2)中使用此键。ftok提供的唯一服务就是由一个路径名和项目ID产生一个键。

#include <sys/ipc.h>

key_t ftok(const char *path, int id);

返回值:若成功,返回键;若出错,返回(key_t)−1

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

ftok创建的键通常是用下列方式构成的:按给定的路径名取得其stat结构(见4.2节)中的部分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相类似)。

15.6.2 权限结构

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 */

};

每个实现会包括另外一些成员。如欲了解你所用系统中它的完整定义,请参见<sys/ipc.h>。

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

mode字段的值类似于图4-6中所示的值,但是对于任何IPC结构都不存在执行权限。另外,消息队列和共享存储使用术语“读”和“写”,而信号量则用术语“读”和“更改”(alter)。图15-24显示了每种IPC的6种权限。

图15-24 XSI IPC权限

某些实现定义了表示每种权限的符号常量,但是这些常量并不包括在Single UNIX Specification中。

15.6.3 结构限制

所有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来找到可调节参数。

15.6.4 优点和缺点

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

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

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

Andrade、Carges和Kovach[1989]对使用System V IPC构建的一个事务处理系统进行了综述。他们认为System V IPC使用的命名空间(标识符)是一个优点,而不是前面所说的问题,理由是使用标识符使一个进程只要使用单个函数调用(msgsnd)就能将一个消息发送到一个队列,而其他形式的IPC则通常还要调用open、write和close。这种说法是错误的。为了避免使用键和调用 msgget,客户进程总要以某种方式获得服务器进程队列的标识符。分派给特定队列的标识符取决于在创建该队列时,有多少消息队列已经存在,也取决于自内核自举以来,内核中将分配给新队列的表项已经使用了多少次。这是一个动态值,无法猜到或事先存放在一个头文件中。正如15.6.1节所述,至少服务器进程应将分配给队列的标识符写到一个文件中以便客户进程读取。

这些作者列举的消息队列的其他优点是:它们是可靠的、流控制的以及面向记录的;它们可以用非先进先出次序处理。图15-25对这些不同形式IPC的某些特征进行了比较。

图15-25 不同形式IPC之间的特征比较

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

图 15-25 中没有显示的一个特征是:IPC 设施能否自动地为每个客户进程创建一个到服务器进程的唯一连接。第17章将说明UNIX流套接字可以提供这种能力。下面3节将对3种形式的XSI IPC进行详细的描述。

15.7 消息队列

消息队列是消息的链接表,存储在内核中,由消息队列标识符标识。在本节中,我们把消息队列简称为队列,其标识符简称为队列ID。

Single UNIX Specification的消息传送选项中包括一种替代的IPC消息队列接口,该接口来源于POSIX实时扩展。本书不讨论这个接口。

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 */

};

此结构定义了队列的当前状态。结构中所示的各成员是由Single UNIX Specification定义的。具体实现可能包括标准中没有定义的另一些字段。

图15-26列出了影响消息队列的系统限制。“导出的”表示这种限制来源于其他限制。例如,在Linux系统中,最大消息数是根据最大队列数和队列中所允许的最大数据量来决定的。其中最大队列数还要根据系统上安装的RAM 的数量来决定。注意,队列的最大字节数限制进一步限制了队列中将要存储的消息的最大长度。

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

图15-26 影响消息队列的系统限制

#include <sys/msg.h>

int msgget(key_t key, int flag);

返回值:若成功,返回消息队列ID;若出错,返回−1

15.6.1 节说明了将key变换成一个标识符的规则,并且讨论了是创建一个新队列还是引用一个现有队列。在创建新队列时,要初始化msqid-ds结构的下列成员。

•ipc-perm结构按15.6.2节中所述进行初始化。该结构中的mode成员按flag中的相应权限位设置。这些权限用图15-24中的值指定。

•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 <sys/msg.h>

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 <sys/msg.h>

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结构的指针。接收者可以使用消息类型以非先进先出的次序取消息。

某些平台既支持32位环境,又支持64位环境。这影响到长整型和指针的大小。例如,在64位SPARC系统中,Solaris允许32位应用程序和64位应用程序同时存在。如果一个32位应用程序要经由管道或套接字与一个64位应用程序交换此结构,就会出问题。因为在32位应用程序中,长整型的大小是4字节,而在64位应用程序中,长整型的大小是8字节。这意味着,32位应用程序期望mtext字段在结构起始地址后的第4个字节处开始,而64位应用程序则期望mtext字段在结构起始地址后的第8个字节处开始。在这种情况下,64位应用程序的mtype字段的一部分会被32位应用程序视为mtext字段的组成部分,而32位应用程序的mtext字段的前4个字节会被64位应用程序解释为mtype字段的组成部分。

但是,XSI消息队列就不会发生这种问题。Solaris实现的IPC系统调用的32位版本和64位版本具有不同的入口点。这些系统调用知道如何处理32位应用程序与64位应用程序的通信操作,并对类型字段做了特殊处理以避免它干扰消息的数据部分。唯一的潜在问题是,当64位应用程序向32位应用程序发送消息时,如果它在8字节类型字段中设置的值大于32位应用程序中4字节类型字段可表示的值,那么32位应用程序在其mtype字段中得到的将是一个截短了的类型值。

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

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

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

msgrcv从队列中取用消息。

#include <sys/msg.h>

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)。

实例:消息队列与全双工管道的时间比较

如若需要客户进程和服务器进程之间的双向数据流,可以使用消息队列或全双工管道。(回忆图15-1,通过 UNIX域套接字机制,见17.2节,可以使全双工管道可用,而某些平台通过pipe函数提供全双工管道。)

图15-27显示了在Solaris上3种技术在时间方面的比较,这3种技术是:消息队列、全双工(STREAMS)管道和UNIX域套接字。测试程序先创建IPC通道,调用fork,然后从父进程向子进程发送约200 MB数据。数据发送的方式是:对于消息队列,调用100 000次msgsnd,每个消息长度为2 000字节;对于全双工管道和UNIX域套接字,调用100 000次write,每次写2 000字节。时间都以秒为单位。

图15-27 在Solaris上3种IPC的时间比较

从这些数字中可见,消息队列原来的实施目的是提供高于一般速度的 IPC,但现在与其他形式的 IPC 相比,在速度方面已经没有什么差别了。(在原来实施消息队列时,可用的其他形式的IPC就只有半双工管道这一种。)考虑到使用消息队列时遇到的问题(见15.6.4节),我们得出的结论是,在新的应用程序中不应当再使用它们。

15.8 信号量

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

Single UNIX Specification包括了另外一套信号量接口,该接口原来是实时扩展的一部分。我们将在15.10节讨论这种接口。

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

(1)测试控制该资源的信号量。

(2)若此信号量的值为正,则进程可以使用该资源。在这种情况下,进程会将信号量值减1,表示它使用了一个资源单位。

(3)否则,若此信号量的值为 0,则进程进入休眠状态,直至信号量值大于 0。进程被唤醒后,它返回至步骤(1)。

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

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

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

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

(1)信号量并非是单个非负值,而必需定义为含有一个或多个信号量值的集合。当创建信号量时,要指定集合中信号量值的数量。

(2)信号量的创建(semget)是独立于它的初始化(semctl)的。这是一个致命的缺点,因为不能原子地创建一个信号量集合,并且对该集合中的各个信号量值赋初值。

(3)即使没有进程正在使用各种形式的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 */

};

Single UNIX Specification定义了上面所示的各字段,但是具体实现可在semid_ds结构中定义添加的成员。

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

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 */

};

图15-28列出了影响信号量集合的系统限制。

图15-28 影响信号量的系统限制

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

#include <sys/sem.h>

int semget(key_t key, int nsems, int flag);

返回值:若成功,返回信号量ID;若出错,返回−1

15.6.1节说明了将key变换为标识符的规则,讨论了是创建一个新集合,还是引用一个现有集合。创建一个新集合时,要对semid_ds结构的下列成员赋初值。

•按15.6.2节中所述,初始化ipc_perm结构。该结构中的mode成员被设置为flag中的相应权限位。这些权限是用图15-24中的值设置的。

•sem_otime设置为0。

•sem_ctime设置为当前时间。

•sem_nsems设置为nsems。

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

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

#include <sys/sem.h>

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 */

};

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

通常应用程序必须定义semun联合。然而,在FreeBSD 8.0中,semun已经由<sys/sem.h>为我们定义好了。

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 <sys/sem.h>

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位。)

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

(2)若sem_op为负值,则表示要获取由该信号量控制的资源。

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

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

a.若指定了IPC_NOWAIT,则semop出错返回EAGAIN。

b.若未指定IPC_NOWAIT,则该信号量的semncnt值加1(因为调用进程将进入休眠状态),然后调用进程被挂起直至下列事件之一发生。

i.此信号量值变成大于等于sem_op的绝对值(即某个进程已释放了某些资源)。此信号量的semncnt值减1(因为已结束等待),并且从信号量值中减去sem_op的绝对值。

如果指定了undo标志,则sem_op的绝对值也加到该进程的此信号量调整值上。

ii.从系统中删除了此信号量。在这种情况下,函数出错返回EIDRM。

iii.进程捕捉到一个信号,并从信号处理程序返回,在这种情况下,此信号量的semncnt值减1(因为调用进程不再等待),并且函数出错返回EINTR。

(3)若sem_op为0,这表示调用进程希望等待到该信号量值变成0。

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

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

a.若指定了 IPC_NOWAIT,则出错返回EAGAIN。

b.若未指定 IPC_NOWAIT,则该信号量的 semzcnt 值加 1(因为调用进程将进入休眠状态),然后调用进程被挂起,直至下列的一个事件发生。

i.此信号量值变成0。此信号量的semzcnt值减1(因为调用进程已结束等待)。

ii.从系统中删除了此信号量。在这种情况下,函数出错返回EIDRM。

iii.进程捕捉到一个信号,并从信号处理程序返回。在这种情况下,此信号量的semzcnt值减1(因为调用进程不再等待),并且函数出错返回EINTR。

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

exit时的信号量调整

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

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

实例:信号量、记录锁和互斥量的时间比较

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

若使用信号量,则先创建一个包含一个成员的信号量集合,然后将该信号量值初始化为 1。为了分配资源,以 sem_op 为−1调用 semop。为了释放资源,以sem_op为+1调用semop。对每个操作都指定SEM_UNDO,以处理在未释放资源条件下进程终止的情况。

若使用记录锁,则先创建一个空文件,并且用该文件的第一个字节(无需存在)作为锁字节。为了分配资源,先对该字节获得一个写锁。释放该资源时,则对该字节解锁。记录锁的性质确保了当一个锁的持有者进程终止时,内核会自动释放该锁。

若使用互斥量,需要所有的进程将相同的文件映射到它们的地址空间里,并且使用 PTHREAD_PROCESS_SHARED互斥量属性在文件的相同偏移处初始化互斥量。为了分配资源,我们对互斥量加锁。为了释放锁,我们解锁互斥量。如果一个进程没有释放互斥量而终止,恢复将是非常困难的,除非我们使用鲁棒互斥量(回忆12.4.1节中讨论的pthread_mutex_consistent函数)。

图15-29显示了在Linux上,使用这3种不同技术进行锁操作所需的时间。在每一种情况下,资源都被分配、释放1 000 000次。这同时由3个不同的进程执行。图15-29中所示的时间是3个进程的总计,单位是秒。

图15-29 Linux上锁替代技术的时间比较

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

在本书讨论的4个平台中,只有Linux 3.2.0和Solaris 10当前支持进程共享的互斥量属性。

15.9 共享存储

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

Single UNIX Specification在其共享存储对象选项中包括了访问共享存储的替代接口,这些接口源于实时扩展。本书不讨论这些接口。

我们已经看到了共享存储的一种形式,就是在多个进程将同一个文件映射到它们的地址空间的时候。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一样大。图15-30列出了影响共享存储的系统限制。

图15-30 影响共享存储的系统限制

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

#include <sys/shm.h>

int shmget(key_t key, size_t size, int flag);

返回值:若成功,返回共享存储ID;若出错,返回−1

15.6.1 节说明了将key变换成一个标识符的规则,以及是创建一个新共享存储段,还是引用一个现有的共享存储段。当创建一个新段时,初始化shmid_ds结构的下列成员。

•ipc_perm结构按15.6.2节中所述进行初始化。该结构中的mode按flag中的相应权限位设置。这些权限用图15-24中的值指定。

•shm_lpid、shm_nattach、shm_atime和shm_dtime都设置为0。

•shm_ctime设置为当前时间。

•shm_segsz设置为请求的size。

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

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

#include <sys/shm.h>

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 <sys/shm.h>

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 <sys/shm.h>

int shmdt(const void *addr);

返回值:若成功,返回0;若出错,返回-1

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

实例

内核将以地址0连接的共享存储段放在什么位置上与系统密切相关。图15-31中的程序打印了一些特定系统存放各种类型的数据的位置信息。

图15-31 打印各种类型的数据存放的位置

在一个基于Intel的64位Linux系统上运行此程序,其输出如下:

$ ./a.out

array[] from 0x6020c0 to 0x60bd00

stack around 0x7fff957b146c

malloced from 0x9e3010 to 0x9fb6b0

shared memory attached from 0x7fba578ab000 to 0x7fba578c36a0

图15-32显示了这种情况,这与图7-6中所示的典型存储区布局类似。注意,共享存储段紧靠在栈之下。

回忆一下mmap函数(见14.8节),它可将一个文件的若干部分映射至进程地址空间。这在概念上类似于用shmat XSI IPC函数连接一个共享存储段。两者之间的主要区别是,用mmap映射的存储段是与文件相关联的,而XSI共享存储段则并无这种关联。

图15-32 在基于Intel的Linux系统上的存储区布局

实例:/dev/zero的存储映射

共享存储可由两个不相关的进程使用。但是,如果进程是相关的,则某些实现提供了一种不同的技术。

下面说明的技术用于FreeBSD 8.0、Linux 3.2.0和Solaris 10。Mac OS X 10.6.8当前并不支持将字符设备映射至进程地址空间。

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

•创建一个未命名的存储区,其长度是mmap的第二个参数,将其向上取整为系统的最近页长。

•存储区都初始化为0。

•如果多个进程的共同祖先进程对mmap指定了MAP_SHARED标志,则这些进程可共享此存储区。

图15-33中的程序是使用此特殊设备的一个例子。

图15-33 在父进程、子进程之间使用/dev/zero的存储映射I/O的IPC

该程序打开此/dev/zero设备,然后指定长整型的长度调用mmap。注意,一旦存储区映射成功,我们就要关闭(close)此设备。然后,进程创建一个子进程。因为在调用mmap时指定了 MAP_SHARED,所以一个进程写到存储映射区的数据可被另一进程见到。(如果已指定MAP_PRIVATE,则此程序不能工作。)

然后,父进程、子进程交替运行,它们使用 8.9 节中的同步函数各自对共享存储映射区中的长整型数加1。存储映射区由mmap初始化为0。父进程先对它进行增1操作,使其成为1,然后子进程对其进行增1操作,使其成为2,然后父进程使其成为3,依此类推。注意,当在update函数中对长整型值增1时,因为增加的是其值,而不是指针,所以必须使用括号。

以上述方式使用/dev/zero 的优点是:在调用 mmap 创建映射区之前,无需存在一个实际文件。映射/dev/zero 自动创建一个指定长度的映射区。这种技术的缺点是:它只在两个相关进程之间起作用。但在相关进程之间使用线程可能更为简单有效(见第11章和第12章)。注意,无论使用哪一种技术,都需对共享数据进行同步访问。

实例:匿名存储映射

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

本书讨论的 4 种平台都支持匿名存储映射设施。但是注意,Linux 为此设备定义了 MAP_ANONYMOUS标志,并将MAP_ANON标志定义为与它相同的值以改善应用的可移植性。

为使图 15-33 中的程序应用这个设施,我们对它做了 3 处修改:(a)删除了/dev/zero 的open语句,(b)删除了fd的close语句,(c)将mmap调用修改如下:

if ((area = mmap(0, SIZE, PROT_READ | PROT_WRITE,

MAP_ANON | MAP_SHARED, -1, 0)) == MAP_FAILED)

此调用指定了MAP_ANON标志,并将文件描述符设置为−1。图15-33中的程序的其余部分没变。

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

15.10 POSIX信号量

POSIX信号量机制是3种IPC机制之一,3种IPC机制源于POSIX.1的实时扩展。Single UNIX Specification将3种机制(消息队列、信号量和共享存储)置于可选部分中。在SUSv4之前,POSIX信号量接口已经被包含在信号量选项中。在SUSv4中,这些接口被移至了基本规范,而消息队列和共享存储接口依然是可选的。

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

•相比于XSI接口,POSIX信号量接口考虑到了更高性能的实现。

•POSIX 信号量接口使用更简单:没有信号量集,在熟悉的文件系统操作后一些接口被模式化了。尽管没有要求一定要在文件系统中实现,但是一些系统的确是这么实现的。

•POSIX信号量在删除时表现更完美。回忆一下,当一个XSI信号量被删除时,使用这个信号量标识符的操作会失败,并将errno设置成EIDRM。使用POSIX信号量时,操作能继续正常工作直到该信号量的最后一次引用被释放。

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

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

#include <semaphore.h>

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的取值和打开文件的权限位相同:用户读、用户写、用户执行、组读、组写、组执行、其他读、其他写和其他执行。赋值给信号量的权限可以被调用者的文件创建屏蔽字修改(见 4.5 节和4.8节)。注意,只有读和写访问要紧,但是当我们打开一个现有信号量时接口不允许指定模式。实现经常为读和写打开信号量。

在创建信号量时,value参数用来指定信号量的初始值。它的取值是0~SEM_VALUE_MAX(见图2-9)。

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

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

•名字的第一个字符应该为斜杠(/)。尽管没有要求POSIX信号量的实现要使用文件系统,但是如果使用了文件系统,我们就要在名字被解释时消除二义性。

•名字不应包含其他斜杠以此避免实现定义的行为。例如,如果文件系统被使用了,那么名字/mysem和//mysem会被认定为是同一个文件名,但是如果实现没有使用文件系统,那么这两种命名可以被认为是不同的(考虑下如果实现把名字哈希运算转换成一个用来识别信号量的整数值会发生什么)。

•信号量名字的最大长度是实现定义的。名字不应该长于_POSIX_NAME_MAX(见图 2-8)个字符长度。因为这是使用文件系统的实现能允许的最大名字长度的限制。

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

#include <semaphore.h>

int sem_close(sem_t *sem);

返回值:若成功,返回0;若出错,返回-1

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

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

#include <semaphore.h>

int sem_unlink(const char *name);

返回值:若成功,返回0;若出错,返回-1

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

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

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

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

#include <semaphore.h>

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 <semaphore.h>

#include <time.h>

int sem_timedwait(sem_t *restrict sem,

const struct timespec *restrict tsptr);

返回值:若成功,返回0;若出错,返回−1

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

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

#include <semaphore.h>

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 <semaphore.h>

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 <semaphore.h>

int sem_destroy(sem_t *sem);

返回值:若成功,返回0;若出错,返回−1

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

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

#include <semaphore.h>

int sem_getvalue(sem_t *restrict sem, int *restrict valp);

返回值:若成功,返回0;若出错,返回−1

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

Mac OS X 10.6.8不支持sem_getvalue函数。

实例

介绍POSIX接口的动机之一就是,通过设计,它们的性能要明显好于现有XSI信号量接口。下面将了解现有系统是否达到了这个目标,尽管这些系统没有设计支持实时的应用。

在图15-34中,让3个进程在两种平台(Linux 3.2.0和Solaris 10)上竞争分配和释放信号量1 000 000次,比较了分别使用XSI信号量(不带SEM_UNDO)和POSIX信号量时的性能。

图15-34 信号量实现的时间比较

在图15-34中可以看到,在Solaris系统中,POSIX信号量相对于XSI信号量在时间上仅提高了12%,但是在Linux系统中却提高了94%(近18倍的速度)。如果跟踪程序,我们会发现,POSIX信号量的Linux实现将文件映射到了进程地址空间中,并且没有使用系统调用来操作各自的信号量。

实例

回忆图12-5,Single UNIX Specification并没用定义当一个线程对一个普通互斥量加锁,而另一个线程试图去解锁它的情况,但是这种情况下错误检查互斥量和递归互斥量会产生错误。因为二进制信号量可以像互斥量一样来使用,我们可以使用信号量来创建自己的锁原语从而提供互斥。

假设我们将要创建自己的锁,这种锁能被一个线程加锁而被另一线程解锁,那么它的结构可能是这样的:

struct slock {

sem_t *semp;

char name[_POSIX_NAME_MAX];

};

图15-35中的程序展示了基于信号量的互斥原语的实现。

图15-35 使用POSIX信号量的互斥

根据进程 ID 和计数器来创建名字。我们不会刻意用互斥量去保护计数器,因为当两个竞争的线程同时调用s_alloc并以同一个名字结束时,在调用sem_open中使用O_EXCL标志将会使其中一个线程成功而另一个线程失败,失败的线程会将errno设置成EEXIST,所以对于这种情况,我们只是再次尝试。注意,我们打开一个信号量后断开了它的连接。这销毁了名字,所以导致其他进程不能再次访问它,这也简化了进程结束时的清理工作。

15.11 客户进程-服务器进程属性

下面详细说明客户进程和服务器进程的某些属性,这些属性受到它们之间所使用的各种 IPC类型的影响。最简单的关系类型是使客户进程 fork 然后 exec 所希望的服务器进程。在 fork之前先创建两个半双工管道使数据可在两个方向传输。图15-16是这种安排的一个例子。所执行的服务器进程可能是一个设置用户 ID 的程序,这使它具有了特权。另外,服务器进程查看客户进程的实际用户ID就可以决定客户进程的真实身份。(回忆8.10节,从中可了解到在exec前后实际用户ID和实际组ID并没有改变。)

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

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

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

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

(1)在服务器进程和所有客户进程之间只使用一个队列,使用每个消息的类型字段指明谁是消息的接受者。例如,客户进程可以用设置为1的类型字段来发送它们的消息。在请求之中应包括客户进程的进程ID。此后,服务器进程在发送响应消息时,将类型字段设置为客户进程的进程ID。服务器进程只接受类型字段为1的消息(msgrcv的第4个参数),客户进程则只接受类型字段等于它们进程ID的消息。

(2)另一种方法是每个客户进程使用一个单独的消息队列。在向服务器进程发送第一个请求之前,每个客户进程先使用键IPC_PRIVATE创建它自己的消息队列。服务器进程也有它自己的队列,其键或标识符是所有客户进程都知道的。客户进程将其第一个请求发送到服务器进程的众所周知的队列上,该请求中应包含其客户进程消息队列的队列ID。服务器进程将其第一个响应发送到此客户进程队列,此后的所有请求和响应都在此队列上交换。

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

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

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

我们将在17.2节中使用下列技术,使服务器进程可以标识客户进程。这一技术可使用FIFO、消息队列、信号量以及共享存储。在下面的说明中假定按图15-23使用了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。套接字子系统在两个进程之间传送文件描述符时可以做到这一点。

15.12 小结

本章详细说明了进程间通信的多种形式:管道、命名管道(FIFO)、通常称为 XSI IPC 的 3种形式的IPC(消息队列、信号量和共享存储),以及POSIX提供的替代信号量机制。信号量实际上是同步原语而不是 IPC,常用于共享资源(如共享存储段)的同步访问。对于管道,我们说明了popen函数的实现、协同进程以及使用标准I/O库缓冲机制时可能遇到的问题。

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

下一章将介绍网络IPC,它们使进程能够跨越计算机的边界进行通信。

习题

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

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

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

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

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

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代替呢?

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中的程序,实现父进程与子进程间的交替。

15.17 使用建议性记录锁改写图15-33中的程序,实现父进程与子进程间的交替。

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