System IPC包括三种不同的进程间通信机制

  • 消息队列用来在进程之间传递消息。消息队列和管道有点像,但存在两个重大差别。第一是消息队列是存在边界的,这样读者和写者之间以消息进行通信,而不是通过无分隔符的字节流进行通信的。第二是每条消息包括一个整形的type字段,并且可以通过类型类选择消息而无需以消息被写入的顺序来读取消息
  • 信号量允许多个进程同步它们的动作。一个信号量是一个由内核维护的整数值,它对所有具备相应权限的进程可见。一个进程通过对信号量的值进行相应的修改来通知其他进行它正在执行某个动作
  • 共享内存使得多个进程能够共享内存(即同被映射到多个进程的虚拟内存的页帧)的同一块区域(称为一个段)。由于访问用户空间内存的操作是非常快的,因此共享内存是其中一种速度最快的IPC方法:一旦进程更新了共享内存,那么这个变更会立即对共享同一个内存段的其他进程可见。

这三种 IPC 机制在功能上存在着很大的差异,但把它们放在一起讨论是有原因的。其中一个原因是它们是一同被开发出来的。另一个原因是它们的编程接口都具备一些特性,因此很多同样的概念都适用于所有这些机制。

  • SUSv3 因需遵从 XSI 而要求实现 System V IPC,因此有时候这种机制也被称为 XSI IPC。
  • System V IPC 是一个通过 CONFIG_SYSVIPC 选项进行配置的内核选项。

概述

下表对使用System V IPC对象需要用到的头文件和系统调用进行了总结

接口

消息队列

信号量

共享内存

头文件

<sys/msg.h>

<sys/sem.h>

<sys/shm.h>

关联数据结构

msqid_ds

semid_ds

shmid_ds

创建/打开对象

msgget()

semget()

shmget() + shmat()

关闭对象

(无)

(无) shmdt()

控制操作

msgctl()

semctl()

shmctl()

执行 IPC

msgsnd()——写入消息

semop()——测试/调整信号量

访问共享区域中的内存

msgrcv()——接收消息

创建和打开一个System V IPC对象

每个System V IPC机制都有一个相关的get系统调用(msgget()、semget()或 shmget()),它与文件上的open()系统调用类似。给定一个整数key(类似文件名),get调用完成下列某个操作:

  • 使用给定key创建一个新的IPC对象并返回一个唯一的标识符来标识该对象
  • 返回一个拥有给定的 key 的既有 IPC 对象的标识符------即打开一个既有 IPC 对象。在这种情况下,get调用所做的事情是将一个数字(key)转换为另一个数字(标识符)

在 System V IPC 的上下文中的对象与面向对象程序设计中的对象毫无关系。这个术语仅仅用来将 System V IPC 机制与文件区分开来。尽管文件和 System V IPC 对象之间存在几点类似之处,但与标准的 UNIX 文件 I/O 模型相比,IPC 对象的用法在几个重要方面都存在差异,这也是 System V IPC 机制之所以复杂的一个原因。

IPC标识符与文件描述符类型,在后继所有引用该IPC对象的系统调用中都需要用到它。但这两者之间存在一个重要的语义上的差别。文件描述符是一个进程特性,而IPC标识符则是对象本身的一个属性并且对系统全局可见。所有访问同一对象的进程使用同样的标识符。这意味着如果知道一个IPC对象已经存在,那么可以跳过get调用,只要能够通过某种机制来获得对象的标识符即可。比如,创建对象的进程可以将标识符写入一个可供其他进程读取的文件。

下面的例子展示了如何创建一个System V 消息队列。

id = msgget(key, IPC_CREAT | S_IRUSR | S_IWUSR);
if(id == -1){
	perror("msgget");
	exit(EXIT_FAILURE);
}

如果没有与给定的 key 对应的 IPC 对象存在并且在 flags 参数中指定了 IPC_CREAT(与open()的O_CREAT 标记类似),那么 get 调用会创建一个新的 IPC 对象。如果不存在相应的 IPC对象并且没有指定 IPC_CREAT(并且没有将 key 指定为IPC_PRIVATE),那么 get 调用会失败并返回 ENOENT 错误

一个进程可以通过指定 IPC_EXCL 标记(类似于 open()的 O_EXCL 标记)来确保它是创建 IPC 对象的进程。如果指定了 IPC_EXCL 并且与给定 key 对应的 IPC 对象已经存在,那么get 调用会失败并返 EEXIST 错误。

IPC对象删除和对象持久

各种 System V IPC 机制的 ctl 系统调用(msgctl()、semctl()、shmctl())在对象上执行一组控制操作,其中很多操作都是特定于某种IPC机制的,但是有一些是适用于所有的IPC机制的,其中一个就是IPC_RMID控制操作,他可以用来删除一个对象。比如下面可以删除一个共享内存对象:

if(shmctl(id, IPC_RMID, NULL) == -1){
	exit(EXIT_FAILURE);
}

对于消息队列和信号量来讲,IPC对象的删除是立即生效的,对象中包含的所有信息都会被销毁,不管是否有其他进程仍然在使用该对象(这也是System IPC对象的操作与文件的操作不同的一个地方。在文件操作中,如果删除了指向文件的最后一个链接,那么实际上只有当所有引用该文件的打开着的文件描述符都被关闭了之后才会删除该文件)

共享内存对象的删除操作是不相同的,在shmctl(id,IPC_RMID, NULL)调用之后,只有当所有使用该内存段的进程与该内存段分离之后(使用 shmdt())才会删除该共享内存段。(这一点与文件删除更加接近。)

System V IPC具有持久性。一旦被创建之后,一个对象就一直存在直到它被显示的删除或者系统被关闭。System V IPC对象的这个属性是非常有用的,因为一个进程可以创建一个对象、修改其状态、然后退出并使得在后面某个时刻启动的进程可以访问这个对象。但是这种属性也是存在缺点的,其原因如下:

  • 系统对每种类型的IPC对象的数量是有限制的。如果没有删除不用的对象,那么应用程序最终可能会因达到这个限制而发送错误。
  • 在删除一个消息队列或者信号量时,地锦草应用程序可能难以确定哪个进程是最后一个需要访问对象的进程,从而导致难以确定何时可以安全的删除对象。这里的问题是这些对象是无连接的------内核不会记录哪个进程打开了对象(共享内存段不存在这个确定,因为它们的删除操作的语义不同)

IPC Key

System V IPC key 是一个整数值,其数据类型为 key_t。IPC get 调用将一个 key 转换成相应的整数 IPC 标识符。这些调用能够确保如果创建的是一个新 IPC 对象,那么对象能够得到一个唯一的标识符,如果指定了一个既有对象的 key,那么总是会取得该对象的(同样的)标识符。(在内部,内核会为各种IPC机制维护这一个数据结构将key映射成标识符)

那么如何产生唯一的key呢?这个问题存在三种解决方案

  • 随机地选取一个整数值作为 key 值,这些整数值通常会被放在一个头文件中,所有使用 IPC 对象的程序都需要包含这个头文件。这个方法的难点在于可能会无意中选取了一个已被另一个应用程序使用的值。
  • 在创建IPC对象的get调用中将IPC_PRIVATE常量作为key的值,这样就会导致每个调用都会创建一个全新的IPC对象,从而确保每个对象都拥有一个唯一的key
  • 使用ftok()函数生成一个(接近唯一)key

IPC_PRIVATE 和 ftok()是通常采用的技术。

使用 IPC_PRIVATE 产生一个唯一的 key

在创建一个新 IPC 对象时必须要像下面这样将 key 指定为 IPC_PRIVATE。

id = msgget(IPC_PRIVATE, S_IRUSR | S_IWUSR);

在上面的代码中无需指定 IPC_CREAT 和 IPC_EXCL 标记

使用 ftok()产生一个唯一的 key

ftok()(file to key)函数返回一个适合在后继对某个System V IPC get系统调用进行调用时使用的key值

NAME
       ftok -将路径名和项目标识符转换为 System V IPC 密钥 

SYNOPSIS
       #include <sys/types.h>
       #include <sys/ipc.h>

       key_t ftok(const char *pathname, int proj_id);

key值是使用实现定义的算法根据提供的pathname和proj_id值生成的。SusV3要求如下:

  • 算法只使用 proj_id的最低的 8 个有效位
  • 应用程序必须要确保 pathname 引用一个可以应用 stat()的既有文件(否则 ftok()会返回−1)
  • 如果将引用同一个文件(即 i-node)不同的路径名(链接)传递给了 ftok()并且指定了同样的 proj_id值,那么函数必须要返回同样的 key 值

换句话说,ftok()使用i-node号来生成key值,而并没有使用文件名来生成key值(由于ftok()算法依赖于i-node号,因此在应用程序的生命周期中不应该将文件删除和重新创建,因为重新创建文件时可能会分配到一个不同的i-node号)。

proj_id的目的仅仅是允许从同一个文件中生成多个key,这对于需要创建同种类型的多个IPC镀锡的应用程序来讲是有用的。

SUSv3 并没有规定当 proj 的值为 0 时 ftok()的行为。在 AIX 5.1 上,当 proj 为 0 时 ftok()返回−1。在 Linux 上,这个值没有特殊的含义,但可移植的应用程序应该避免将 proj 值设置为 0.

通常,传递给fork()的pathname会引用构成应用程序或者由应用程序创建的文件或者目录之一,协同运行的进程会将同样的pathname传递给ftok()

在 Linux 上,ftok()返回的 key 是一个 32 位的值,它通过取 proj 参数的最低 8 个有效位、包含该文件所属的文件系统的设备的设备号(即次要设备号)的最低 8 个有效位以及 pathname所引用的文件的 i-node 号的最低 16 个有效位组合而成。(后两项信息通过在 pathname 上调用stat()获得。

glibc ftok()的算法与其他 UNIX 实现所采用的算法类似,它们都存在一个类似的限制:两个不同的文件可能会产生同样的 key 值(可能性非常小)。之所以会发生这样的情况是因为不同文件系统上的两个文件的i-node号的最低有效位可能会相同,并且两个不同的磁盘设备可能有同样的次要设备号号。但在实践中,不同的应用程序产生同样的 key 值的可能性非常非常小以至于使用 ftok()产生 key 已经是一项可靠的技术了。

ftok()的典型用法如下所示。

key_t key;
int id;


key = ftok("mydir/myfile", 'x');
if(key == -1){
	exit(EXIT_FAILURE);
}

id = msgget(key, IPC_CREAT | S_IRUSR | S_IWUSR);
if(id == -1){
	perror("msgget");
	exit(EXIT_FAILURE);
}

关联数据结构和对象权限

内核为System V IPC对象的每个实例都维护这一个关联数据结构。这个数据结构的形式因IPC机制(消息队列、信号量、或共享内存)的不同而不同,它是在各个IPC机制对应的头文件中对应的。

一个IPC对象的关联数据结构会再通过相应的get系统调用创建对象时进行初始化。对象一旦被创建之后,程序就可以通过指定IPC_STAT操作类型使用合适的ctl系统调用来获取这个数据结构的一个副本。使用IPC_SET操作可以修改这个数据结构中的部分数据。

除了各种IPC对象特有的数据结构外,所有三种 IPC 机制的关联数据结构都包含一个子结构 ipc_perm,它保存了用于确定对象之上的权限的信息。

struct ipc_perm{
	key_t         __key;
	uid_t         uid;           // owner's user ID
	git_t         gid;           // owner's group ID
	uid_t         cuid;          // creator's user ID
	gid_t         cgid;          // creator's group ID
	unsigned short mode;         // permission
	unsigned short _seq;         // squence number   
};

SUSv3 要求 ipc_perm 结构中除__key 和__seq 字段之外的所有其他字段都要具备。大多数UNIX 实现都提供了相应的字段。

  • uid 和 gid 字段指定了 IPC 对象的所有权
  • cuid和cgid字段保存着创建该对象的进程的用户ID和组ID

一开始,相应的用户和创建者ID字段的值是一样的,它们都源自调用进程的有效ID。创建者ID是不可变的,而所有者ID可以通过IPC_SET操作进行修改。下面代码演示了如何修改共享内存段的uid:

struct shmid_ds shmds;

if(shmctl(id, IPC_STAT, &shmds) == -1) {  // fetch from kernel
	exit(EXIT_FAILURE);
}
shmds.shm_perm.uid = newuid;
if(shmctl(id, IPC_SET, &shmds) == -1) { // update kernet copy
	exit(EXIT_FAILURE);
}
  • ipc_perm 子结构的 mode 字段保存着 IPC 对象的权限掩码。
  • 这些权限是使用在创建该对象的 get 系统调用中指定的 flags 参数的低 9 位初始化的,但后面使用 IPC_SET 操作则可以修改这个字段的值
  • 与文件一样,权限被分成了三类——owner(也称为 user)、group 以及 other——并且可以为各个类别指定不同的权限。但 IPC 对象的权限模型与文件权限模型存在一些显著差别。
  • 对于 IPC 对象来讲只有读和写权限有意义。(对于信号量来讲,写权限通常被称为修改(alter)权限。)执行权限是没有意义的,在执行大多数访问检测时通常会忽略这个权限。
  • 权限检测会根据进程的有效用户 ID、有效组 ID 以及辅助组 ID 来进行。(这与 Linux上文件系统权限检测不同,它使用的是进程的文件系统 ID

IPC对象上的进程权限分配的准确规则如下:

  • 如果进程是特权进程(CAP_IPC_OWNER),那么所有权限都会被赋予 IPC 对象。
  • 如果进程的有效用户 ID 与 IPC 对象的所有者或创建者 ID 匹配,那么会将对象的 owner(user)的权限赋予进程
  • 如果进程的有效用户 ID 或任意一个辅助组 ID 与 IPC 对象的所有者组 ID 或创建者组ID 匹配,那么会将对象的 group 的权限赋予进程。
  • 否则会将对象的 other 的权限赋予进程。

在内核代码中,只有当一个进程没有通过其他测试被赋予所需的权限时才会去测试该进程是否是一个特权进程。之所以这样做是为了避免不必要的设置ASU进程标记,该标记用于指示进程是否使用超级用户权限

注意 IPC_PRIVATE key 值的使用和 IPC_EXCL 标记的存在不会影响进程对 IPC 对象的访问,这种访问权限只由对象的所有者和权限来确定

如何解释一个对象的读和写权限以及是否需要这些权限依赖于对象的类型以及所执行的操作

当需获取一个既有IPC对象的标识符而执行一个get调用时会进行初次权限检测以确定在flags 参数中指定的权限与既有对象上的权限是否匹配。如果不匹配,那么 get 调用会失败并返回 EACCES 错误

其他常见操作所需的权限如下所述

  • 从对象中获取信息(如从消息队列中读取一条消息,获取一个信号量的值,或因读取而附上一个共享内存段)需要读权限
  • 更新对象中的信息(如向消息队列写入一条消息,修改一个信号量的值,或因写入而附上一个共享内存段)需要写权限
  • 获取一个 IPC 对象的关联数据结构的副本(IPC_STAT ctl 操作)需要读权限。
  • 删除一个 IPC 对象(IPC_RMID ctl 操作)或修改其关联数据结构(IPC_SET ctl 操作)不需要读或写权限,相反,调用进程必须是特权进程(CAP_SYS_ADMIN)或有效用户 ID 与对象的所有者用户 ID 或创建者用户 ID 匹配(否则返回错误 EPERM)。