linux基础编程:进程通信之System V IPC:消息队列,信号量,共享内存

时间:2021-04-27 15:14:28

Linux下的进程通信基本上是从Unix平台上的进程通信手段继承而来的。而对Unix发展做出重大贡献的两大主力AT&T的贝尔实验室及BSD(加州大学伯克利分校的伯克利软件发布中心)在进程间通信方面的侧重点有所不同。前者对Unix早期的进程间通信手段进行了系统的改进和扩 充,形成了“system V IPC”,通信进程局限在单个计算机内;后者则跳过了该限制,形成了基于套接口(socket)的进程间通信机制。Linux则把两者继承了下来,如图示:


linux基础编程:进程通信之System V IPC:消息队列,信号量,共享内存

其中,最初Unix IPC包括:管道、FIFO、信号;System V IPC包括:System V消息队列、System V信号灯、System V共享内存区;Posix IPC包括: Posix消息队列、Posix信号灯、Posix共享内存区。有两点需要简单说明一下:1)由于Unix版本的多样性,电子电气工程协会(IEEE)开 发了一个独立的Unix标准,这个新的ANSI Unix标准被称为计算机环境的可移植性操作系统界面(PSOIX)。现有大部分Unix和流行版本都是遵循POSIX标准的,而Linux从一开始就遵 循POSIX标准;2)BSD并不是没有涉足单机内的进程间通信(socket本身就可以用于单机内的进程间通信)。事实上,很多Unix版本的单机 IPC留有BSD的痕迹,如4.4BSD支持的匿名内存映射、4.3+BSD对可靠信号语义的实现等等。

在文章《linux基础编程:进程通信之信号》和《linux基础编程:进程通信之管道》两篇文章中介绍了最初的Unix IPC通信机制。通过对这两种方式的理解,我们知道管道和信号都是随着进程持续而存在(IPC一直存在到打开IPC对象的最后一个进程关闭该对象为止),如果进程结束了,管道和信号都会关闭或者丢失。下面将会分别介绍基于System V IPC的通信机制:消息队列,信号灯,共享内存区。基于System V IPC的通信机制的特点是:它是随着内核的持续而存在(IPC一直持续到内核重新启动或者显示删除该对象为止)。本文将介绍System V IPC 在内核中实现的原理和以及相应的API,应用。


System V IPC原理


首先基于System V IPC的通信是基于内核来实现。首先我们来分析整个System V IPC的结构。在linux 3.6.5内核源码中我们可以在/include/linux/ipc_namespace.h文件中找到struct ipc_namespace这个结构体,该结构体是基于System V IPC 三种通信机制的命名空间或者说全局入口,在该结构体中定义了一个struct ipc_ids ids[3]结构体数组,关键的结构体代码如下:

struct ipc_namespace {
atomic_tcount;
struct ipc_idsids[3];
...
};
struct ipc_ids {
int in_use;
unsigned short seq;
unsigned short seq_max;
struct rw_semaphore rw_mutex;
struct idr ipcs_idr;
};
每一个struct ipc_ids结构体对应System V IPC 每一种通信机制,struct ipc_ids ids[3]就对应了三种IPC(msg_ids消息队列,sem_ids信号量,shm_ids共享内存区)。通过下面宏定义可以分别得到三种IPC结构体:

#define IPC_SEM_IDS     0
#define IPC_MSG_IDS 1
#define IPC_SHM_IDS 2
#define msg_ids(namespace) ((namespace)->ids[IPC_MSG_IDS])
#define sem_ids(namespace) ((namespace)->ids[IPC_SEM_IDS])
#define shm_ids(namespace) ((namespace)->ids[IPC_SHM_IDS])
每一个struct ipc_ids结构体对应System V IPC 每一种通信机制,struct ipc_ids结构体中struct idr结构体记录了该IPC所有条目(比如:如果是消息队列,此时idr中记录了系统中当前所有消息队列的信息)。在文件/include/linux/idr.h中定义struct idr结构体,它是一种类似数组的内存区域。在IPC通信中,我们把该数组的每一项条目存储内容为struct kern_ipc_perm的结构体的指针。通过/ipc/util.c文件中的int ipc_addid(struct ipc_ids* ids, struct kern_ipc_perm* new, int size)函数,可以把struct kern_ipc_perm结构体指针添加到相对应的struct ipc_ids的struct idr中,此时struct kern_ipc_perm*就指向相应的IPC的一个条目,其结构体定义如下:
struct kern_ipc_perm
{
spinlock_tlock;
intdeleted;
intid;
key_tkey;
uid_tuid;
gid_tgid;
uid_tcuid;
gid_tcgid;
umode_tmode;
unsigned longseq;
void*security;
};

对于每一种IPC具体的条目中,struct kern_ipc_perm为相应条目的第一个元素,得到struct kern_ipc_perm的指针的头指针,就相当于得到相应条目的头指针,以消息队列为例子代码如下。struct kern_ipc_perm结构体中的key_t key为该条目的唯一的key标识符。struct kern_ipc_perm结构体中还定义对应的ipc的特征信息(uid用户ID等)。

struct msg_queue {
struct kern_ipc_perm q_perm;
....
};

通过前面描述的内容,我们可以得到到每一个IPC条目的索引,下面我们将介绍具体的IPC条目的存储内容。


消息队列

消息队列就是一个消息的链表。可以把消息看作一个记录,具有特定的格式以及特定的优先级。对消息队列有写权限的进程可以向中按照一定的规则添加新消息;对消息队列有读权限的进程则可以从消息队列中读走消息。消息队列是随内核持续的,记录消息队列的数据结构位于内核中,只有在内核重起或者显示删除一个消息队列时,该消息队列才会真正被删除。
通过上面的分析我们知道通过struct kern_ipc_perm的指针可以找到相应的条目,在消息队列中,我们的每一个条目为一个消息队列msg_queue,定义在/include/linux/msg.h:
/* one msq_queue structure for each present queue on the system */
struct msg_queue {
struct kern_ipc_perm q_perm;
time_t q_stime;/* last msgsnd time */
time_t q_rtime;/* last msgrcv time */
time_t q_ctime;/* last change time */
unsigned long q_cbytes;/* current number of bytes on queue */
unsigned long q_qnum;/* number of messages in queue */
unsigned long q_qbytes;/* max number of bytes on queue */
pid_t q_lspid;/* pid of last msgsnd */
pid_t q_lrpid;/* last receive pid */

struct list_head q_messages;
struct list_head q_receivers;
struct list_head q_senders;
};
每一个消息队列包括了该队列的基本信息,struct list_head 类型的消息队列,以及当前出于阻塞状态的消息接受者和发送者。对于q_messages队列来说,每一个元素都为struct msg_msg类型:
/* one msg_msg structure for each message */
struct msg_msg {
struct list_head m_list;
long m_type;
int m_ts; /* message text size */
struct msg_msgseg* next;
void *security;
/* the actual message follows immediately */
};
该处采用了文章 《通过看linux环境相关源码学习编程》中提到的第四条的方法来存储具体的消息内容,该结构体用于内核部分存储消息。在用户空间的代码发送和接受到消息为一个如下的简单的结构体:
struct msgbuf {
long mtype; /* type of message */
char mtext[1]; /* message text */
};
mtype成员代表消息类别,从消息队列中读取消息的一个重要依据就是消息的类型;mtext是消息内容,当然长度不一定为1。因此,在用户空间里,对于发送消息来说,首先预置一个msgbuf缓冲区并写入消息类型和内容,调用相应的发送函数即可;对读取消息来说,首先分配这样一个msgbuf缓冲区,然后把消息读入该缓冲区即可。
上面基本上介绍了整个消息队列的内存模型,对消息的操作也比较少,在include/linux/ipc.h文件中定义:
#define MSGSND11//发送消息到队列
#define MSGRCV12//从队列中接受消息
#define MSGGET13//打开或创建消息队列
#define MSGCTL14//控制消息队列
第二节将详细介绍消息队列的操作。

信号量

信号灯与其他进程间通信方式不大相同,它主要提供对进程间共享资源访问控制机制。相当于内存中的标志,进程可以根据它判定是否能够访问某些共享资源,同时,进程也可以修改该标志。除了用于访问控制外,还可用于进程同步。信号灯有以下两种类型:
  • 二值信号灯:最简单的信号灯形式,信号灯的值只能取0或1,类似于互斥锁。 注:二值信号灯能够实现互斥锁的功能,但两者的关注内容不同。信号灯强调共享资源,只要共享资源可用,其他进程同样可以修改信号灯的值;互斥锁更强调进程,占用资源的进程使用完资源后,必须由进程本身来解锁。
  • 计算信号灯:信号灯的值可以取任意非负值(当然受内核本身的约束)。
和消息队列一样,通过struct kern_ipc_perm的指针可以找到相应的信号量,在System V IPC 信号量中,我们的每一个条目为一个信号量,定义在/include/linux/sem.h:
struct sem_array {
struct kern_ipc_perm____cacheline_aligned_in_smp
sem_perm;/* permissions .. see ipc.h */
time_tsem_otime;/* last semop time */
time_tsem_ctime;/* last change time */
struct sem*sem_base;/* ptr to first semaphore in array */
struct list_headsem_pending;/* pending operations to be processed */
struct list_headlist_id;/* undo requests on this array */
intsem_nsems;/* no. of semaphores in array */
intcomplex_count;/* pending complex operations */
};
其中struct sem *sem_base为信号量列表的头指针。struct sem是一个简单的数据结构:
struct sem {
intsemval;/* current value */
intsempid;/* pid of last operation */
struct list_head sem_pending; /* pending single-sop operations */
};
它维持一个当前值,最后操作的进程ID以及一个阻塞队列。在用户空间可以通过struct sembuf对sem中的信号量的值进行改变(SETVAL操作)或者通过通过联合体union semun对整个信号量进行改变(IPC_STAT SETVAL等操作),两个结构分别如下:
/* semop system calls takes an array of these. */
struct sembuf {
unsigned short sem_num;/* semaphore index in array */
shortsem_op;/* semaphore operation */
shortsem_flg;/* operation flags */
};

/* arg for semctl system calls. */
union semun {
int val;/* value for SETVAL */
struct semid_ds __user *buf;/* buffer for IPC_STAT & IPC_SET */
unsigned short __user *array;/* array for GETALL & SETALL */
struct seminfo __user *__buf;/* buffer for IPC_INFO */
void __user *__pad;
};
和消息队列一样,在在include/linux/ipc.h文件中定义相应的操作:
#define SEMOP 1//改变信号量的值
#define SEMGET 2//打开或者创建一个信号量
#define SEMCTL 3//消息量控制
#define SEMTIMEDOP 4//好像是内部使用吧,没有仔细去看
具体的操作函数以及参数含义在后面API部分会详细介绍。

共享内存

共享内存可以说是最有用的进程间通信方式,也是最快的IPC形式。两个不同进程A、B共享内存的意思是,同一块物理内存被映射到进程A、B各自的进程地址空间。进程A可以即时看到进程B对共享内存中数据的更新,反之亦然。由于多个进程共享同一块内存区域,必然需要某种同步机制,互斥锁和信号量都可以。
采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据[1]: 一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建 立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回 文件的。因此,采用共享内存的通信方式效率是非常高的。
内核中关于共享内存的实现的数据结构就不去翻阅了,感觉弄懂了前面两种以后,共享内存的性质基本上差不多。这里列出一下有关共享内存的操作:
#define SHMAT21//空间映射:把上面打开的内存区域连接到用户的进程空间中
#define SHMDT22//解除映射:将共享内存从当前进程中分离
#define SHMGET23//创建打开一个内存区域
#define SHMCTL24//内存区域的控制:包括初始化和删除内存区域。
一般对内存区域的操作是先打开-》映射-》(操作)-》(控制)-》解除映射。

API以及应用


上面从实现原理上对三种System V IPC进行介绍,我们发现其实三种通信机制和原理差不多,对其进行操作也不多,并且比较相似。下面我将介绍在用户空间通过相应的API函数来操作相应的IPC。


消息队列API

在上面原理部分我们介绍了,对消息队列的操作主要包括:MSGSND:发送消息到队列;MSGRCV:从队列中接受消息;MSGGET:打开或创建消息队列;MSGCTL:控制消息队列。
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgget(key_t key, int msgflg);
打开或创建消息队列:System V IPC中通过一个key来唯一标识一个IPC对象,在消息队列中,一个key唯一标识一个队列。msgflg低端的九个位为权限标志。如果需要创建一个新的消息队列,需要设置IPC_CREAT标志,即:msgflg |=IPC_CREAT。如果创建的ID已经存在,此时函数不会出现错误,而只是忽略创建标志。但是如果msgflg 是联合使用IPC_CREAT和IPC_EXCL,那么如果创建的ID已经存在,此时将会返回错误,可以确保创建的是一个新的IPC对象。如果成功将会返回一个队列标识符,失败返回-1.
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
发送消息到消息队列:msgid为msgget函数返回队列标识符,已经不是队列Key了。msgp为一个具体的消息内容,它指向一个struct msgbuf类型的结构体:
struct msgbuf {
long mtype; /* type of message */
char *mtext;
};
在具体的应用中,可以自定义该结构体,只要第一个字段为一个long类型的消息类型,比如,如下的结构体:
struct msgbuf {
long mtype; /* type of message */
int fromPID;
int cmdID
};
msgsz为发送消息的内容的长度,注意:该长度不包括类型字段的一个long类型的大小,比如上面例子msgsz=sizeof(msgbuf)-sizeof(long)。
msgflg主要是用来控制当前消息队列无法容纳发送过来的数据或者消息的个数达到系统的限制数目时,操作的阻塞或者直接返回。如果被设置了 IPC_NOWAIT,函数将立即返回,不会发送消息,并且返回值为-1;如果清除了该标志,函数将会挂起等待队列腾出可用空间,直到可以容纳完整消息或者消息队列被删除,或被信号中断;msgsnd的发送的数据保存完整性,要么全部发送成功,要么失败,不会发送部分数据。

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg);
从消息队列中读取消息:前三个参数和msgsnd一样,这里就不描述了。
msgtyp是一个long类型的整数,用来标识要接受消息类别。如果msgtyp=0,那么将获取队列第一个可用消息。如果msgtyp>0,那么将接受第一个相同类型的第一个消息。如果msgtyp<0,将获取类型等于或小于msgtyp绝对值的第一个消息。msgflg用来控制当前队列没有相应类型的消息可以接受时,采取的操作。如果被设置为IPC_NOWAIT,函数将会立即返回,返回值为-1。如果该标志被清除,进程将会挂起等待,直到相应的消息到达,或者消息队列被删除,或被信号中断;
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
消息队列控制函数:根据cmd的不同,该函数功能不一样,下面主要讨论三种:
  1. IPC_STAT:检索当期当前消息队列的属性,返回的值储存在一个struct msqid_ds结构体中,该结构见下面。
  2. IPC_SET:如果进程有足够权限,可以利用buf来设置队列属性。
  3. IPC_RMID:用于删除队列。
struct msqid_ds是一个定义在/include/linux/msg.h中的结构体,相对来说,还是比较负责,如下:
struct msqid_ds {
struct ipc_perm
{
__kernel_key_tkey;
__kernel_uid_tuid;
__kernel_gid_tgid;
__kernel_uid_tcuid;
__kernel_gid_tcgid;
__kernel_mode_tmode;
unsigned shortseq;
};
struct msg *msg_first;/* first message on queue,unused */
struct msg *msg_last;/* last message in queue,unused */
__kernel_time_t msg_stime;/* last msgsnd time */
__kernel_time_t msg_rtime;/* last msgrcv time */
__kernel_time_t msg_ctime;/* last change time */
unsigned long msg_lcbytes;/* Reuse junk fields for 32 bit */
unsigned long msg_lqbytes;/* ditto */
unsigned short msg_cbytes;/* current number of bytes on queue */
unsigned short msg_qnum;/* number of messages in queue */
unsigned short msg_qbytes;/* max number of bytes on queue */
__kernel_ipc_pid_t msg_lspid;/* pid of last msgsnd */
__kernel_ipc_pid_t msg_lrpid;/* last receive pid */
};
由于该结构体比较复杂,在实际开发过程中,特别在进行IPC_SET操作时候,正确的办法是先调用IPC_STAT得到这样一个结构体,然后再修改该结构体,最后再调用IPC_SET操作。

信号量API

信号量主要是用来提供对进程间共享资源访问控制机制。通常我们可能需要仅仅一个进程进入到一个称为临界区域的代码段,并对相应的资源拥有独占式的访问权。对信号量主要有两个操作P()和V()操作。假设sv为一个信号量变量,此时
  • P(sv):如果sv值是大于0,就给它减去1;如果它的值为0,就挂起进程的执行。
  • V(sv):如果有其他进程因为等待sv而挂起,就让它恢复运行;如果没有因sv等待而挂起的进程,就对该信号量进行加1操作。
从信号量的作用来说,我们知道我们对信号量的操作主要有:SEMOP:改变信号量的值;SEMGET:打开或者创建一个信号量;SEMCTL:消息量控制。对信号量的操作流程为:SEMGET--》SEMOP(-1)---》(操作临界区)--》SEMOP(+1);(SEMCTL删除或者修改信号量参数等)。具体的API函数如下
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);

打开或者创建信号量:参数key是一个键值,唯一标识一个信号灯集,用法与msgget()中的key相同;参数nsems指定打开或者新创建的信号灯集中将包含信号灯的数目,一般情况下,都是取值为1;semflg参数是一些标志位。参数key和semflg的取值,以及何时打开已有信号灯集或者创建一个新的信号灯集与msgget()中的对应部分相同,不再祥述。该调用返回与健值key相对应的信号灯集描述字。调用返回:成功返回信号灯集描述字,否则返回-1。


int semop(int semid, struct sembuf *sops, unsigned nsops);
对信号量进行PV操作:semid为semget返回的信号量描述符。我们知道在打开或者创建的时候,如果nsems参数不为1,此时semid指向的是一个信号量集,而不是单独的一个信号量。因此每次对该信号集进行操作时候必须指定需要操作的信号量数目,即nsops大小。struct sembuf *sops指向的是一个struct sembuf结构体数组,数组大小即为nsops。如果我们的信号量集只有一个信号量,此时,nsops=1,我们的sops就直接指向一个struct sembuf类型的指针。下面主要介绍一下struct sembuf数据结构,在上面原理部分已经给出该结构体的定义,为了描述,重复给一次了:
/* semop system calls takes an array of these. */
struct sembuf {
unsigned short sem_num;/* semaphore index in array */
shortsem_op;/* semaphore operation */
shortsem_flg;/* operation flags */
};
该结构比较简单,semnum是当前需要操作的信号量在信号集中编号,从0开始。
sem_flg可取IPC_NOWAIT以及SEM_UNDO两个标志。如果设置了SEM_UNDO标志,那么在进程结束时,相应的操作将被取消,这是比较重要的一个标志位。如果设置了该标志位,那么在进程没有释放共享资源就退出时,内核将代为释放。如果为一个信号灯设置了该标志,内核都要分配一个sem_undo结构来记录它,为的是确保以后资源能够安全释放。事实上,如果进程退出了,那么它所占用就释放了,但信号灯值却没有改变,此时,信号灯值反映的已经不是资源占有的实际情况,在这种情况下,问题的解决就靠内核来完成。这有点像僵尸进程,进程虽然退出了,资源也都释放了,但内核进程表中仍然有它的记录,此时就需要父进程调用waitpid来解决问题了。sem_op为操作类型.即PV。如果其值为正数,该值会加到现有的信号内含值中。通常用于释放所控资源的使用权;如果sem_op的值为负数,而其绝对值又大于信号的现值,操作将会阻塞,直到信号值大于或等于sem_op的绝对值。通常用于获取资源的使用权;如果sem_op的值为0,则操作将暂时阻塞,直到信号的值变为0。

int semctl(int semid, int semnum, int cmd, ...);
信号量控制函数:semnum为需要控制的信号量在信号集中的编号,如果信号集只有一个元素,该值为0。cmd为控制类型,对于有些操作,需要第四个参数,即为一个union semun联合体,根据cmd不同,使用联合体中不同的字段:
/* arg for semctl system calls. */
union semun {
int val;/* value for SETVAL */
struct semid_ds __user *buf;/* buffer for IPC_STAT & IPC_SET */
unsigned short __user *array;/* array for GETALL & SETALL */
struct seminfo __user *__buf;/* buffer for IPC_INFO */
void __user *__pad;
};
cmd=SETVAL:用于把信号量初始化为一个已知的值,用于第一次使用该信号量时,完成信号量值的初始化。此时使用的是union semun 中val字段。
cmd=IPC_RMID:用于删除已经不再继续使用的信号量标识符,该操作会解除所有在该信号量上的挂起进程。cmd=IPC_STAT/IPC_SET:和消息队列相似。

共享内存API

共享内存是通过把同一段内存分别映射到自己用户进程空间中,从而实现两个进程之间的通信。注意:共享内存通信本身没有提供同步机制,如果同时被多个进程进行映射和写操作,会导致破坏该内存空间的内容。因此在实际应用过程中,需要通过其他的机制来同步对共享内存的访问。共享内存的操作和前面两个差不多,这里就放在一起说了,不分开,感觉啰嗦了。
#include <sys/types.h>
#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);//创建共享内存
void *shmat(int shmid, const void *shmaddr, int shmflg);//映射到自己的内存空间
int shmdt(const void *shmaddr);//解除映射
int shmctl(int shmid, int cmd, struct shmid_ds *buf);//控制共享内存

sheget为创建或者打开一个共享内存,成功就返回相应的共享内存标识符,否则就返回-1。shmflg低端9位为权限标志,利用共享内存进行通信时候,可以利用该标志对共享内存进行只读,只写等权限控制。

shmat为空间映射。通过创建的共享内存,在它能被进程访问之前,需要把该段内存映射到用户进程空间。shmaddr是用来指定共享内存映射到当前进程中的地址位置,要想该设置有用,shmflg必须设置为SHM_RND标志。大部分情况下,应该设置为为空指针(void *)0。让系统自动选择地址,从而减小程序对硬件的依赖性。shmflg除了上面的设置以外,还可以设置为SHM_RDONLY,使得映射过来的地址只读。如果函数调用成功,返回映射的地址的第一个字节,否则返回-1。

shmdt用于解除上面的映射

shmctl用于控制共享内存,相比上面几个控制函数,这里的比较简单,明确的三个参数。struct shmid_ds定义在include/linux/shm.h,如下。cmd有IPC_STAT,IPC_SET,IPC_RMID含义和消息队列一样的。好了。好像很简单一样。。。。linux基础编程:进程通信之System V IPC:消息队列,信号量,共享内存

struct shmid_ds {
struct ipc_perm
{
__kernel_key_tkey;
__kernel_uid_tuid;
__kernel_gid_tgid;
__kernel_uid_tcuid;
__kernel_gid_tcgid;
__kernel_mode_tmode;
unsigned shortseq;
};
intshm_segsz;/* size of segment (bytes) */
__kernel_time_tshm_atime;/* last attach time */
__kernel_time_tshm_dtime;/* last detach time */
__kernel_time_tshm_ctime;/* last change time */
__kernel_ipc_pid_tshm_cpid;/* pid of creator */
__kernel_ipc_pid_tshm_lpid;/* pid of last operator */
unsigned shortshm_nattch;/* no. of current attaches */
unsigned short shm_unused;/* compatibility */
void *shm_unused2;/* ditto - used by DIPC */
void*shm_unused3;/* unused */
};

总结