👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍
目录
- 前言
- 一、消息队列 (了解)
- 1.1 原理
- 1.2 消息队列的数据结构
- 1.3 系统调用接口
- 1.3.1 msgget - 创建消息队列
- 1.3.2 msgctl - 释放消息队列
- 1.3.3 msgsnd - 发送数据块
- 1.3.4 msgrcv - 接收数据块
- 1.4 小结
- 二、信号量
- 2.1 前置概念:互斥、临界资源等概念(重点)
- 2.2 理解信号量(重点)
- 2.3 总结一波
- 2.4 系统调用接口(了解)
- 2.4.1 semget - 创建信号量
- 2.4.2 semctl - 释放
- 2.4.3 semop - 操作
- 2.5 信号量凭什么是进程间通信的一种?
- 2.6 信号量的数据结构
- 三、深入理解 System V 通信方式 (重点)
前言
在
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
接收消息,在整个通信的过程中可能会出现错乱问题。比方A
向B
发送100Byte
的任务信息,但是A
可能才写到50Byte
,B
进程就开始读走了,导致B
进程任务信息不完整。我们称之 数据不一致问题。因此,就衍生出以下几个概念:
- 首先可以通过加锁的方式(多线程部分讲解) 来保证 互斥。互斥本质就是:任何时刻,只允许一个执行流访问共享资源(保护共享资源免受并发访问的影响),
- 而这种只允许一个执行流访问(执行访问代码)的资源称做临界资源。这个临界资源一般是内存空间。(比方说管道就是一种临界资源)
- 我们访问临界资源的代码称做 临界区(类比代码区)
注意:在管道通信中不存在这些问题,因为管道有原子性和同步互斥,而共享内存是没有任何的保护机制的。
那么现在就可以解释一个现象:为什么多个进程(或者线程)向显示器打印各自的信息有时候会错乱。原因很简单,在Linux
中,显示器是文件,当多个进程向同一个文件打印,前提是这些进程需要看到同一份资源,所以显示器文件在多进程中就是一个共享资源,而在打印的过程中并没有添加保护机制,因此会看到数据不一致,错乱问题。如果不想有这些情况,你就需要将显示器文件变成临界资源,如加锁等。
2.2 理解信号量(重点)
信号量(有的教材叫信号灯)的本质是就是计数器。这个计数器用来记录可用资源的数量。
下面将一个故事来加深理解:假设一个放映厅有100
个位置,对应也会售卖100
张票(不考虑其他情况)。当我们买票去看电影,但是还没去观看(甚至不看),那个位置在电影的时间段永远是我们自己的。因此,买票的本质是对资源的预定机制!而每卖一张票,剩余的票数(计数器)就要减一,对应的放映厅里面的资源就要少一个。当票数的计数器减到0
之后,表示资源已经被申请完毕了。
临界资源(如同放映厅)可以被划分很多小块的资源(如同放映厅里的位置),那么我们可以允许多个执行流(如同观众)来访问这份临界资源,但是最怕多个执行流会访问同一个小块的资源,一旦出现,就会发生混乱现象。因此,最好的方法就是引入一个计数器cnt
,当cnt > 0 && cnt - 1
,说明执行流申请资源成功(本质是对资源的预定机制),就有访问资源的权限。当cnt <= 0
表示资源被申请完了,当再有执行流申请,一定会失败,除了有执行流释放(退票)。
所以每一个执行流若是要访问共享资源中的一小部分的时候,不是直接访问,而是先申请计数器资源。如同看电影需要先买票 ~
故事还没完,如果电影院的放映厅只有一个座位,我们只需要值为1
的计数器,但如果有10
个人想要这一个位置,那么必定要先申请计数器资源,但不变的是只有一个人能看电影,不就是只有一个执行流在访问一份临界资源,这不就是互斥访问吗?
在并发编程中,一个只能取两个状态(通常是0
和1
)的计数器被称为二元信号量。二元信号量通常被用来实现互斥访问(本质就是一个锁),即保证在任何时刻只有一个进程(或线程)能够访问临界资源。在电影院座位的故事中,计数器的两个状态可以分别表示座位的空闲(1
)和已占用(0
)状态。
这又有一个新的问题:要访问临界资源,先要申请计数器资源。而信号量本质是计数器,那么信号量不就是共享资源吗?
而计数器(信号量)作为保护方,要保护临界资源只允许一个执行流访问。但俗话说得好,要保护别人的安全,首先得先保证自己的安全。而对一个整数--
其实并不安全,这里简单说说为什么不是安全的,等到线程部分再详谈。
--
操作在C
语言上是一条语句;但是这条语句在汇编语言上就是多条汇编语句,一般是三条。首先计数器的值要从内存中放到CPU
的寄存器中,然后再CPU
进行--
操作,最后再将计算结果协会计数器变量的内存位置。而执行流在运行的时候,可以随时被切换,如果没有适当的同步措施(如互斥锁),多个执行流同时访问计数器可能会导致竞态条件。竞态条件会破坏计数器的预期行为,使其不能正确地反映实际资源的状态。
即然--
都不安全,那谈何保护别人?
因此,申请信号量,本质是对计数器--
,在信号量中专门封装了一个操作(方法),我们将这种操作称为P
操作。如果减一后的计数器值小于零(即信号量的计数器值变为负数),那么执行流就会被阻塞,直到信号量的计数器变为正数,表示有可用资源;释放资源的同时也要释放信号量,本质是对计数器进行++
操作,也叫做V
操作。
需要注意的是,P
操作和V
操作通常需要具有 原子性。其意思是一件事情要么不做,要做就做完,是两态的。没有“正在做”这样的概念。也就是说,原子性确保了多个执行流在执行--
操作时,不会被其他执行流中断或干扰,而且操作要么完全执行成功,要么完全不执行,没有正在执行的说法。
2.3 总结一波
-
信号量本质是计数器,
PV
操作具有原子性。 -
执行流申请资源,必须先申请信号量(计数器)资源,得到信号量之后,才能访问临界资源!
-
信号量值
1
,0
两态的。二元信号量,就是互斥功能。 -
申请计数器(信号量)的本质是对临界资源的预定机制!
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
对象时使用特定的系统调用(如msgget
、shmget
、semget
)来分配和初始化相应的kern_ipc_perm
结构体。这些结构体通常包含一个类型标志位字段,用于标识这个IPC
对象的类型。
那这不就是多态的思想吗?struct ipc_perm
充当基类,其他的IPC
对象数据结构充当子类,它们继承了 struct ipc_perm
的属性,并且增加了特定于每种 IPC 对象类型的信息和操作。指针指向谁就调用谁。
至此,进程间通信的知识点就到此结束啦~