【Linux】System V消息队列 System V信号量

在这里插入图片描述

👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍


前言

System V通信标准中,还有一种通信方式:消息队列,以及一种实现互斥的工具:信号量;随着时代的发展,这些陈旧的标准都已经较少使用了,但作为IPC中的经典知识,我们可以对其做一个简单了解。尤其是 信号量,可以通过它,为以后多线程学习中POSIX信号量的学习做铺垫

一、消息队列 (了解)

1.1 原理

进程间通信的本质是:要让双方进程看到同一块资源。那么对于System V消息队列,操作系统首先就要在内核中创建一个队列(数据结构),再通过某种手段将两个或多个进程看到同一个队列后,即可通信

  • 进程A发送数据是以数据块的形式发送到消息队列中。
  • 进程B同样是以数据块的形发送到消息队列中。
  • 注意:System V消息队列允许多个进程双向进行通信,而管道通常只能单向通信

在这里插入图片描述

  • 但有一个问题:那消息队列中存放着不同进程发送的数据块,那如何判断该数据块是由哪个进程接收呢?

发送消息时,接收进程通常是根据消息类型来判断消息的来源。

当然了,消息队列跟共享内存一样,是由操作系统创建的,其生命周期不随进程,因此在使用结束后需要手动释放,不然会导致内存泄漏!

1.2 消息队列的数据结构

而我们知道,因为系统中不止一对进程在进行通信,可能会存在多个,那么操作系统就要在内核中开辟多个消息队列,那么操作系统就必须对这些消息队列进行管理,这又得搬出管理的六字真言:先描述,再组织。在Unix/Linux中,描述消息队列的信息通常通过struct msqid_ds结构体来表示:

  • struct msqid_ds
struct msqid_ds
{
	// struct ipc_perm 结构包含了消息队列的所有权和权限信息。
    struct ipc_perm msg_perm;  
    // 最后一次向队列中发送消息 (msgsnd) 的时间。 
    time_t msg_stime;      
    // 最后一次从队列中接收消息 (msgrcv) 的时间。     
    time_t msg_rtime;     
    // 消息队列属性最后一次变更的时间。       
    time_t msg_ctime;       
    // 队列中当前的字节数     
    unsigned long __msg_cbytes;    
    // 队列中当前的消息数目。                           
    msgqnum_t msg_qnum;         
    // 队列中允许存放的最大字节数。                          
    msglen_t msg_qbytes;  
    // 最后一次发送消息 (msgsnd) 的进程pid。                  
    pid_t msg_lspid;    
    // 最后一次接收消息 (msgrcv) 的进程pid。
    pid_t msg_lrpid;           
};
  • struct ipc_perm
struct ipc_perm
{
	// __key用于标识 IPC 对象的键值,由用户指定。
    key_t __key;         
    // 拥有者的有效用户ID (UID),即对象的当前所有者。
    uid_t uid;          
    // 拥有者的有效组ID (GID),即对象的当前所属组。
    gid_t gid;        
    // 创建者的有效用户ID (UID),即创建对象的用户。    
    uid_t cuid;     
    // 创建者的有效组ID (GID),即创建对象的用户所属的组。      
    gid_t cgid;        
    // 对象的权限模式,定义了对象的访问权限,通常以八进制表示。    
    unsigned short mode;  
    // 序列号,用于处理 IPC 对象创建时的竞争条件。
    unsigned short __seq; 
};

最后再通过诸如链表、顺序表等数据结构将这些结构体对象管理起来。因此,往后我们对共享内存的管理,只需转化为对某种数据结构的增删查改。

1.3 系统调用接口

1.3.1 msgget - 创建消息队列

msgget用于创建一个新的System V消息队列或获取一个已经存在的消息队列。

函数原型如下:

#include <sys/types.h>

#include <sys/ipc.h>
#include <sys/msg.h>

int msgget(key_t key, int msgflg);

参数说明:

  • key:消息队列的键值。这个键值用于唯一标识一个消息队列(内核层使用),多个进程可以通过相同的键值来访问同一个消息队列。通常,可以使用ftok函数来生成一个键值。

  • msgflg:这是一个标志参数,用于指定操作模式和权限。可以用操作符'|'进行组合使用。它可以是以下几个标志的组合:

    • IPC_CREAT:这个选项单独使用的话,如果申请的消息队列不存在,则创建一个新的消息队列;如果存在,获取已存在的消息队列。
    • IPC_EXCL: 一般配合IPC_CREAT一起使用(不单独使用)。他主要是检测共享内存是否存在,如果存在,则出错返回;如果不存在就创建。确保申请的消息队列一定是新的。
    • 权限标志:以与文件权限类似的方式指定消息队列的访问权限(例如0666表示所有用户可读写)。
    • 但在获取已存在的消息队列时,可以设置为0
  • 返回值:

    • 成功时返回消息队列标识符msqid。(操作系统内部分配的,提供给用户层使用,类似于文件描述符fd
    • 失败时返回 -1,并设置errno以指示错误原因。

看到这里,有没有发现以上接口和创建共享内存段shmget函数非常的像啊,至于key和消息队列标识符的区别这里就不再详细介绍了,更多细节请参考:点击跳转

接下来我们简单使用msgget函数创建消息队列,并使用 ipcs -q指令查看系统维护的消息队列的信息

#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

using namespace std;

const char *pathname = "/home/wj";
int proj_id = 'A';

int main()
{
    // 使用ftok函数生成键值
    key_t key = ftok(pathname, proj_id);
    printf("key is 0x%x\n", key);

    // 创建消息队列
    int msqid = msgget(key, IPC_CREAT | IPC_EXCL | 0666);
    printf("msqid is %d\n", msqid);

    return 0;
}

【程序结果】

在这里插入图片描述

由于我们还没使用消息队列进行通信,所以已使用字节used-bytes和消息数messages都是0

1.3.2 msgctl - 释放消息队列

如上我们可以看见,当进程结束后,我们还是能看到消息队列在系统的相关信息。所以我们应该手动将其释放,避免内存泄漏!

释放的方法和共享内存一样有两种方法:

  • 方法一:使用以下指令
ipcrm -q <msqid>

在这里插入图片描述

  • 方法二:使用系统调用接口

msgctl函数是用于控制消息队列的函数之一,它允许程序员执行多种操作,如获取消息队列的属性、设置消息队列的属性、删除消息队列等。

具体的函数原型如下:

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

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

参数说明:

  • msqid:消息队列的标识符。即msgget函数的返回值。

  • cmd:要执行的操作命令,可以是以下几种之一:

    • IPC_STAT:获取消息队列的状态信息,并将其存储在struct msqid_ds *buf中。
    • IPC_SET:设置消息队列的状态,从struct msqid_ds *buf中读取新的状态信息。
    • IPC_RMID:从系统中删除消息队列。
  • buf:一个指向struct msqid_ds结构的指针,用于存储或传递消息队列的状态信息。如果是删除消息队列,此参数可以设置为nullptr

#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

using namespace std;

const char *pathname = "/home/wj";
int proj_id = 'A';

int main()
{
    // 使用ftok函数生成键值
    key_t key = ftok(pathname, proj_id);
    printf("key is 0x%x\n", key);

    // 创建消息队列
    int msqid = msgget(key, IPC_CREAT | IPC_EXCL | 0666);
    printf("msqid is %d\n", msqid);

    // 进程结束前释放消息队列
    msgctl(msqid, IPC_RMID, nullptr);

    return 0;
}

【程序结果】

在这里插入图片描述

1.3.3 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);

参数说明:

  • msqid:消息队列的标识符,由msgget函数返回。
  • msgp:指向要发送的消息内容的指针,通常是用户定义的结构体指针。就是我们在原理部分说的数据块结构体。其结构如下:
struct msgbuf
{
    long mtype;    /* message type, must be > 0 */
    char mtext[1]; /* message data */
};

mtype就是传说中数据块类型,据发送方而设定;而mtex是一个比较特殊的东西:柔性数组,其中存储待发送的 信息,因为是 柔性数组,所以可以根据 信息 的大小灵活调整数组的大小。对于柔性数组,大家可以参考这篇文章:点击跳转

  • msgsz:消息的大小,以字节为单位。这个大小必须是消息队列的最大消息大小(msg_qbytes)的一个有效值,否则会导致msgsnd失败。
  • msgflg:表示发送数据块的方式,一般默认为0
  • 返回值:成功返回0,失败返回-1
1.3.4 msgrcv - 接收数据块

msgrcv函数用于从消息队列中接收消息。

其函数原型如下:

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

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

参数说明:

  • msqid:是消息队列的标识符,由msgget函数返回。
  • msgp:是一个指向接收消息的缓冲区的指针,通常是一个用户定义的结构体指针。
  • msgsz:是接收缓冲区的大小,即可以接收的最大消息大小(字节数)。如果实际接收到的消息大小大于msgsz,则消息可能会被截断,这取决于msgflg是否设置了MSG_NOERROR
  • msgtyp:是消息类型,即从消息队列中选择接收的消息类型。如果msgtyp大于0,则只接收msgtyp 类型的消息;如果msgtyp等于0,则接收队列中的第一个消息;如果msgtyp小于0,则接收队列中小于或等于msgtyp绝对值的最高优先级的消息。
  • msgflg:表示接收数据块的方式,一般默认为0
  • 返回值:成功返回接收到的消息的大小(字节数);失败返回-1,并设置errno来指示错误的具体原因。

同样的,接收的数据结构如下所示,也包含了类型和柔性数组

struct msgbuf
{
    long mtype;    /* message type, must be > 0 */
    char mtext[1]; /* message data */
};

1.4 小结

消息队列 的大部分接口都与 共享内存 近似,所以掌握 共享内存 后,即可快速上手 消息队列。但是如你所见,System V版的消息队列 使用起来比较麻烦,并且过于陈旧,现在已经较少使用了,所以我们不必对其进行深究,知道个大概就行了 ~

二、信号量

2.1 前置概念:互斥、临界资源等概念(重点)

进程A发送消息,进程B接收消息,在整个通信的过程中可能会出现错乱问题。比方AB发送100Byte的任务信息,但是A可能才写到50ByteB进程就开始读走了,导致B进程任务信息不完整。我们称之 数据不一致问题。因此,就衍生出以下几个概念:

  1. 首先可以通过加锁的方式(多线程部分讲解) 来保证 互斥互斥本质就是:任何时刻,只允许一个执行流访问共享资源(保护共享资源免受并发访问的影响)
  2. 而这种只允许一个执行流访问(执行访问代码)的资源称做临界资源。这个临界资源一般是内存空间。(比方说管道就是一种临界资源)
  3. 我们访问临界资源的代码称做 临界区(类比代码区)

注意:在管道通信中不存在这些问题,因为管道有原子性和同步互斥,而共享内存是没有任何的保护机制的。

那么现在就可以解释一个现象:为什么多个进程(或者线程)向显示器打印各自的信息有时候会错乱。原因很简单,在Linux中,显示器是文件,当多个进程向同一个文件打印,前提是这些进程需要看到同一份资源,所以显示器文件在多进程中就是一个共享资源,而在打印的过程中并没有添加保护机制,因此会看到数据不一致,错乱问题。如果不想有这些情况,你就需要将显示器文件变成临界资源,如加锁等。

2.2 理解信号量(重点)

信号量(有的教材叫信号灯)的本质是就是计数器。这个计数器用来记录可用资源的数量

下面将一个故事来加深理解:假设一个放映厅有100个位置,对应也会售卖100张票(不考虑其他情况)。当我们买票去看电影,但是还没去观看(甚至不看),那个位置在电影的时间段永远是我们自己的。因此,买票的本质是对资源的预定机制!而每卖一张票,剩余的票数(计数器)就要减一,对应的放映厅里面的资源就要少一个。当票数的计数器减到0之后,表示资源已经被申请完毕了。

临界资源(如同放映厅)可以被划分很多小块的资源(如同放映厅里的位置),那么我们可以允许多个执行流(如同观众)来访问这份临界资源,但是最怕多个执行流会访问同一个小块的资源,一旦出现,就会发生混乱现象。因此,最好的方法就是引入一个计数器cnt,当cnt > 0 && cnt - 1,说明执行流申请资源成功(本质是对资源的预定机制),就有访问资源的权限。当cnt <= 0表示资源被申请完了,当再有执行流申请,一定会失败,除了有执行流释放(退票)。

所以每一个执行流若是要访问共享资源中的一小部分的时候,不是直接访问,而是先申请计数器资源。如同看电影需要先买票 ~

故事还没完,如果电影院的放映厅只有一个座位,我们只需要值为1的计数器,但如果有10个人想要这一个位置,那么必定要先申请计数器资源,但不变的是只有一个人能看电影,不就是只有一个执行流在访问一份临界资源,这不就是互斥访问吗?

在并发编程中,一个只能取两个状态(通常是01)的计数器被称为二元信号量。二元信号量通常被用来实现互斥访问(本质就是一个锁),即保证在任何时刻只有一个进程(或线程)能够访问临界资源。在电影院座位的故事中,计数器的两个状态可以分别表示座位的空闲(1)和已占用(0)状态。

这又有一个新的问题:要访问临界资源,先要申请计数器资源。而信号量本质是计数器,那么信号量不就是共享资源吗?

而计数器(信号量)作为保护方,要保护临界资源只允许一个执行流访问。但俗话说得好,要保护别人的安全,首先得先保证自己的安全。而对一个整数--其实并不安全,这里简单说说为什么不是安全的,等到线程部分再详谈。

--操作在C语言上是一条语句;但是这条语句在汇编语言上就是多条汇编语句,一般是三条。首先计数器的值要从内存中放到CPU的寄存器中,然后再CPU进行--操作,最后再将计算结果协会计数器变量的内存位置。而执行流在运行的时候,可以随时被切换,如果没有适当的同步措施(如互斥锁),多个执行流同时访问计数器可能会导致竞态条件。竞态条件会破坏计数器的预期行为,使其不能正确地反映实际资源的状态。

即然--都不安全,那谈何保护别人?

因此,申请信号量,本质是对计数器--,在信号量中专门封装了一个操作(方法),我们将这种操作称为P操作。如果减一后的计数器值小于零(即信号量的计数器值变为负数),那么执行流就会被阻塞,直到信号量的计数器变为正数,表示有可用资源;释放资源的同时也要释放信号量,本质是对计数器进行++操作,也叫做V操作。

需要注意的是,P操作和V操作通常需要具有 原子性其意思是一件事情要么不做,要做就做完,是两态的。没有“正在做”这样的概念。也就是说,原子性确保了多个执行流在执行--操作时,不会被其他执行流中断或干扰,而且操作要么完全执行成功,要么完全不执行,没有正在执行的说法。

2.3 总结一波

  1. 信号量本质是计数器,PV操作具有原子性。

  2. 执行流申请资源,必须先申请信号量(计数器)资源,得到信号量之后,才能访问临界资源!

  3. 信号量值10两态的。二元信号量,就是互斥功能。

  4. 申请计数器(信号量)的本质是对临界资源的预定机制!

2.4 系统调用接口(了解)

信号量的系统调用挺“恶心”的,大家了解就行~

2.4.1 semget - 创建信号量
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);
组成部分含义
返回值 int创建成功返回信号量集的 semid,失败返回 -1
参数1 key_t key创建信号量集时的唯一 key 值,通过函数 ftok 计算获取
参数2 int nsems待创建的信号量个数,这也正是 集 的来源
参数3 int semflg位图,可以设置消息队列的创建方式及创建权限

除了参数2,其他基本与另外俩兄弟一模一样,实际传递时,一般传 1,表示只创建一个 信号量

2.4.2 semctl - 释放

老生常谈的两种释放方式:指令释放、函数释放

  • 指令释放:直接通过指令ipcrm -s <semid>释放信号量集。你还可以使用ipcs -s来查看系统中信号量的相关信息。
  • 通过函数释放。
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semctl(int semid, int semnum, int cmd, ...);
组成部分含义
返回值 int成功返回 0,失败返回 -1
参数1 int semid待控制的信号量集 id
参数2 int semnum表示对信号量集中的第 semnum 个信号量作操作
参数4 ...可变参数列表,不止可以获取信号量的数据结构,还可以获取其他信息
2.4.3 semop - 操作

信号量的操纵比较ex,也比较麻烦,所以仅作了解即可

使用 semop 函数对 信号量 进行诸如 +1、-1 的基本操作。

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

 int semop(int semid, struct sembuf *sops, unsigned nsops);
组成部分含义
返回值 int成功返回 0,失败返回 -1
参数1 int semid待操作的信号量集 id
参数2 struct sembuf *sops一个比较特殊的参数,需要自己设计结构体
参数3 unsigned nsops可以简单理解为信号量编号

重点在于参数2,这是一个结构体,具体成员如下:

unsigned short sem_num;  /* semaphore number */
short          sem_op;   /* semaphore operation */
short          sem_flg;  /* operation flags */

其中包含信号量编号、操作等信息,需要我们自己设计出一个结构体,然后传给semop函数使用。

可以简单理解为:sem_op 就是要进行的操作,如果将 sem_op 设为 -1,表示信号量 -1(申请),同理 +1 表示信号量 +1(归还)

sem_flg 是设置动作,一般设为默认即可

当然这些函数我们不必深入去研究,知道个大概就行了

2.5 信号量凭什么是进程间通信的一种?

讲了这么多信号量的知识,我们并没有发现信号量能传数据进行通信,而是作为一个计数器。

这里就要解释一下了,通信并不仅仅在于数据的传递,也在于双方互相协同

补充什么是协同:双方或多方在通信或合作过程中,通过相互配合、相互支持、相互理解和相互作用,共同达成某种目标。

虽然协同不是以传输数据为目的,但是它是以事件通知为目的,它的本质也是在传递信息,只是没那么容易感知到而已。

因此,协同本质也是通信,信号量首先要被所有的通信进程看到。

2.6 信号量的数据结构

Linux中,可以通过man semctl进行查看

  • struct semid_ds
struct semid_ds
{
    struct ipc_perm sem_perm; /* Ownership and permissions */
    time_t sem_otime;         /* Last semop time */
    time_t sem_ctime;         /* Last change time */
    unsigned long sem_nsems;  /* No. of semaphores in set */
};
  • struct ipc_perm
struct ipc_perm
{
    key_t __key;          /* Key supplied to semget(2) */
    uid_t uid;            /* Effective UID of owner */
    gid_t gid;            /* Effective GID of owner */
    uid_t cuid;           /* Effective UID of creator */
    gid_t cgid;           /* Effective GID of creator */
    unsigned short mode;  /* Permissions */
    unsigned short __seq; /* Sequence number */
};

显然,无论是 共享内存、消息队列、信号量,它们的ipc_perm结构体中的内容都是一模一样的,结构上的统一可以带来管理上的便利,具体原因可以接着往下看。

三、深入理解 System V 通信方式 (重点)

接下来我们再来详细说说IPC资源在内核中是怎么管理的。

在这里插入图片描述

如上我们发现:操作系统描述IPC对象(共享内存、消息队列、信号量)的数据结构的第一个字段的第一个成员都是struct ipc_perm类型成员变量

这样设计的好处就是,在操作系统内可以定义一个struct ipc_perm类型的数组(或链表等数据结构)来管理所有的IPC对象,此时每当我们申请一个IPC资源,就在该数组当中开辟一个这样的结构。

在这里插入图片描述

这是因为IPC对象的增、删、查、改操作与struct ipc_perm结构体相关,struct ipc_perm包含了IPC对象的权限信息。这些权限信息对于操作系统来说是非常重要的,它决定了哪些进程可以访问、操作这些IPC对象。因此,往后我们对IPC对象的增、删、查和改操作,就转化为对数组的增、删、查和改操作。而数组下标,就是IPC对象的标识符。(类似于文件描述符fd

就比方说通过共享内存段标识符在数组中找到其struct ipc_perm对象,而当我们需要访问其struct shmid_ds成员变量时,只需将struct ipc_perm*强制转化为struct shmid_ds*即可访问。

而操作系统为什么能知道要转化哪个IPC对象?可以这么理解:

  • 在用户角度,操作(增、删、查、改)IPC对象时会使用struct ipc_perm结构体来描述对象的权限和所有者信息。这是给开发者和应用程序使用的接口,用来传递创建和访问IPC对象的参数。
  • 但从内核角度出发,真正管理IPC对象的是kern_ipc_perm结构体(或类似的结构体)。内核会在创建IPC对象时使用特定的系统调用(如 msggetshmgetsemget)来分配和初始化相应的 kern_ipc_perm结构体。这些结构体通常包含一个类型标志位字段,用于标识这个IPC对象的类型。

那这不就是多态的思想吗?struct ipc_perm充当基类,其他的IPC对象数据结构充当子类,它们继承了 struct ipc_perm的属性,并且增加了特定于每种 IPC 对象类型的信息和操作。指针指向谁就调用谁。

至此,进程间通信的知识点就到此结束啦~

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值