从操作系统的IPC角度深入理解消息队列模型

System V消息队列

管道和FIFO都是字节流的模型,这种模型不存在记录边界。如果从管道里面读出100字节,你无法确认这100字节是单次写入的100字节,还是分10次写入的。管道或FIFO里的数据如何解读,完全取决于写入进程和读取进程之间的约定。

从这个角度上讲,System V消息队列和POSIX消息队列都是由于管道和FIFO的。原因是消息队列机制中,双方是通过消息来通信的,无需花费精力从字节流中解析出完整的消息。

System V消息队列比管道或FIFO优越的第二个地方在于每条消息都有type字段,消息的读取进程可以通过type字段来选择自己感兴趣的消息,也可以根据type字段来实现按消息的优先级进行读取,而不一定要按照消息生成的顺序依次读取。

内核为每一个System V消息队列分配了一个msg_queue类型的结构体,其成员变量和各自的含义如下所示:

struct msg_queue {
struct kern_ipc_perm q_perm;
time_t q_stime; /* 上一次 msgsnd的时间*/
time_t q_rtime; /* 上一次 msgrcv的时间 */
time_t q_ctime; /* 属性变化时间 */
unsigned long q_cbytes; /* 队列当前字节总数*/
unsigned long q_qnum; /*队列当前消息总数*/
unsigned long q_qbytes; /*一个消息队列允许的最大字节数*/
pid_t q_lspid; /*上一个调用msgsnd的进程ID*/
pid_t q_lrpid; /*上一个调用msgrcv的进程ID*/
struct list_head q_messages;
struct list_head q_receivers;
struct list_head q_senders;
};

1. 创建或打开一个消息队列

消息队列的创建或打开是由msgget函数来完成的,成功后,获得消息队列的标识符ID,函数接口定义如下:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg)

当调用成功时,返回消息队列的标识符,后续的msgsnd、msgrcv和msgctl函数都通过该标识符来操作消息队列。当函数调用失败时,返回-1,并且设置相应的errno。

关于创建消息队列,一个很容易想到的问题是:操作系统到底允许创建多少个消息队列?

有三种方法可以查看系统消息队列个数的上限,如下所示。

方法一:通过procfs查看:

cat /proc/sys/kernel/msgmni
3969

方法二:通过sysctl查看:

sysctl kernel.msgmni
kernel.msgmni = 3969

方法三:通过ipcs命令查看:

ipcs -q -l
------ Messages Limits --------
max queues system wide = 3969
max size of message (bytes) = 8192
default max size of queue (bytes) = 16384.

操作系统会根据系统的硬件情况(主要是内存大小),计算出一个合理的上限值,因此不同的硬件环境下,该值是不同的。当然无论该值设置为多少,内核都存在硬上限IPCMNI(32768)

当然可以通过如下的手段,修改msgmni的值,从而允许创建更多的消息队列。

方法一:通过procfs来修改

echo 20000 > /proc/sys/kernel/msgmni
cat /proc/sys/kernel/msgmni
20000

方法二:通过sysctl-w来修改

sysctl -w kernel.msgmni=20000

上述两种方法都是立即生效,但是一旦系统重启,设置就失去了。要想确保重启后依然有效,需要将配置写入/etc/sysctl.conf。

kernel.msgmni=20000

注意写入/etc/sysctl.conf并不会立即生效,需要执行sysctl-p重新加载,才能生效。

2. 发送消息

获取到消息队列的标识符之后,可以通过调用msgsnd函数向队列插入消息。内核会负责将消息维护在消息队列中,等待另外的进程取走消息,从而完成通信的全过程。

msgsnd函数的定义如下:

#include <sys/types.h>
#include <sys/ipc.h>
include <sys/msg.h>
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

其中msgqid是由msgget返回的标识符ID。

参数msgp指向用户定义的缓冲区。它的第一个成员必须是一个指定消息类型的long型,后面跟着消息文本的内容。通常其定义如下:

struct msgbuf {
long mtype; /*消息类型,必须大于0*/
char mtext[1]; /*消息体,不一定是字符数组,可以是任意结构*/
};

每条消息只能存放一个字符?并非如此。事实上可以是任意结构,mtext是由程序员定义的结构,其长度和内容都是程序员控制的,只要发送方很接收方约定好即可。比如可以将结构体定义如下:

struct private_buf {
long mtype;
struct pirate_info {
/*定义你需要的成员变量*/
} info;
};

第三个参数msgsz制定了mtetx字段中包含的字节数。消息队列单条消息的大小是有上限的,上限值为MSGMAX,,记录在/proc/sys/kernel/msgmax中:

cat /proc/sys/kernel/msgmax
8192
sysctl kernel.msgmax
kernel.msgmax = 8192

如果消息的长度超过了MSGMAX,那么msgsnd函数返回-1,并置errno为EINVAL。

下面以发送字符串消息为例,介绍msgsnd函数所需的步骤:

  1. 因为glibc没有定义msgbuf结构体,因此首先要定义msgbuf结构体
  2. 分配一个类型为msgbuf,长度足以容纳字符串的缓冲区mbuf。
  3. 将message的内容拷贝到mbuf->mtext中去。
  4. 在mbuf->mtype中设置消息类型
  5. 调用msgsnd发送消息
  6. 释放mbuf

注意两点,既要对msgsnd进行错误检测和及时释放mbuf,以防止内存泄漏。

最后一个参数msgflg是一组标志位的位掩码,用于控制msgsnd的行为。目前只定义了IPC_NOWAIT一个标志位。

IPC_NOWAIT表示执行一个无阻塞的发送操作。当没有设置IPC_NOWAIT标志位,如果消息队列满了,那么msgsnd函数就会陷入阻塞,直到队列有足够的空间来存放这条消息为止。但是如果设置了IPC_NOWAIT标志位,那么msgsnd函数就不会陷入阻塞了,而是立刻返回失败,并置errno为EAGAIN。

那么什么情况下,消息队列才能被称为是满的?

任何一个消息队列,容纳的字节数是有有上限的。这个上限值为MSGMNB,该值被记录在/proc/sys/kernel/msgmnb中:

cat /proc/sys/kernel/msgmnb
16384
sysctl kernel.msgmnb
kernel.msgmnb = 16384

内核中消息队列对应的数据结构msg_queue中维护有当前字节数、当前消息数及允许的最大字节数等信息:

struct msg_queue{
...
time_t q_stime; /*最后调用msgsnd的时间*/
unsigned long q_cbytes; /*消息队列当前字节的总数*/
unsigned long q_qnum; /*消息队列当前消息的个数*/
unsigned long q_qbytes; /*消息队列允许的消息最大字节数*/
pid_t q_lspid; /*最后调用msgsnd的进程ID*/
...}

检查消息队列是否满的逻辑非常简单,内核判断能否立刻发送消息的逻辑如下:

if (msgsz + msq->q_cbytes <= msq->q_qbytes &&
1 + msq->q_qnum <= msq->q_qbytes) {
break;
}

3. 接收消息

有发送就要有接收,没有接收者的消息是没有意义的。System V消息队列用msgrcv函数来接收消息。

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz,
long msgtyp,int msgflg);

其中前三个参数与msgsnd的含义是一致的。msgrcv调用进程也需要定义结构体,而结构体的定义要和发送端的定义一致,并且第一个字段必须是long类型,代码如下所示:

struct private_buf {
	long mtype;
	struct pirate_info { 
		/*定义你需要的成员变量*/
		} info;
};

对于具有固定长度的消息体来讲,只要发送方和接收方的结构体达成一致,就不会存在风险。但是如果消息体是变长的,情况就复杂了点。因为不能预先得知收到消息体的长度,因此接收端的缓冲区要足够大,防止消息队列中的消息长度大于缓冲区的大小。

msgrcv函数的第四个参数msgtyp是消息队列的精华,提取消息时,可以选择进程感兴趣的消息类型。正是基于这个参数,读取消息的顺序才无须和发送顺序一致,然后就可以演化出很多用法。

第5个参数是可选标志位。msgrcv函数有3个可选标志位。
·IPC_NOWAIT:如果消息队列中不存在满足msgtyp要求的消
息,默认情况是阻塞等待,但是一旦设置了IPC_NOWAIT标志位,
则立即返回失败,并且设置errno为ENOMSG。
·MSG_EXCEPT:这个标志位是Linux特有的,只有当msgtyp大
于0时才有意义,含义是选择mtype!=msgtyp的第一条消息。
·MSG_NOERROR:前面也提到过,在消息体变长的情况下,可
能事前并不知道消息体的大小,尽管要求maxmsgsz应尽可能地大,
但是仍然存在maxmsgsz小于消息体大小的可能。如果发生这种情
况,默认情况是返回错误E2BIG,但是如果设置了MSG_NOERROR
标志位,情况就不同了,此时会将消息体截断并返回。

msgrcv函数调用成功时,返回消息体的大小;失败时返回-1,并且设置errno。另外msgrcv函数和msgsnd函数一样,如果被信号中断,则不会重启系统调用,哪怕安装信号时设置了SA_RESTART标志位。

System V消息队列存在一个问题,即当消息队列中有消息到来时,无法通知到进程。消息队列的读取者进程,要么以阻塞的方式调用msgrcv函数,阻塞在消息队列上直到消息出现;要么以非阻塞(IPC_NOWAIT)的方式调用msgrcv函数,失败返回,过段时间再重试。

4. 控制消息队列

msgctl函数可以控制消息队列的属性,其接口定义如下:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgctl(int msqid, int cmd, struct msqid_ds *buf);

该函数提供的功能取决于cmd字段,msgctl支持的操作

标识符与IPC key

System V IPC相关系统接口:在这里插入图片描述

System V IPC未遵循“一切都是文件”的Unix哲学,而是采用标识符ID和键值来标识一个System V IPC对象。每种System V IPC都有一个相关的get调用,该函数返回一个整形标识符ID,System V IPC后续的函数操作都要作用在该标识符ID上。

System V IPC对象的作用范围是整个操作系统,内核没有维护引用计数。调用各种get函数返回的ID是操作系统范围内的标识符,对于任何进程,无论是否存在亲缘关系,只要有相应的权限,都可以通过操作System V IPC对象来达到通信的目的。

System V IPC对象具有内核持久性。哪怕创建System V IPC对象的进程已经退出,哪怕有一段时间没有任何进程打开该IPC对象,只要不执行删除操作或系统重启,后面启动的进程依然可以使用之前创建的System V IPC对象来通信

此外,我们无法像操作文件一样来操作System V IPC对象。System V IPC对象在文件系统中没有实体文件与之关联。我们不能用文件相关的操作函数来访问它或修改它的属性。所以不得不提供专门的系统调用(如msgctl、semop等)来操作这些对象。在shell中无法用ls查看存在的IPC对象,无法用rm将其删除,也无法用chmod来修改它们的访问权限。幸好Linux提供了ipcs、ipcrm和ipcmk等命令来操作这些对象。

还有System V IPC对象不是文件描述符,所以无法使用基于文件描述符的多路I/O技术。

1. 标识符与IPC Key

System V IPC对象是靠标识符ID来识别和操作的。该标识符要具有系统唯一性。这和文件描述符不同,文件描述符是进程内有效的。一个进程内的文件描述符4和另一个进程的文件描述符4可能毫不相关。但是IPC的标识符ID是操作系统的全局变量,只要知道该值具有相应的权限,任何进程都可以通过标识符进行进程间通信。

三种IPC对象操作的起点都是调用相应的get函数来获取标识符ID,如消息队列的get函数为:

int msgget(key_t key, int msgflg);

其中第一个参数是key_t类型,它其实是一个整形的变量。IPC的get函数将key转换成相应的IPC标识符。根据IPC get函数中的第二个参数oflag的不同,会有不同的控制逻辑。

因为key可以产生IPC标识符,就是同一个key调用IPC的get函数总是返回同一个整形值。实际上并非如此。在IPC对象的生命周期中,key到标识符ID的映射是稳定不变的,即同一个key调用get函数,总是返回相同的标识符ID。但是一旦key对应的IPC对象被删除或系统重启后,则重新使用key创建的新的IPC对象分配的标识符很可能是不同的。

不同进程可通过同一个key获取标识符ID,进而操作同一个System V IPC对象。那么现在问题就演变成了如何选择key。

对于key的选择,存在以下三种方法

  1. 第一种方法是随机选择一个整数值作为key值。作为key值的整数通常被放在一个头文件中,所有使用该IPC对象的程序都要包含该头文件。需要注意的是,要防止无意中选择了重复的key值,从而导致不需要通信的进程之间意外通信,以至发生程序混乱。一个技巧是将项目用到的所有key放入同一个同文件中,这样就可以方便的检查是否有重复的key值。
  2. 第二种方法是使用IPC_PRIVATE,使用方法如下:
id = msgget(IPC_PRIVATE,S_IRUSR | S_IWUSR);

这种方法无须指定IPC_CREATE和IPC_EXCL标志位,就能创建一个新的IPC对象。使用IPC_PRIVATE 时总是会创建新的IPC对象,从这个角度看将其称之为IPC_NEW或许更合理。

不过,使用IPC_PRIVATE来得到IPC标识符会存在一个问题,即不相干的进程无法通过key值来得到同一个IPC标识符。因为IPC_PRIVATE总是创建一个新的IPC对象。因此IPC_PRIVATE一般用于父子进程,父进程调用fork之前创建IPC对象,创建子进程后,子进程也就继承了IPC标识符,从而父子进程可以通信。当然无亲缘关系的进程也可以使用IPC_PRIVATE,只是稍微麻烦了一点,IPC对象的创建者必须想办法将IPC标识符共享出来,让其他进程有办法获取到,从而通过IPC标识符进行通信。

  1. 第三种方法是使用ftok函数,根据文件名生成一个key。ftok是file to key的意思,多个进程通过同一个路径名获得相同的key值,进而得到同一个IPC标识符。

ftok函数接口定义如下:

#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);

在Linux实现中,该接口把通过path-name获取的信息和传入的第二个参数的低8位糅合在一起,得到一个整形的IPC key值 ,如图所示。需要注意的是,pathname对应的文件必须是存在的。

这个函数在Linux上的实现是:按照给定的路径名,获取到文件的stat信息,从stat信息中取出st_dev和st_ino,然后结合给出的proj_id,按照图所示的算法获取到32位的key值。
在这里插入图片描述

2. IPC的公共数据结构

三种System V IPC对象有很多共性,从代码层面上看也有很多公共的部分。权限结构就是其中一个。IPC的权限结构至少包括以下成员:

struct ipc_perm{
key_t key;
uid_t uid;
gid_t gid;
uid_t cuid;
gid_t cgid;
mode_t mode;
ulong_t seq ;
};
/消息队列控制相关的结构体/
struct msqid_ds {
struct ipc_perm msg_perm;
...
}
/*信号量控制相关的结构体*/
struct semid_ds {
struct ipc_perm sem_perm;
...
}
/*共享内存控制相关的结构体*/
struct shmid_ds {
struct ipc_perm shm_perm;
...
}

uid和gid字段用于指定IPC对象的所有权。cuid和cgid字段保存着创建该IPC对象的进程的有效用户ID和有效组ID。初始情况下,用户ID(uid)和创建者ID(cuid)的值是相同的。它们都是调用进程的有效ID。但是创建者ID(cuid)是不可改变的,而所有者ID则可以通过IPC_SET来改写

下面的代码演示了如何修改共享内存的uid字段

struct shmid_ds shm_ds;
if(shmctl(id,IPC_STAT,&shm_ds)) == -1
{
/*error handler*/
}
shm_ds.shm_perm.uid = newuid;
if(shmctl(id,IPC_SET,&shm_ds) == -1)
{
/*error handle*/
}

mode是用来控制读写权限。所有的System V IPC对象都不具备执行权限,只有读写权限。其中对于信号量而言,写权限意味着修改权限。IPC对象的权限控制,分别具有读写权限。

和文件的权限有点类似,IPC对象的权限被分成了三类:owner、group和other。创建对象时可以为各个类别设定不同的访问权限,代码如下:

msg_id = msgget(key,IPC_CREAT | S_IRUSR | S_IWUSR |S_IRGRP);
msg_id = msgget(key,IPC_CREAT | 0640);

当以一个进程尝试对IPC对象执行某种操作的时候,首先会检查权限。检查逻辑如下:

  1. 如果进程是特权进程,那么进程拥有对IPC对象的所有权限。
  2. 如果进程的有效用户ID与IPC对象的所有者或创建者ID匹配,那么会将对象的owner的权限赋值给进程。
  3. 如果进程的有效用户ID或任意一个辅助组ID与IPC对象的所有者组ID或创建者组ID匹配,那么会将IPC对象的group的权限赋予进程。
  4. 否则,将IPC对象的other权限赋予进程。

数据结构ipc_perm中的key和seq也很有意思。key比较简单,就是调用get函数创建IPC对象时传递进去的key值。如果key的值是IPC_PRIVATE,则实际的key值是0。

和key相比,成员变量seq就不那么好理解了。进程分配文件描述符时采用的是最小可用算法。比如文件描述符5曾经被分配给文件A,但是很快进程关闭了文件A。如果进程尝试打开另外一个文件,此时如果5是最小可用的槽位,那么新打开的文件描述符就是5.但是IPC对象的标识符ID分配不能采用这个算法。因为对个进程要通过标识符ID来通信,而标识符ID是整个系统内有效的。如果采用最小可用的算法,一般来讲,IPC对象的个数不会太多,那么这个数字很容易就被猜到了。举例来说,如果存在一个恶意程序要攻击消息队列,它只需要尝试很小范围内的数字,就可以猜到IPC对象的标识符ID,进而偷偷取走信息队列里面的信息。

内核为每一种System V IPC维护了一个ipc_ids类型的结构体。该结构体的组成如图所示:
在这里插入图片描述
结构体中seq字段记录了开机依赖创建该IPC对象的流水号。创建时seq的值自加,但是销毁的时候seq的值并不会自减。seq的值随着该种IPC对象的创建而单调递增,直到递增到上限(max_seq)在溢出回绕,重新从0开始。

当需要创建新的IPC对象时,三种IPC对象都会走到ipc_addid处。

ipc_addid函数会初始化IPC对象的很多成员变量,比如权限相关的uid、gid、cuid,也会维护该IPC对象的seq值。

前面提到,内核分配IPC对象标识符的时候,使用的并不是最小可用算法,如下:

#define IPCMNI 32768
#define SEQ_MULTIPLIER (IPCMIN)
static inline int ipc_buildid(int id, int seq)
{
return SEQ_MULTIPLIER * seq + id;
}

上面公式中的id就是最小可用的槽位,而seq是开机以来内核创建IPC对象的流水号。因此,返回的ID是一个比较大的值。仍然以消息队列为例,如果开机后,消息队列为空,创建的第一个消息队列的标识符必然为0。第二个是32769,第三个是65538。

根据上面的讨论可知,IPC对象的标识符ID虽然是通过get函数来获得的,但是和key值并不存在永久的关系,即不存在公式可以通过key值来计算出标识符ID。内核仅仅是关联了两者。重启系统之后,后者删除IPC对象之后,根据相同的key值再次创建,得到的标识符ID很可能并不相同。

内核面临着如何根据IPC对象的标识符ID,快速地找到内核中的IPC对象的难题,根据前面的计算公式,可得:

slot_index = 标识符ID % SEQ_MULTIPLIER

这个公式透露出了一个问题:整个系统内,每一种IPC对象的槽位有限,最多有IPCMNI个槽位。在ipc_addid函数中也证实了这一点,系统内的硬上限为IPCMNI,即32768。这个限制就决定了不能无限制地创建IPC对象。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值