前面说明了进程控制原语并且观察了如何调用多个进程。但是这些进程之间交换信息的唯一方法是经由fork或exec传送打开文件,或通过文件系统。本章将说明进程之间相互通信的其他技术—IPC(InterProcess Communication)。
1 管道
管道是UNIX IPC的最老形式,并且所有UNIX系统都提供此种通信机制,管道有两种限制;
(1) 它们是半双工的。数据只能在一个方向上流动。
(2) 它们只能在具有公共祖先的进程之间使用。通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
我们将会看到FIFO没有第二种限制,UNIX域套接字和和命名流管道则没有第一种限制。尽管有这两种限制,半双工管道仍是最常用的IPC形式。
1.1普通管道
管道是由调用pipe函数而创建的。
#include <unistd.h>
int pipe(int filedes[2]) ;
返回:若成功则为0,若出错则为- 1
经由参数filedes返回两个文件描述符: filedes[0]为读而打开,filedes[1]为写而打开。filedes[1]的输出是filedes[0]的输入。
有两种方法来描绘一个管道,见下图。左半图显示了管道的两端在一个进程中相互连接,右半图则说明数据通过内核在管道中流动。
单个进程中的管道几乎没有任何用处。通常,调用pipe的进程接着调用fork,这样就创建了从父进程到子进程或反之的IPC通道。下图显示了这种情况。
fork之后做什么取决于我们想要有的数据流的方向。对于从父进程到子进程的管道,父进程关闭管道的读端(fd[0]),子进程则关闭写端(fd[1])。下图显示了描述符的最后安排。
下面来总结下管道的实质。
通过pipe函数的参数fd[2]可以知道,fd总是指代着一个打开文件file结构的。因此可以猜测管道就是以特殊方式打开的文件。事实就是如此,管道一被创建,进程就可以使用read()或write()这两个VFS系统调用访问管道,这更加说明了管道就是文件。对于每个管道来说,内核都要创建一个索引节点和两个文件对象(file),一个file用于读,另一个file用于写,这也就是两个fd的作用。这也进一步解释了为什么管道是通过fork的方式使用的。fork的子进程会复制父进程的fd,而复制的fd是和父进程的fd指向同一个file结构的(前面所说的进程间共享file)。这就是为什么fork后管道有写端有两个进程(父进程fd[1]和子进程fd[1]指向同一个写file),读端有两个进程。
当管道的一端被关闭后,下列规则起作用:
(1) 当读一个写端已被关闭的管道时,在所有数据都被读取后,read返回0,以指示达到了文件结束处(从技术方面考虑,管道的写端还有进程时,就不会产生文件的结束。可以复制一个管道的描述符,使得有多个进程具有写打开文件描述符。但是,通常一个管道只有一个读进程,一个写进程。下一节介绍FIFO时,我们会看到对于一个单一的FIFO常常有多个写进程)。
(2) 如果写一个读端已被关闭的管道,则产生信号SIGPIPE。如果忽略该信号或者捕捉该信号并从其处理程序返回,则write出错返回,errno设置为EPIPE。
在写管道时,常数PIPE_BUF规定了内核中管道缓存器的大小。如果对管道进行write调用,而且要求写的字节数小于等于PIPE_BUF,则此操作不会与其他进程对同一管道(或FIFO)的write操作穿插进行。但是,若有多个进程同时写一个管道(或FIFO),而且某个或某些进程要求写的字节数超过PIPE_BUF字节数,则数据可能会与其他写操作的数据相穿插。
1.2 popen、pclose
它们的一般做法是在调用fork之前先创建一个管道。fork之后父进程关闭其读端,子进程关闭其写端。子进程然后调用dup2,使其标准输入成为管道的读端。
再来看popen和pclose。这两个函数实现的操作是:创建一个管道,fork一个子进程,关闭管道的不使用端, exec一个shell以执行命令,等待命令终止。
#include <stdio.h>
FILE *popen(const char *cmdstring, const char * type) ;
返回:若成功则为文件指针,若出错则为N U L L
int pclose(FILE fp*) ;
返回:cmdstring的终止状态,若出错则为- 1
函数popen 先执行fork,然后调用exec以执行cmdstring,并且返回一个标准I/O文件指针。如果type是"r",则文件指针连接到cmdstring的标准输出。
如果type 是"w",则文件指针连接到cmdstring 的标准输入。如下图
有一种方法可以帮助我们记住popen最后一个参数及其作用,这种方法就是与fopen进行类比。如果type是"r",则返回的文件指针是可读的,如果type是"w",则是可写的。因此可知,这里的返回指针以及可读可写都是针对父进程来说的。popen的实现方式也是通过调用pipe,然后fork(进程间共享file),然后dup2将标准输入(输出)复制为与之前fd指向同一个file(进程内共享file),然后关闭不用的fd。详细参考apue15.3
1.3 协同进程
UNIX过滤程序从标准输入读取数据,对其进行适当处理后写到标准输出。几个过滤进程通常在shell管道命令中线性地连接。当同一个程序产生某个过滤程序的输入,同时又读取该过滤程序的输出时,则该过滤程序就成为协同进程(coprocess)。
popen提供连接到另一个进程的标准输入或标准输出的一个单行管道,而对于协同进程,则它有连接到另一个进程的两个单行管道—一个接到其标准输入,另一个则来自标准输出。我们先要将数据写到其标准输入,经其处理后,再从其标准输出读取数据。
通过apue15.4的详细例子可以理解协同进程的工作原理。其实关键思想就是:过滤程序在子进程(协同进程)中运行,过滤程序中原本用的标准输入输出在协同进程中通过dup2的复制而被指向到管道的file。两个管道,一个用作协同进程的标准输入,另一个则用作它的标准输出。(子进程调用dup2使管道描述符移至其标准输入和输出,然后调用execl。)
这两节中都有说将管道描述符复制为标准输入和标准输出。这如何理解?
我们应该这样来理解。首先看fd是什么。fd其实是进程的files_struct结构中file数组fd_array[]的数组索引。键盘和屏幕在系统中也是以文件的形式存在的,换言之键盘和屏幕也是有file结构与之对应的。我们知道标准输入的fd是0,标准输出的fd是1。而我们之所以说标准输入是键盘,标准输出是屏幕,是因为在一般情况下即进程初始化时,fd(0)(fd_array[0])是指向键盘的file,fd(1)(fd_array[1])是指向屏幕的file。而在调用dup2后,fd_array[0]和fd_array[1]不再指向这两个file,而是指向管道文件的读file和写file。可既然这样,fd(0)和fd(1)就不再代表键盘和屏幕了,为什么过滤程序中的printf没有在屏幕输出呢?这是因为printf本身就是向标准输出写,而不是向屏幕写(只是在一般情况下,标准输出就是屏幕)。更进一步,进程在引用标准输入输出的时候(比如像printf、scanf、write(STDOUT_FILENO,…)、fgets(…,stdin)等这样的系统调用),它不是直接用标准输入输出的file的,而是直接引用fd,即0和1的。而0、1此时已经重定向到管道文件,因此过滤程序中的printf、scanf这些的系统调用就不再会输出到屏幕或是从键盘读取,而是转而输出输入到管道文件。
总结来说由于IO系统调用是直接引用fd(0、1)的,因此我们所说的标准输入就是fd(0),标准输出就是fd(1)的,而不是指键盘或屏幕。只是一般情况下fd(0、1)是指向键盘或屏幕的file,而两者等价了。但在这种特殊情形下,标准输入输出(即0、1)是可以被重定向的。
1.4 FIFO
FIFO也被称为命名管道。管道只能由相关进程使用,它们共同的祖先进程创建了管道。但是,通过FIFO,不相关的进程也能交换数据。也就是说FIFO是有名字的管道,不相关的进程也可以通过名字来直接引用。
前面已经提及c是一种文件类型。stat结构成员st_mode的编码指明文件是否是FIFO类型。可以用S_ISFIFO宏对此进行测试。创建FIFO类似于创建文件。确实,FIFO的路径名存在于文件系统中。
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode) ;
返回:若成功则为0,若出错则为- 1
一旦已经用mkfifo创建了一个FIFO,就可用open打开它。确实,一般的文件I/O函数(close、read、write、unlink等)都可用于FIFO。
当打开一个FIFO时,非阻塞标志( O_NONBLOCK)产生下列影响:
(1) 在一般情况中(没有说明O_NONBLOCK),只读打开要阻塞到某个其他进程为写打开此FIFO。类似,为写而打开一个FIFO要阻塞到某个其他进程为读而打开它。
(2) 如果指定了O_NONBLOCK,则只读打开立即返回。但是,如果没有进程已经为读而打开一个FIFO,那么只写打开将出错返回,其errno是ENXIO。
类似于管道,若写一个尚无进程为读而打开的FIFO,则产生信号SIGPIP E。若某个FIFO的最后一个写进程关闭了该FIFO,则将为该FIFO的读进程产生一个文件结束标志。
FIFO有两种用途:
(1) FIFO由shell命令使用以便将数据从一条管道线传送到另一条,为此无需创建中间临时文件。
(2) FIFO用于客户机-服务器应用程序中,以在客户机和服务器之间传递数据。我们用两个图来说明这两种用途。详细参考apue15.6
mkfifo fifo1
prog3 < fifo1 &
prog1 < infile | tee fifo1 | prog2
创建FIFO,然后在后台起动prog3, 它从FIFO读数据。然后起动progl, 用tee将其输出发送到FIFO和prog2。如下图
2 消息队列
2.1 XSI IPC
有三种IPC我们称作XSI IPC,即消息队列、信号量以及共享存储器。他们之间有很多相似之处。
每个内核中的IPC结构(消息队列、信号量或共享存储段)都用一个非负整数的标识符加以引用。例如,为了对一个消息队列发送或取消息,只需知道其队列标识符。与文件描述符不同, IPC标识符不是小的整数。当一个IPC结构被创建,以后又被删除时,与这种结构相关的标识符连续加1,直至达到一个整型数的最大正值,然后又回转到0。(即使在IPC结构被删除后也记住该值,每次使用此结构时则增1,该值被称为“槽使用顺序号”。它在ipc_perm结构中。)
无论何时创建IPC结构(调用msgget、semget或shmget),都应指定一个关键字(key),关键字的数据类型由系统规定为key_t,通常在头文件<sys/types.h >中被规定为长整型。关键字由内核变换成标识符。
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 c gid ; /* creator's effective group id */
mode_t mode; /* access modes */
ulong seq ; /* slot usage sequence number */
key_t key; /* key */
}
在创建IPC结构时,除seq以外的所有字段都赋初值。以后,可以调用msgctl、semctl或shmctl修改uid、gid和mode字段。为了改变这些值,调用进程必须是IPC结构的创建者或超级用户。更改这些字段类似于对文件调用chown和chmod。
2.2 消息队列
消息队列是消息的链接表,存放在内核中并由消息队列标识符标识。msgget用于创建一个新队列或打开一个现存的队列。msgsnd用于将新消息添加到队列尾端。每个消息包含一个正长整型类型字段,一个非负长度以及实际数据字节(对应于长度),所有这些都在将消息添加到队列时,传送给msgsnd。msgrcv用于从队列中取消息。我们并不一定要以先进先出次序取消息,也可以按消息的类型字段取消息。
每个队列都有一个msqid_ds结构与其相关。此结构规定了队列的当前状态。
下面说明消息队列四个主要接口,详细参考apue15.7.
调用的第一个函数通常是msgget,其功能是打开一个现存队列或创建一个新队列。
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int flag) ;
返回:若成功则为消息队列I D,若出错则为- 1
msgctl函数对队列执行多种操作。它以及另外两个与信号量和共享存储有关的函数( semctl和shmctl )是系统V IPC的类似于ioctl的函数(亦即垃圾桶函数)。
int msgctl(int msqid, int cmd, struct msqid_ds *buf) ;
返回:若成功则为0,出错则为- 1
cmd参数指定对于由msqid规定的队列要执行的命令:
• IPC_STAT 取此队列的msqid_ds结构,并将其存放在buf指向的结构中。
• IPC_SET 按由buf指向的结构中的值,设置与此队列相关的结构中的下列四个字段:msg_perm.uid、msg_perm.gid、msg_perm.mode和msg_qbytes。此命令只能由下列两种进程执行:一种是其有效用户ID等于msg_perm.cuid或msg_perm.uid;另一种是具有超级用户特权的进程。只有超级用户才能增加msg_qbytes的值
• IPC_RMID 从系统中删除该消息队列以及仍在该队列上的所有数据。这种删除立即生效。仍在使用这一消息队列的其他进程在它们下一次试图对此队列进行操作时,将出错返回EIDRM。
这三条命令(IPC_STAT、IPC_SET和IPC_RMID)也可用于信号量和共享存储。
调用m s g s n d将数据放到消息队列上。
int msgsnd(int msqid, const void *ptr, size_t nbytes, int flag) ;
返回:若成功则为0,若出错则为- 1
正如前面提及的,每个消息都由三部分组成,它们是:正长整型类型字段、非负长度(nbytes)以及实际数据字节(对应于长度)。消息总是放在队列尾端。
ptr指向一个长整型数,它包含了正整型消息类型,在其后立即跟随了消息数据。(若nbytes是0,则无消息数据。)
当msgsnd成功返回,与消息队列相关的msqid_ds结构得到更新,以标明发出该调用的进程ID(msg_lspid)、进行该调用的时间(msg_stime),并指示队列中增加了一条消息(msg-qnum)。
msgrcv从队列中取用消息。
int msgrcv(int msqid, void *ptr, size_t nbytes, long type, int flag) ;
返回:若成功则为消息数据部分的长度,若出错则为- 1
如同msgsnd中一样,ptr参数指向一个长整型数(返回的消息类型存放在其中),跟随其后的是存放实际消息数据的缓存。nbytes说明数据缓存的长度。
参数type使我们可以指定想要哪一种消息:
• type == 0 返回队列中的第一个消息。
• type > 0 返回队列中消息类型为type的第一个消息。
• type < 0 返回队列中消息类型值小于或等于type绝对值,而且在这种消息中,其类型值又最小的消息。
非0 type用于以非先进先出次序读消息。例如,若应用程序对消息赋优先权,那么type就可以是优先权值。如果一个消息队列由多个客户机和一个服务器使用,那么type字段可以用来包含客户机进程ID。
3 共享存储
3.1 信号量
信号量与已经介绍过的IPC机构(管道、FIFO以及消息列队)不同。它是一个计数器,用于多进程对共享数据对象的存取。为了获得共享资源,进程需要执行下列操作:
(1) 测试控制该资源的信号量。
(2) 若此信号量的值为正,则进程可以使用该资源。进程将信号量值减1,表示它使用了一个资源单位。
(3) 若此信号量的值为0,则进程进入睡眠状态,直至信号量值大于0。若进程被唤醒后,它返回至(第( 1 )步)。
当进程不再使用由一个信息量控制的共享资源时,该信号量值增1。如果有进程正在睡眠等待此信号量,则唤醒它们。
为了正确地实现信息量,信号量值的测试及减1操作应当是原子操作。为此,信号量通常是在内核中实现的。
不幸的是,XSI的信号量与此相比要复杂得多。三种特性造成了这种并非必要的复杂性:
(1) 信号量并非是一个非负值,而必需将信号量定义为含有一个或多个信号量值的集合。当创建一个信号量时,要指定该集合中的各个值
(2) 创建信息量(semget)与对其赋初值(semctl)分开。这是一个致命的弱点,因为不能原子地创建一个信号量集合,并且对该集合中的所有值赋初值。
(3) 即使没有进程正在使用各种形式的系统V IPC,它们仍然是存在的,所以不得不为这种程序担心,它在终止时并没有释放已经分配给它的信号量。
内核为每个信号量设置了一个semid_ds结构。
struct semid_ds {
struct ipc_perm sem_perm; /* see Section 14.6.2 */
struct sem *sem_base;/* ptr to first semaphore in set */
unsigned short sem_nsems ; /* #of semaphores in set */
time_t sem_otime ; /* last-semop() time */
time_t sem_ctime ; /* last-change time */
};
对用户而言,sem_base指针是没有价值的,它指向内核中的sem结构数组,该数组中包含了sem_nsems个元素,每个元素各对应于集合中的一个信号量值。
信号集的操作接口如下
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int flag) ;
返回:若成功则返回信号量I D,若出错则为- 1
int semctl(int semid, int semnum, int cmd,…/*union semnu arg*/) ;
int semop(int semid , struct sembuf semoparray[] , size_t nops) ;
返回:若成功则为0,若出错则为- 1
前面两个与消息队列类似,说明一下semop。
函数semop自动执行信号量集合上的操作数组。semoarray是一个指针,它指向一个信号量操作数组。
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规定该数组中操作的数量(元素数)。对集合中每个成员的操作由相应的semop规定。此值可以是负值、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,则出错返回EAGAIN ;
(b) 若未指定IPC_NOWAIT,则该信号量的semncnt值加1(因为将进入睡眠状态),然后调用进程被挂起直至下列事件之一发生:
i. 此信号量变成大于或等于sem_op的绝对值(即某个进程已释放了某些资源)。此信号量的semncnt值减1(因为已结束等待) ,并且从信号量值中减去sem_op的绝对值。如果指定了undo标志,则sem_op的绝对值也加到该进程的此信号量调整值上。
ii. 从系统中删除了此信号量。在此情况下,函数出错返回ERMID。
iii. 进程捕捉到一个信号,并从信号处理程序返回,在此情况下,此信号量的semncnt值减1(因为不再等待),并且函数出错返回EINTR.
(3) 若sem_op为0,这表示希望等待到该信号量值变成0。
3.2 共享存储
共享存储允许两个或多个进程共享一给定的存储区。因为数据不需要在客户机和服务器之间复制,所以这是最快的一种IPC。使用共享存储的唯一窍门是多个进程之间对一给定存储区的同步存取。若服务器将数据放入共享存储区,则在服务器做完这一操作之前,客户机不应当去取这些数据。通常,信号量被用来实现对共享存储存取的同步。(不过正如前节最后部分所述,记录锁也可用于这种场合。)
内核为每个共享存储段设置了一个shmid_ds结构。
为获得一个共享存储标识符,调用的第一个函数通常是shmget
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key , size_t size , int flag) ;
返回:若成功则为共享内存I D,若出错则为- 1
int shmctl(int shmid , int cmd , struct shmid_ds *buf) ;
返回:若成功则为0,若出错则为- 1
void *shmat(int shmid , const void *addr , int flag) ;
返回:若成功则为指向共享存储段的指针,若出错则为- 1
int shmdt(void *addr) ;
返回:若成功则为0,若出错则为- 1
前面两个与消息队列类似,说明后面两个函数。
一旦创建了一个共享存储段,进程就可以调用shmat将其连接到它的地址空间中。共享存储段连接到调用进程的哪个地址上与addr参数以及在flag中是否指定SHM_RND位有关。
(1) 如果addr为0,则此段连接到由内核选择的第一个可用地址上。
(2) 如果addr非0,并且没有指定SHM_RND,则此段连接到addr所指定的地址上。
(3) 如果addr非0,并且指定了SHM_RND,则此段连接到(addr-(addr mod SHMLBA))所表示的地址上。
shmat的返回值是该段所连接的实际地址,如果出错则返回- 1。
当对共享存储段的操作已经结束时,则调用shmdt脱接该段。注意,这并不从系统中删除其标识符以及其数据结构。该标识符仍然存在,直至某个进程(一般是服务器)调用shmctl(带命令IPC_RMID)特地删除它。addr参数是以前调用shmat时的返回值。
从下面这个图可知共享存储区在进程地址空间的位置。详细参考apue15.9.