APUE笔记:Interprocess Communication(十五)
本文记录《UNIX环境高级编程》第3版 第15章 Interprocess Communication 的一些知识点。
Introduction
下图列出了4种实现所支持的不同形式的(InterProcess Communication,IPC)。

使用“(full)”来表示通过使用全双工管道支持半双工管道的实现。
前10种IPC形式通常限于同一台主机的两个进程之间的IPC。最后两行(套接字和STREAMS)是仅有的支持不同主机上两个进程之间IPC的两种形式。
Pipes
管道是UNIX系统IPC的最古老形式,所有UNIX系统都提供此种通信机制。管道有以下两种局限性。
(1)历史上,它们是半双工的(数据只能在一个方向上流动)。现在,某些系统提供全双工管道,但是为了最佳可移植性,决不应预先假定系统支持全双工管道。
(2)管道只能在具有公共祖先的两个进程之间使用。通常,一个管道由一个进程创建,在进程调用fork之后,这个管道就能在父进程和子进程之间使用了(不一定非要是父子,只要公共祖先创建管道,后续子孙进程继承也可以)。
FIFO没有第二种局限性,UNIX域套接字没有这两种局限性。
尽管有这两种局限性,半双工管道仍是最常用的IPC形式。每当在管道中键入命令序列,让 shell 执行时,shell 都会为每一条命令单独创建一个进程,然后用管道将前一条命令进程的标准输出与后一条命令的标准输入相连接。
管道是通过调用 pipe 函数创建的。
1 |
|
经由参数 fd
返回两个文件描述符:fd[0]为读而打开,fd[1]为写而打开。fd[1]的输出是fd[0]的输入。
POSIX.1允许实现支持全双工管道。对于这些实现,fd[0]和fd[1]可以以读/写方式打开。
下图中给出了两种描绘半双工管道的方法。图的左半部分显示了管道的两端在一个单一进程中相连。图的右半部分则着重表明,管道中的数据是通过内核流动的。

fstat
函数对管道的每一端都返回一个FIFO类型的文件描述符。可以用
S_ISFIFO 宏来测试管道。
单个进程中的管道几乎没有任何用处。通常,进程会先调用
pipe,接着调用
fork,从而创建从父进程到子进程的IPC通道,反之亦然。下图显示了这种情况。

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

对于一个从子进程到父进程的管道,父进程关闭fd[1],子进程关闭fd[0]。
当管道的一端被关闭后,下列两条规则起作用。
(1)当读(read)一个写端已被关闭的管道时,在所有数据都被读取后,read
返回0,表示文件结束。
(2)如果写(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运行命令,然后等待命令终止。
1 |
|
popen 先执行 fork,然后调用
exec 执行
cmdstring,并且返回一个标准I/O文件指针。若
type是 r,则文件指针连接到
cmdstring 的标准输出(见下图)。
如果 type 是 w,则文件指针连接到
cmdstring 的标准输入,如图所示。
有一种方法可以帮助记住 popen
的最后一个参数及其作用,这就是与 fopen 进行类比。如果
type 是”r”,则返回的文件指针是可读的,如果
type 是”w”,则是可写的。
pclose
函数关闭标准I/O流,等待命令终止,然后返回shell的终止状态。如果 shell
不能被执行,则 pclose 返回的终止状态与shell已执行
exit(127) 一样。
协程
UNIX系统过滤器是一个从标准输入读取数据并向标准输出写入数据的程序。过滤器通常在线性连接的shell管道中使用。当同一个程序既生成过滤器的输入又读取过滤器的输出时,该过滤器就成了协同进程。
协同进程通常在 shell 的后台运行,其标准输入和标准输出通过管道连接到另一个程序。尽管启动协同进程并将其输入和输出连接到其他进程所需的 shell 语法相当复杂,但协同进程在 C 程序中也很有用。
popen
提供了一条通向另一个进程的标准输入或来自其标准输出的单向管道,而对于协同进程,我们有两条通向该进程的单向管道:一条通向其标准输入,另一条来自其标准输出。我们希望向其标准输入写入数据,让它对数据进行处理,然后从其标准输出读取数据。
FIFOs
FIFO有时被称为命名管道。无名管道仅能在有共同祖先创建了该管道的相关进程之间使用。然而,借助FIFO,不相关的进程也可以交换数据。
FIFO是一种文件类型。通过 stat 结构的
st_mode 成员的编码可以知道文件是否是FIFO类型。可以用
S_ISFIFO 宏对此进行测试。
创建FIFO类似于创建文件。事实上,FIFO的路径名存在于文件系统中。
1 |
|
一旦使用 mkfifo 或 mkfifoat
创建了一个FIFO,就可以使用 open
来打开它。实际上,常规的文件I/O函数(例如
close、read、write、unlink)都可用于FIFO。
当 open
一个FIFO时,非阻塞标志(O_NONBLOCK)会产生下列影响。
在一般情况下(没有指定
O_NONBLOCK),只读open要阻塞到某个其他进程为写而打开这个FIFO为止。类似地,只写open要阻塞到某个其他进程为读而打开它为止。如果指定了
O_NONBLOCK,则只读open立即返回。但是,如果没有进程为读而打开,那么只写open将返回−1,并将errno设置成ENXIO。
类似于管道,若 write 一个尚无进程为读而打开的FIFO,则产生信号
SIGPIPE。当一个FIFO的最后一个写入者关闭该FIFO时,会为该FIFO的读取者生成一个文件结束标志。
一个给定的 FIFO
有多个写进程是常见的。这就意味着,如果不希望多个进程所写的数据交叉,则必须考虑原子写操作。和管道一样,常量
PIPE_BUF 说明了可被原子地写到FIFO的最大数据量。
FIFO有以下两种用途。
(1)FIFO 被 shell 命令用来将数据从一个 shell 管道传递到另一个,而无需创建中间临时文件。
(2)在客户机-服务器应用程序中,FIFO被用作会合点,以便在客户机和服务器之间传递数据。
各用一个实例来说明这两种用途。
实例:用FIFO复制输出流
FIFO可用于复制一系列sell命令中的输出流。这就防止了将数据写向中间磁盘文件(类似于使用管道来避免中间磁盘文件)。但是不同的是,管道只能用于两个进程之间的线性连接,而FIFO是有名字的,因此它可用于非线性连接。
考虑这样一个过程,它需要对一个经过过滤的输入流进行两次处理。下图显示了这种安排。

使用FIFO和UNIX程序 tee(1)
就可以实现这样的过程而无需使用临时文件。(tee
程序将其标准输入同时复制到其标准输出以及其命令行中命名的文件中。)
1 | mkfifo fifo1 |
创建FIFO,然后在后台启动prog3,从FIFO读数据。然后启动prog1,用tee将其输出发送到FIFO和prog2。下图显示了进程安排。

实例:使用FIFO进行客户进程-服务器进程通信
如果有一个服务器进程,它与很多客户进程连接,每个客户进程都可将其请求写到一个该服务器进程创建的众所周知的FIFO中。下图显示了这种安排。

因为该 FIFO
有多个写进程,所以客户进程发送给服务器进程的请求的长度要小于
PIPE_BUF 字节。这样就能避免客户进程的多次写之间的交叉。
在这种类型的客户进程-服务器进程通信中使用FIFO的问题是:服务器进程如何将回答送回各个客户进程。不能使用单个FIFO,因为客户端永远无法知道何时读取自己的响应,何时读取其他客户端的响应。一种解决方法是,每个客户进程都在其请求中包含它的进程ID。然后服务器进程为每个客户进程创建一个FIFO,所使用的路径名基于客户进程的进程ID。例如,服务器进程可以用名字
/tmp/serv1.XXXXX 创建FIFO,其中 XXXXX
被替换成客户进程的进程ID。下图显示了这种安排。

虽然这种安排可以工作,但服务器进程不能判断一个客户进程是否崩溃终止,这就使得客户进程专用FIFO会遗留在文件系统中。另外,服务器进程还必须得捕捉
SIGPIPE
信号,因为客户进程在发送一个请求后有可能没有读取响应就终止了,于是留下一个只有写进程(服务器进程)而无读进程的客户进程专用FIFO。
按照图中的安排,如果服务器进程以只读方式打开众所周知的FIFO(因为它只需读该FIFO),则每当客户进程个数从1变成0时,服务器进程就将在FIFO中读到(read)一个文件结束标志。为使服务器进程免于处理这种情况,一种常用的技巧是使服务器进程以读-写方式打开该众所周知的FIFO。
XSI IPC
有3种称作XSI IPC的IPC:消息队列、信号量以及共享内存。它们之间有很多相似之处。本节先介绍它们相类似的特征,后面几节将说明这些IPC各自的特殊功能。
标识符和键
每个内核中的 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产生一个键。
1 |
|
path
参数必须引用一个现有的文件。生成key时,仅使用id的低8位。
3个get函数(msgget、semget 和
shmget )都有两个类似的参数:一个 key
和一个整型 flag。如果键是
IPC_PRIVATE,或者键当前未与特定类型的IPC结构相关联且指定了标志的
IPC_CREAT
位,就会创建一个新的IPC结构(通常由服务器创建)。要引用一个已存在的队列(通常由客户端执行),key
必须与创建队列时指定的 key 相同,且不得指定
IPC_CREAT。
注意,决不能指定 IPC_PRIVATE
作为键来引用一个现有队列,因为这个特殊的键值总是用于创建一个新队列。为了引用一个用
IPC_PRIVATE
键创建的现有队列,一定要知道这个相关的标识符,然后在其他IPC调用中(如
msgsnd、msgrcv)使用该标识符,这样可以绕过get函数。
如果我们想要创建一个新的IPC结构,且确保不会引用到具有相同标识符的现有结构,就必须指定一个同时设置了
IPC_CREAT 和 IPC_EXCL 位的
flag。这样一来,如果该IPC结构已经存在,就会返回
EEXIST 错误。(这类似于指定了 O_CREAT 和
O_EXCL 标志的 open 操作。)
权限结构
XSI IPC为每一个IPC结构关联了一个 ipc_perm
结构。该结构规定了权限和所有者,它至少包括下列成员:
1 | struct ipc_perm { |
每个实现都包含额外的成员。有关完整定义,请查看系统上的
<sys/ipc.h>。
在创建IPC结构时,对所有字段都赋初值。以后,可以调用
msgctl、semctl 或 shmctl 修改
uid、gid 和 mode
字段。为了修改这些值,调用进程必须是IPC结构的创建者或超级用户。修改这些字段类似于对文件调用
chown 和 chmod。
对于任何IPC结构 mode
字段的值都不存在执行权限。另外,消息队列和共享内存使用术语“读”和“写”,而信号量则用术语“读”和“更改”(alter)。下表显示了每种IPC的6种权限。
| Permission | Bit |
|---|---|
| user-read | 0400 |
| user-write (alter) | 0200 |
| group-read | 0040 |
| group-write (alter) | 0020 |
| other-read | 0004 |
| other-write (alter) | 0002 |
配置限制
所有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和Mac中,等效的命令是
ipcs -T。在Solaris中,可以通过运行 sysdef –i
来找到可调节参数。
优点和缺点
XSI
IPC的一个基本问题是,IPC结构是系统级的,并且没有引用计数。例如,如果我们创建一个消息队列,在队列中放置一些消息,然后终止进程,该消息队列及其内容并不会被删除。它们会一直存在于系统中,直到某个进程调用
msgrcv 或 msgctl
来专门读取或删除它们,或者有人执行 ipcrm(1)
命令,又或者系统被重启。将其与管道进行比较,管道会在最后一个引用它的进程终止时被完全移除。对于FIFO(命名管道),尽管其名称会保留在文件系统中,直到被显式删除,但当最后一个引用FIFO的进程终止时,FIFO中遗留的任何数据都会被移除。
XSI
IPC的另一个问题是,这些IPC结构在文件系统中没有名称标识。我们无法使用在第3章和第4章中介绍的函数来访问它们并修改其属性。内核中新增了近十几个系统调用(如
msgget、semop、shmat等)来支持这些IPC对象。我们不能用
ls 命令查看这些IPC对象,不能用 rm
命令删除它们,也不能用 chmod
命令更改它们的权限。相反,新增了两个命令 ipcs(1)和
ipcrm(1) 来完成这些操作。
由于这些IPC形式不使用文件描述符,所以无法将多路复用I/O函数(
select 和 poll
)与它们一同使用。这使得同时使用多个此类IPC结构,或者将任何此类IPC结构与文件I/O或设备I/O结合使用变得更加困难。例如,我们无法让服务器在不借助某种形式的忙等循环的情况下,等待消息被放入两个消息队列中的任意一个。
下表比较了这些不同形式的进程间通信(IPC)的部分特性。
| IPC type | Connectionless? | Reliable? | Flow control? | Records? | Message types or priorities? |
|---|---|---|---|---|---|
| message queues | no | yes | yes | yes | yes |
| STREAMS | no | yes | yes | yes | yes |
| UNIX domain stream socket | no | yes | yes | no | no |
| UNIX domain datagram socket | yes | yes | no | yes | no |
| FIFOs (non-STREAMS) | no | yes | yes | no | no |
Connectionless“无连接”,是指无需先调用某种形式的打开函数就能发送消息的能力。由于所有这些进程间通信(IPC)形式都局限于单一主机,所以它们都是可靠的。当消息通过网络发送时,消息丢失的可能性就成了一个需要关注的问题。Flow control“流量控制”指的是,如果系统资源(缓冲区)不足,或者接收方无法再接收更多消息,发送方会进入睡眠状态。当流量控制的条件缓解时(即队列中有空间时),发送方应自动被唤醒。
下面3节将对3种形式的XSI IPC进行详细的描述。
消息队列
XSI / System V 消息队列(message queue) 的概念结构图如下:
1 | 用户进程 A 用户进程 B |
消息队列是消息的链接表,存储在内核中,由消息队列标识符标识。在本节中,把消息队列简称为队列,其标识符简称为队列ID。
msgget
用于创建一个新队列或打开一个现有队列。msgsnd
将新消息添加到队列尾端。每个消息包含一个正的长整型类型的字段、一个非负的长度以及实际数据字节数(对应于长度),所有这些在消息被添加到队列时都被指定给
msgsnd。msgrcv
用于从队列中取消息。我们并不一定要以先进先出次序取消息,也可以按消息的类型字段取消息。
每个队列都有一个 msqid_ds 结构与其相关联:
1 | struct msqid_ds { |
此结构定义了队列的当前状态。
调用的第一个函数通常是
msgget,其功能是打开一个现有队列或创建一个新队列。
1 |
|
msgctl
函数对队列执行多种操作。它和另外两个与信号量及共享存储有关的函数(semctl
和 shmctl)都是XSI IPC的类似于 ioctl
的函数(亦即垃圾桶函数)。
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 将数据放到消息队列中。
1 |
|
每个消息都由3部分组成:一个正的长整型类型的字段、一个非负的长度(nbytes)以及实际数据字节数(对应于长度)。消息总是放在队列尾端。
ptr
参数指向一个长整型数,它包含了正的整型消息类型,其后紧接着的是消息数据(若nbytes是0,则无消息数据)。若发送的最长消息是512字节的,则可定义下列结构:
1 | struct mymesg { |
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 从队列中取用消息。
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,errno
设置为
ENOMSG。如果没有指定IPC_NOWAIT,则进程会一直阻塞到有了指定类型的消息可用,或者从系统中删除了此队列(返回−1,error
设置为
EIDRM),或者捕捉到一个信号并从信号处理程序返回(这会导致
msgrcv 返回−1,errno设置为 EINTR)。
msgrcv 成功执行时,内核会更新与该消息队列相关联的
msgid_ds
结构,以指示调用者的进程ID(msg_lrpid)和调用时间(msg_rtime),并指示队列中的消息数减少了1个(msg_qnum)。
信号量
信号量与已经介绍过的 IPC方式(管道、FIFO 以及消息队列)不同。信号量是一种计数器,用于为多个进程提供对共享数据对象的访问权限。
为了获得共享资源,进程需要执行下列操作:
(1)测试控制该资源的信号量。
(2)若此信号量的值为正,则进程可以使用该资源。在这种情况下,进程会将信号量值减1,表示它使用了一个资源单位。
(3)否则,若此信号量的值为 0,则进程进入休眠状态,直至信号量值大于 0。进程被唤醒后,它返回至步骤(1)。
当一个进程使用完由信号量控制的共享资源后,信号量的值会增加1。如果有其他进程处于休眠状态,等待该信号量,它们将会被唤醒。
为了正确地实现信号量,信号量值的测试及减1操作应当是原子操作。为此,信号量通常是在内核中实现的。
常用的信号量形式被称为二元信号量(binary semaphore)。它控制单个资源,其初始值为1。但是,一般而言,信号量的初值可以是任意一个正值,该值表明有多少个共享资源单位可供共享。
遗憾的是,XSI信号量与此相比要复杂得多。以下3种特性造成了这种不必要的复杂性。
(1)信号量并非是单个非负值,而必需定义为含有一个或多个信号量值的集合。当创建信号量时,要指定集合中信号量值的数量。
(2)信号量的创建(semget)是独立于它的初始化(semctl)的。这是一个致命的缺点,因为不能原子地创建一个信号量集合,并且对该集合中的各个信号量值赋初值。
(3)即使没有进程正在使用各种形式的XSI
IPC,它们仍然是存在的。有的程序在终止时并没有释放已经分配给它的信号量,所以我们不得不为这种程序担心。后面将要说明的
undo 功能就是处理这种情况的。
XSI 信号量概念结构
XSI / System V 信号量(semaphore) 的概念结构图如下:
1 | 用户进程 A 用户进程 B |
内核为每个信号量集合维护着一个 semid_ds 结构:
1 | struct semid_ds { |
每个信号量由一个无名结构表示,它至少包含下列成员:
1 | struct { |
函数semget
当我们想使用XSI信号量时,首先需要通过调用函数 semget
来获得一个信号量ID。
1 |
|
创建一个新集合时,要对 semid_ds
结构的下列成员赋初值。
- 初始化
ipc_perm结构。该结构中的mode成员被设置为flag中的相应权限位。 sem_otime设置为0。sem_ctime设置为当前时间。sem_nsems设置为nsems。
nsems
是该集合中的信号量数。如果是创建新集合(一般在服务器进程中),则必须指定
nsems。如果是引用现有集合(客户进程中),则将
nsems 指定为0。
函数semctl
semctl 函数包含了多种信号量操作。
1 |
|
第4个参数是可选的,是否使用取决于所请求的命令,如果使用该参数,则其类型是
semun,它是多个命令特定参数的联合(union):
1 | union semun { |
注意,这个选项参数是一个联合,而非指向联合的指针。
通常应用程序必须定义 semun 联合。然而,在FreeBSD
8.0中,semun 已经由 <sys/sem.h>
为我们定义好了。
函数semop
函数 semop 以原子方式对信号量集执行一组操作。
1 |
|
参数 semoparray 是一个指针,它指向一个由
sembuf 结构表示的信号量操作数组:
1 | struct sembuf { |
参数 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(有人动了账本了,以前的旧账就乱了恢复困难,所以直接作废,重新开始算)。
共享存储
XSI 共享内存(shared memory)的概念结构图如下:
1 | 进程 A 进程 B |
共享内存允许两个或多个进程共享特定的内存区域。这是最快的进程间通信(IPC)形式,因为数据无需在客户端和服务器之间进行复制。使用共享内存时,唯一的难点在于在多个进程之间同步对特定区域的访问。如果服务器正在向共享内存区域写入数据,那么客户端在服务器完成操作之前不应尝试访问该数据。通常,信号量被用于同步共享内存访问。(但正如我们在上一节末尾所看到的,记录锁定或互斥锁也可以使用。)
我们已经见过一种共享内存的形式,即多个进程将同一个文件映射到它们的地址空间中。XSI共享内存与内存映射文件的不同之处在于,它没有关联的文件。XSI共享内存段是匿名的内存段。
内核为每个共享内存段维护一个至少包含以下成员的结构:
1 | struct shmid_ds { |
shmatt_t 类型定义为无符号整型,它至少与unsigned
short一样大。
调用的第一个函数通常是
shmget,它获得一个共享存储标识符。
1 |
|
size
参数是共享内存段的大小(以字节为单位)。实现通常会将此大小向上舍入为系统页面大小的倍数,但如果应用程序指定的
size
不是系统页面大小的整数倍,则最后一页的剩余部分将无法使用。如果要创建新段(通常由服务器创建),我们必须指定其大小。如果我们要引用现有段(客户端),可以将
size 指定为0。创建新段时,段的内容会初始化为零。
shmctl 函数对共享存储段执行多种操作。
1 |
|
一旦共享内存段被创建,进程就会通过调用 shmat
将其附加到自己的地址空间中。
1 |
|
调用进程中附加该段的地址取决于 addr 参数以及
flag 中是否指定了 SHM_RND 位。
- 如果
addr为0,该段落在内核选择的第一个可用地址处附加。这是推荐的技术。 - 如果
addr非0,并且没有指定SHM_RND,则此段连接到addr所指定的地址上。 - 如果
addr非0,且指定了SHM_RND,那么该段会被附加到由(addr减去(addr对SHMLBA取模的结果))所给出的地址处。SHM_RND命令代表“舍入”。SHMLBA代表“低边界地址倍数”,且它始终是2的幂。这种算术运算的作用是将地址向下舍入到SHMLBA的下一个倍数。
除非我们打算仅在单一类型的硬件上运行该应用程序(如今这是极不可能的),否则我们不应该指定要附加段的地址。相反,我们应该将
addr 指定为0,让系统来选择地址。
如果在 flag 中指定了 SHM_RDONLY
位,则以只读方式连接此段,否则以读写方式连接此段。
shmat 返回的值是段所连接的地址,若发生错误则返回−1。如果
shmat 调用成功,内核会增加与该共享内存段相关联的
shmid_ds 结构中的 shm_nattch 计数器。
当使用完一个共享内存段后,会调用 shmdt 来
detach(分离)它。需要注意的是,这并不会从系统中移除该标识符及其关联的数据结构。该标识符会一直存在,直到某个进程(通常是服务器)通过调用
shmctl 并使用 IPC_RMID 命令来专门移除它。
1 |
|
addr 参数是以前调用 shmat
时的返回值。如果成功,shmdt 将使相关 shmid_ds
结构中的 shm_nattch 计数器值减1。
用户进程的虚拟地址空间分布图如下:
1 | 进程虚拟地址空间 |
POSIX信号量
POSIX 信号量(semaphore)概念结构图如下:
1 | POSIX 信号量结构图 |
POSIX信号量机制是源于POSIX.1实时扩展的三种IPC机制(消息队列、信号量和共享内存)之一。
POSIX信号量接口意在解决XSI信号量接口的几个缺陷:
- 相比于XSI接口,POSIX信号量接口考虑到了更高性能的实现。
- POSIX信号量接口使用起来更简单:不存在信号量集,而且有几个接口是仿照人们熟悉的文件系统操作设计的。尽管并没有要求必须在文件系统中实现它们,但有些系统确实采用了这种方法。
- POSIX信号量在被移除时表现得更为得体。回想一下,当一个XSI信号量被移除时,使用相同信号量标识符的操作会失败,且
errno会被设置为EIDRM。而对于POSIX信号量,在最后一个对该信号量的引用被释放之前,相关操作会继续正常工作。
POSIX信号量有两种形式:命名的和未命名的。它们的差异在于创建和销毁的形式上,但其他工作一样。无名信号量仅存在于内存中,并且要求进程必须能够访问该内存才能使用这些信号量。这意味着它们只能被同一进程中的线程,或者已将相同内存区域映射到自身地址空间的不同进程中的线程使用。相比之下,有名信号量通过名称进行访问,任何知道其名称的进程中的线程都可以使用它们。
可以调用 sem_open
函数来创建一个新的命名信号量或者使用一个现有信号量。
1 |
|
当使用一个现有的命名信号量时,我们仅仅指定两个参数:信号量的名字和
oflag 参数的0值。当这个 oflag 参数有
O_CREAT
标志集时,如果命名信号量不存在,则创建一个新的。如果它已经存在,则会被使用,但是不会有额外的初始化发生。
当指定 O_CREAT
标志时,需要提供两个额外的参数。mode
参数指定谁可以访问信号量。mode
的取值和打开文件的权限位相同。赋值给信号量的权限可以被调用者的文件创建屏蔽字修改。然而,需要注意的是,只有读写权限才重要,但这些接口不允许我们在打开现有信号量时指定模式。实现通常会以读写方式打开信号量。
在创建信号量时,value
参数用来指定信号量的初始值。它的取值是
0~SEM_VALUE_MAX。
如果我们想确保创建的是信号量,可以设置 oflag 参数为
O_CREAT|O_EXCL。如果信号量已经存在,会导致
sem_open 失败。
为了提高可移植性,我们在选择信号量名称时必须遵循特定的约定。
- 名称中的第一个字符应该是斜杠(
/)。尽管并没有要求POSIX信号量的实现必须使用文件系统,但如果使用了文件系统,我们希望消除在解释名称时从哪个起点开始的任何歧义。 - 该名称不应包含其他斜杠,以避免因实现方式不同而产生的不确定行为。
- 信号量名称的最大长度由具体实现定义。该名称不应超过
_POSIX_NAME_MAX个字符,因为如果实现确实使用文件系统,这是最大名称长度的最小可接受限制。
sem_open
会返回一个信号量指针,想要对信号量进行操作时,可以将该指针传递给其他信号量函数。当使用完信号量后,可以调用
sem_close 来释放与该信号量相关的所有资源。
1 |
|
如果我们的进程在未先调用 sem_close
的情况下退出,内核会自动关闭所有打开的信号量。需要注意的是,这不会影响信号量值的状态——如果我们已经增加了它的值,并不会仅仅因为进程退出而改变。同样,调用
sem_close 也不会影响信号量的值。这里没有与XSI信号量中的
SEM_UNDO 标志等效的机制。
可以使用 sem_unlink 函数来销毁一个命名信号量。
1 |
|
sem_unlink
函数会移除信号量的名称。如果没有对该信号量的打开引用,那么它会被销毁。否则,销毁操作会推迟到最后一个打开的引用被关闭时进行。
与XSI信号量不同,我们通过一次函数调用只能将POSIX信号量的值调整1。减少计数类似于锁定二进制信号量,或者获取与计数信号量相关联的资源。
可以使用 sem_wait 或者 sem_trywait
函数来实现信号量的减1操作。
1 |
|
使用
sem_wait函数时,如果信号量计数是0就会发生阻塞。直到成功使信号量减1或者被信号中断时才返回。可以使用
sem_trywait 函数来避免阻塞。调用sem_trywait
时,如果信号量是0,则不会阻塞,而是会返回−1并且将errno置为
EAGAIN。
第三个选择是阻塞一段确定的时间。为此,可以使用
sem_timedwait 函数。
1 |
|
tsptr
参数指定了我们希望放弃等待信号量的绝对时间。超时基于
CLOCK_REALTIME
时钟(图6.8)。如果可以立即对信号量执行减操作,那么超时值就无关紧要了——即便它指定的时间可能是过去的某个时间,尝试对信号量执行减操作仍会成功。如果超时时间已到却仍无法对信号量计数执行减操作,那么
sem_timedwait 将返回-1,并将 errno 设置为
ETIMEDOUT。
要增加信号量的值,调用 sem_post
函数。这类似于解锁一个二元信号量,或者释放与计数信号量相关联的资源。
1 |
|
如果一个进程在我们调用 sem_post 时因调用
sem_wait(或
sem_timedwait)而处于阻塞状态,那么该进程会被唤醒,且刚刚被
sem_post 递增的信号量计数会被 sem_wait(或
sem_timedwait)递减。
当我们想在单个进程中使用POSIX信号量时,使用无名信号量会更简便。这仅仅改变我们创建和销毁信号量的方式。要创建无名信号量,需要调用
sem_init 函数。
1 |
|
pshared
参数表明是否在多个进程中使用信号量。如果是,将其设置成一个非0值。value
参数指定了信号量的初始值。
需要声明一个 sem_t
类型的变量并把它的地址传递给sem_init来实现初始化,而不是像
sem_open
函数那样返回一个指向信号量的指针。如果计划在两个进程之间使用信号量,就需要确保
sem 参数指向这两个进程之间共享的内存区域。
对未命名信号量的使用已经完成时,可以调用 sem_destroy
函数丢弃它。
1 |
|
调用 sem_destroy 后,不能再使用任何带有 sem
的信号量函数,除非通过调用 sem_init 重新初始化它。
sem_getvalue 函数可以用来检索信号量值。
1 |
|
成功后,valp
指向的整数值将包含信号量值。然而,需要注意的是,当我们尝试使用刚刚读取的信号量值时,该值可能已经发生了变化。除非我们使用额外的同步机制来避免这种竞争状态,否则
sem_getvalue 函数仅在调试时有用。
Client–Server Properties
open server
1 | [fork before exec] |
小结
我们已经详细介绍了多种进程间通信形式:管道、命名管道(FIFO)、通常称为XSI
IPC的三种进程间通信形式(消息队列、信号量和共享内存),以及POSIX提供的另一种信号量机制。信号量实际上是一种同步原语,并非真正的进程间通信方式,它常被用于同步对共享资源(如共享内存段)的访问。关于管道,我们探讨了
popen
函数的实现、协同进程以及标准I/O库缓冲可能带来的问题。
在比较了消息队列与全双工管道的性能,以及信号量与记录锁的性能之后,我们可以给出以下建议:学习管道和FIFO,因为这两种基本技术在许多应用中仍能有效发挥作用。在任何新应用中都应避免使用XSI消息队列和信号量。相反,应考虑使用全双工管道和记录锁,因为它们要简单得多。共享内存仍有其用武之地,不过通过
mmap 函数也能提供相同的功能。