APUE 进程间通信

《UNIX环境高级编程》第15章 进程间通信 笔记

管道

管道是UNX系统IPC的最古老形式,所有UNX系统都提供此种通信机制。

管道有以下两种局限性:

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

每当在管道中键入一个命令序列,让shell执行时,shell都会为每一条命令单独创建一个进程,然后用管道将前一条命令进程的标准输出与后一条命令的标准输入相连接。

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

1
2
3
4
5
#include <unistd.h>
int pipe(int fd[2]);
Returns: 0 if OK, −1 on error

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

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

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

函数popen和pclose

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

1
2
3
4
5
6
7
`#include <stdio.h>
FILE *popen(const char *cmdstring, const char *type);
// Returns: file pointer if OK, NULL on error
int pclose(FILE *fp);
// Returns: termination status of cmdstring, or −1 on error

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

可以将最后一个参数的使用方法和fopen类比,如果type是”r”,则返回的文件指针是可读的,如果type是”w”,则是可写的

协同进程

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

协同进程通常在shell的后台运行,其标准输入和标准输出通过管道连接到另一个程序。

popen只提供连接到另一个进程的标准输入或标准输出的一个单向管道,而协同进程则有连接到另一个进程的两个单向管道:一个接到其标准输入,另一个则来自其标准输出。

FIFO

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

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

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

1
2
3
4
5
6
#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);
int mkfifoat(int fd, const char *path, mode_t mode);
// Both return: 0 if OK, −1 on error

当我们用 mkfifo或者 mkfifoat创建FIFO时,要用open来打开它。设置非阻塞以及对错误的处理和普通文件的处理是一样的。

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

FIFO有以下两种用途

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

XSI IPC

有3种称作XSI IPC的IPC:消息队列、信号量以及共享存储器。它们之间有很多相似之处

标识符与键

每个内核中的IPC结构(消息队列、信号量或共享存储段)都用一个非负整数的标识符(identifier)加以引用。

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

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

  1. 服务器进程可以指定键IPC_PRIVATE创建一个新IPC结构,将返回的标识符存放在某处(如一个文件)以便客户进程取用。
  2. 可以在一个公用头文件中定义一个客户进程和服务器进程都认可的键。
  3. 客户进程和服务器进程认同一个路径名和项目ID,接着调用函数ftok将这两个值变成一个键
1
2
3
4
5
#include <sys/ipc.h>
key_t ftok(const char *path, int id);
// Returns: key if OK, (key_t)−1 on error

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

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

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

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

权限结构

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

1
2
3
4
5
6
7
8
9
10
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结构时,对所有字段都赋初值。

mode字段的值如下图所示,对于任何IPC结构都不存在执行权限

优点和缺点

XSI IPC的一个基本问题是:IPC结构是在系统范围内起作用的,没有引用计数。例如,如果进程创建了一个消息队列,并且在该队列中放入了几则消息,然后终止,那么该消息队列及其内容不会被删除。它们会一直留在系统中直至发生下列动作为止:由某个进程调用 msgrcv或msct1读消息或删除消息队列;或某个进程执行 ipcri(1)命令删除消息队列;或正在自举的系统删除消息队列。

将此与管道相比,当最后一个引用管道的进程终止时,管道就被完全地删除了。对于FIFO而言,在最后一个引用FIFO的进程终止时,虽然FIFO的名字仍保留在系统中,直至被显式地删除,但是留在FIFO中的数据已被删除了

XSI IPC的另一个问题是:这些IPC结构在文件系统中没有名字,为了支持这些IPC对象,系统增加了许多的系统调用

因为这些形式的IPC不使用文件描述符,所以不能对它们使用多路转接I/O函数

消息队列

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

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

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

创建一个队列或者打开一个现有队列函数msgget

1
2
3
4
5
#include <sys/msg.h>
int msgget(key_t key, int flag);
// Returns: message queue ID if OK, −1 on error

msgctl函数对队列执行多种操作:

1
2
3
4
5
#include <sys/msg.h>
int msgctl(int msqid, int cmd, struct msqid_ds *buf );
// Returns: 0 if OK, −1 on error

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

  • IPC_STAT 获取此队列的msqid_ds结构
  • IPC_SET 设置队列结构
  • IPC_RMID 从系统中删除该消息队列以及仍在该消息队列中的所有数据

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

1
2
3
4
5
#include <sys/msg.h>
int msgsnd(int msqid, const void *ptr, size_t nbytes, int flag);
// Returns: 0 if OK, −1 on error

消息类型结构:

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

msgrcv从队列中取用消息

1
2
3
4
5
#include <sys/msg.h>
ssize_t msgrcv(int msqid, void *ptr, size_t nbytes, long type, int flag);
// Returns: size of data portion of message if OK, −1 on error

信号量

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

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

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

下面3个特性造成了XSI信号量比较复杂:

  1. 信号量并非是单个非负值,而必需定义为含有一个或多个信号量值的集合。
  2. 信号量的创建(semget)是独立于它的初始化(sectl)的。这是一个致命的缺点,因为不能原子地创建一个信号量集合,并且对该集合中的各个信号量值赋初值。
  3. 即使没有进程正在使用各种形式的XSI IPO,它们仍然是存在的。有的程序在终止时并没有释放已经分配给它的信号量,所以我们不得不为这种程序担心。

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

1
2
3
4
5
#include <sys/sem.h>
int semget(key_t key, int nsems, int flag);
// Returns: semaphore ID if OK, −1 on error

semctl函数包含对信号量的各种操作:

1
2
3
4
5
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ... /* union semun arg */ );
// Returns: (see following)

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

1
2
3
4
5
#include <sys/sem.h>
int semop(int semid, struct sembuf semoparray[], size_t nops);
// Returns: 0 if OK, −1 on error

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

共享存储

共享存储允许两个或多个进程共享一个给定的存储区。因为数据不需要在客户进程和服务器进程之间复制,所以这是最快的一种IPC。使用共享存储时要掌握的唯一窍门是,在多个进程之间同步访问一个给定的存储区。若服务器进程正在将数据放入共享存储区,则在它做完这一操作之前,客户进程不应当去取这些数据。通常,信号量用于同步共享存储访问。下面可以看到共享内存为什么是最快的一种IPC方式:

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

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

使用函数shmgget获取一个共享存储标识符:

1
2
3
4
5
#include <sys/shm.h>
int shmget(key_t key, size_t size, int flag);
// Returns: shared memory ID if OK, −1 on error

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

1
2
3
4
5
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf );
// Returns: 0 if OK, −1 on error

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

1
2
3
4
5
#include <sys/shm.h>
void *shmat(int shmid, const void *addr, int flag);
// Returns: pointer to shared memory segment if OK, −1 on error

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

  • 如果addr为0,则此段连接到由内核选择的第一个可用地址上。这是推荐的使用方式。
  • 如果add非0,并且没有指定 SHM_RND,则此段连接到addr所指定的地址上。
  • 如果add非0,并且指定了 SHM_RND,则此段连接到(addr-( addr mod SHMLBA))所表示的地址上。 SHM_RND命令的意思是“取整”。 SHMLBA的意思是“低边界地址倍数”,它总是2的乘方。该算式是将地址向下取最近1个 SHMLBA的倍数。

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

1
2
3
4
5
#include <sys/shm.h>
int shmdt(const void *addr);
// Returns: 0 if OK, −1 on error

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