总结——进程间通信

概述

每个进程都有自己的虚拟地址空间,这既是优点又是缺点,优点在于,一个进程修改自己内存的内容不会影响其他进程。缺点在于有时需要进程间互相通信,不如线程那样便利。这就涉及到一些经典的进程IPC设计:管道、FIFO、消息队列、信号量、共享内存、套接字。

匿名管道

管道这个名词并不陌生,在linux命令中经常遇到,比如:ls -l | grep string,此时系统创建了一个子进程,创建一个管道联系父子进程,父进程运行ls -l,将其输出放入管道,子进程的输入从管道中读取,子进程对输入执行grep string,然后输出,最后的结果就是:在当前目录下寻找文件名含有string的文件并打印到屏幕上。

管道是通过pipe函数创建的:

1
2
3
#include<unistd.h>
int pipe(int fd[2]);
//成功返回0,出错返回-1

经由参数fd返回两个文件描述符:通过fd[0]从管道中读数据,fd[1]向管道中写数据。fd[1]的输出是fd[0]的输入。
通常进程会先调用pipe创建一个管道,然后调用fork创建父进程到子进程的IPC通道。

fork之后做什么取决于想要的数据流方向。如果希望父进程传数据给子进程,那么就关闭父进程的fd[0],关闭子进程的fd[1]。反之亦然。

当管道的一端被关闭时,需要注意两点

  1. 当读一个写端被关闭的管道时,在所有数据都被读取后,read返回0,表示文件结束(如果管道的写端还有进程, 则不会产生文件结束)。
  2. 如果写一个读端被关闭的管道,则产生SIGPIPE信号。如果忽略该信号或者捕捉该信号并从其处理程序返回,则write返回-1,errno设置为EPIPE。

在写管道时,常量PIPE_BUF规定了内核的管道缓冲区的大小。如果对管道调用write,并且要求写的字节数小于PIPE_BUF,则不会发生与其他进程对同一管道的write操作交叉进行的情况。
当写数据的管道没有关闭,而又没有数据可读时,read调用通常会阻塞,但是当写数据的管道关闭时,read调用将会返回0而不是阻塞。

管道的两个缺陷:

  1. 管道只能在具有公共祖先的两个进程间使用。也就是说使用管道通信的进程之间必须存在某种父子关系
  2. 历史上,它们是半双工的(即数据只能在一个方向上流动),现在有的系统提供全双工管道,但是为了可移植性,不应该预先假设支持全双工管道。

命名管道

命名管道即FIFO,匿名管道只能在两个相关的进程之间使用,但是命名管道就打破了这一局限性,它可以在任意两个不相关进程间使用。
命名管道实质上是一种文件类型。使用ls -l命令,其第一位表示为p,代表是一个管道文件。创建命名管道类似于创建文件。

使用mkfifo可以创建一个命名管道:

1
2
3
#incldue<sys/stat.h>
int mkfifo(const char *path,mode_t mode);
//成功返回0,出错返回-1

mode参数的规格说明与open函数中的mode相同。
当用mkfifo创建FIFO时,要用open来打开它。当open一个FIFO时,非阻塞标志(O_NONBLOCK)会产生以下影响:

  1. 在没有指定O_NONBLOCK的情况下,只读open要阻塞到某个其他进程为写而打开这个FIFO为止。类似的,只写open也要阻塞到某个其他进程为只读而打开它为止。
  2. 如果指定了O_NONBLOCK,则只读open立即返回。但是如果没有进程为读而打开一个FIFO,那么只写opne将返回-1,并将errno设置为ENXIO。
    类似于匿名管道,若write一个尚无进程为读而打开的FIFO,则产生SIGPIPE信号。若某个FIFO的最后一个写进程关闭了FIFO,则将为该FIFO的读进程产生一个文件结束标志。
    为一个FIFO有多个写进程是比较常见的,所以为了防止多个进程写的数据交叉,必须保证写的数据大小小于PIPE_BUF

消息队列

消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。可以通过发送消息来避免命名管道的同步和阻塞问题。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。
消息队列是消息的链接表,存储在内核中,由消息队列标识符标识。
msgget用于创建一个新队列或打开一个现有的队列。msgsnd将新消息添加到队列尾端。每个消息包含一个正的长整型类型的字段、一个非负的长度以及实际数据字节数(对应于长度),所有这些都在将消息添加到队列时,传送给msgsnd。msgrcv用于从队列中取消息。并不一定是按照先进先出序列来取消息,也可以按照消息的类型字段来取消息。

1
2
3
#include<sys/msg.h>
int msgget(key_t key,int flag);
//成功返回消息队列ID(标识符),出错返回-1

在msgget函数中的key和flag参数。在创建新的IPC结构时,如果key是IPC_PRIVATE或者和当前某种类型的IPC结构无关,则需要指明flag的IPC_CREAT标志位。在引用一个现有的IPC对象时,key必须等于队列创建时指明的key的值,并且IPC_CREAT必须不被指明。
标识符是IPC对象的内部名。为使多个合作进程能够在同一IPC上汇聚,需要提供一个外部命名方案。为此,每个IPC对象都与一个键相关联,将这个键作为该对象的外部名。

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

1
2
3
#include<sys/msg.h>
int msgctl(int msqid,int cmd,struct msqid_ds *buf);
//成功返回0,出错返回-1

cmd是将要采取的动作,它可以取3个值,

  • IPC_STAT:把msgid_ds结构中的数据设置为消息队列的当前关联值,即用消息队列的当前关联值覆盖msgid_ds的值。
  • IPC_SET:如果进程有足够的权限,就把消息列队的当前关联值设置为msgid_ds结构中给出的值
  • IPC_RMID:删除消息队列

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

1
2
3
#include<sys/msg.h>
int msgsnd(int msqid,const void *ptr,size_t nbytes,int flag);
//成功返回0,出错返回-1

正如前面所说,每个消息都由3部分组成:一个正的长整型的字段、一个非负的长度(nbytes)以及实际数据字节数(对应于长度)。消息总是放在队列尾端。
ptr参数指向一个长整型数,它包含了正的整型消息类型,其后紧随的是消息数据。
flag参数可以指定为IPC_NOWAIT。这类似于文件IO的非阻塞标志。若消息队列已满,则指定IPC_NOWAIT使得msgsnd立即出错返回EAGAIN。如果没有指定IPC_NOWAIT,则进程会一直阻塞到
①有空间可以容纳要发送的消息。
②从系统中删除了此队列。
③捕捉到一个信号,并从信号处理程序中返回。

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

msgrcv从队列中取用消息:

1
2
3
#include<sys/msg.h>
ssize_t msgrcv(int msqid,void *ptr,size_t nbytes,long type,int flag);
//成功返回消息数据部分的长度;出错返回-1

nbytes指定数据缓冲区的长度。若返回的消息长度大于nbytes,而且在flag中设置了MSG_NOERROR位,则该消息被截断,被截去部分丢弃。如果没有设置这一标志,而消息又太长,则出错返回E2BIG,消息仍留在队列中。
type == 0,返回队列中的第一个消息。
type>0,返回队列中消息类型为type的第一个消息
type<0,返回队列中消息类型值小于等于type绝对值的消息,如果这种消息有若干个,则取类型值最小的消息。
同样可以根据flag的IPC_NOWAIT可以决定是阻塞等待还是立即返回。

调用成功时,该函数返回放到接收缓存区中的字节数,消息被复制到由ptr指向的用户分配的缓存区中,然后删除消息队列中的对应消息。

与命名管道相比,消息队列的优势在于

  1. 消息队列也可以独立于发送和接收进程而存在,从而消除了在同步命名管道的打开和关闭时可能产生的困难。
  2. 同时通过发送消息还可以避免命名管道的同步和阻塞问题,不需要由进程自己来提供同步方法。
  3. 接收程序可以通过消息类型有选择地接收数据,而不是像命名管道中那样,只能默认地接收。

信号量

为了防止出现因多个程序同时访问一个共享资源而引发的一系列问题,需要一种方法,它可以通过生成并使用令牌来授权,在任一时刻只能有一个执行线程访问代码的临界区域。临界区域是指执行数据更新的代码需要独占式地执行。而信号量就可以提供这样的一种访问机制,让一个临界区同一时间只有一个线程在访问它,也就是说信号量是用来调协进程对共享资源的访问的。

信号量是一个特殊的变量,程序对其访问都是原子操作,且只允许对它进行等待(即P(信号变量))和发送(即V(信号变量))信息操作。最简单的信号量是只能取0和1的变量,这也是信号量最常见的一种形式,叫做二进制信号量。而可以取多个正整数的信号量被称为通用信号量。
信号量不同于以上三种IPC,它其实是一个计数器,用于为多个进程提供对共享的数据对象的访问。
为了获得共享资源,进程需要执行下列操作。

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

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

共享内存

顾名思义,共享内存就是允许两个不相关的进程访问同一个逻辑内存。共享内存是在两个正在运行的进程之间共享和传递数据的一种非常有效的方式,因为数据不需要在客户进程和服务器进程之间进行赋值,所以这是最快的一种IPC。不同进程之间共享的内存通常安排为同一段物理内存。进程可以将同一段共享内存连接到它们自己的地址空间中,所有进程都可以访问共享内存中的地址,就好像它们是由用C语言函数malloc分配的内存一样。而如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。

特别地:共享内存并未提供同步机制,也就是说,在第一个进程结束对共享内存的写操作之前,并无自动机制可以阻止第二个进程开始对它进行读取。所以通常需要用其他的机制来同步对共享内存的访问,比较常用的是使用互斥量。
调用shmget函数可以创建一个共享内存:

1
2
3
#include<sys/shm.h>
int shmget(key_t key,size_t size,int flag);
//成功返回共享内存标识符,出错返回-1

size是共享内存的长度,以字节为单位。实现上通常将其向上取整为页长的整数倍,若指定的size不是页长的整数倍,则最后一页余下的部分将不能再使用。如果是创建一个新的共享内存,则必须指定size,如果是获取当前已有的共享内存,则size指定为0。当创建一个新的共享内存时,其内容被初始化为0。
调用shmat可以将共享内存连接到进程的地址中:

1
2
3
#include<sys/shm.h>
void *shmat(int shmid,const void *addr,int flag);
//成功返回指向共享内存的指针,出错返回-1

addr为0表示连接到第一个可用地址上。
调用shmdt可以删除一个共享内存:

1
2
3
#incldue<sys/shm.h>
int shmdt(const void *addr);
//成功返回0,出错返回-1

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


参考资料:
《UNIX环境高级编程 3th》(APUE)
匿名管道
命名管道
消息队列
信号量
共享内存
共享内存 IBM