【Linux系统】进程间通信:浅谈 SystemV 标准的消息队列和信号量
消息队列
消息队列的概念
消息队列的队列是以消息结构作为元素:一个消息结构包含:消息类型、消息数据
struct msgbuf {
long mtype; // 消息类型
char mtext[100]; // 消息数据
};
消息队列可以实现两端进程之间的通信, 两端都可以读和写!
同时,消息队列类似一个双端队列,两边都可以进出
消息队列的相关系统调用
简单来说,消息队列的一系列系统调用基本和 共享内存的那套一摸一样!!在 System V
标准中,进程间通信(IPC)机制包括共享内存、消息队列和信号量等,它们的操作系统调用在设计上保持了一致性,方便开发者理解和使用。
System V标准的一致设计
共享内存
-
创建:
shmget(key_t key, size_t size, int shmflg)
- 创建一个新的共享内存段或获取一个已经存在的共享内存段。
-
挂接:
shmat(int shmid, const void *shmaddr, int shmflg)
- 将共享内存段连接到调用进程的地址空间。
-
去关联:
shmdt(const void *shmaddr)
- 断开与共享内存段的连接。
-
删除:
shmctl(int shmid, int cmd, struct shmid_ds *buf)
- 当cmd为
IPC_RMID
时,用于标记共享内存段为删除状态(当所有相关进程都已断开连接后实际删除)。
- 当cmd为
消息队列
-
创建:
msgget(key_t key, int msgflg)
- 创建一个新的消息队列或获取一个已经存在的消息队列。
-
关联:消息队列没有显式的关联操作,一旦创建或者获取了消息队列标识符,就可以直接进行发送和接收操作。
-
去关联:消息队列也不需要去关联,但是可以使用
msgctl
来控制队列的状态。 -
删除:
msgctl(int msqid, int cmd, struct msqid_ds *buf)
- 当cmd为
IPC_RMID
时,用于删除消息队列。
- 当cmd为
信号量
- 创建:
semget(key_t key, int nsems, int semflg)
- 创建一个新的信号量集或获取一个已经存在的信号量集。
- 挂接:信号量没有像共享内存那样的挂接操作,但可以通过
semop
来进行信号量的操作。 - 去关联:信号量也没有去关联的概念。
- 删除:
semctl(int semid, int semnum, int cmd, ...)
- 当cmd为
IPC_RMID
时,用于删除信号量集。
- 当cmd为
System V标准的一致设计
从上述系统调用可以看出,尽管每种IPC机制的功能和应用场景不同,但在System V标准下,它们的创建(*get
)、控制(*ctl
)以及删除(IPC_RMID
命令)等操作的设计思路是相似的。这种一致性体现在:
- 创建与获取:无论是共享内存、消息队列还是信号量,都通过
*get
函数来创建或获取相应的资源。 - 控制操作:每种机制都有对应的
*ctl
函数用于执行各种控制操作,如设置属性、查询状态及删除资源等。 - 删除资源:所有机制都支持通过
*ctl
函数并指定IPC_RMID
命令来删除相应的资源。
这种统一的设计简化了学习曲线,并提高了代码的可移植性和复用性。无论处理哪种类型的IPC,开发者都可以遵循相似的操作模式,降低了开发难度。
其实本质上,共享内存、消息队列、信号量这几个系统调用接口和操作用法基本相同,这就是 SystemV
标准!
但是,他们的用法上当然是不一样的!
消息队列个性化的操作:存在发消息和接收消息的系统调用
发消息和接收消息的系统调用
信号量
信号量的相关概念及系统调用接口
信号量的创建、操作等系统调用和命令都和共享内存、消息队列差不多!
信号量
- 创建:
semget(key_t key, int nsems, int semflg)
- 创建一个新的信号量集或获取一个已经存在的信号量集。
- 删除:
semctl(int semid, int semnum, int cmd, ...)
- 当cmd为
IPC_RMID
时,用于删除信号量集。
- 当cmd为
并发编程,概念铺垫
- 多个执行流(进程),能看到的同一份公共资源:共享资源
- 被保护起来的资源叫做临界资源
- 保护的方式常见:互斥与同步
- 任何时刻,只允许一个执行流访问资源,叫做互斥
- 多个执行流,访问临界资源的时候,具有一定的顺序性,叫做同步
- 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
- 在进程中涉及到互斥资源的程序段叫临界区。你写的代码=访问临界资源的代码(临界区)+不访问临界资源的代码(非临界区)
- 所谓的对共享资源进行保护,本质是对访问共享资源的代码进行保护
信号量正文
命名方面: 信号 vs 信号量,这两者没有关系(就和老婆饼和老婆的关系,只是命名相似)
本质来看: 信号量就是一个计数器
理解资源的互斥使用如下:
资源整体使用
现有一块完整资源,进程 A 和 进程 B 互斥使用该资源,进程 A 用完了才允许进程 B 用,进程 B 用完了才通知给其他进程用,这就是互斥使用一块资源。
问题:谁规定资源必须整体整存整取的使用,能不能将整块资源拆分成小块资源给不同进程使用?
资源分块使用
通过某种方式让不同进程访问同块资源的不同子区域
这种情况下,不同进程,访问同块共享资源(严谨来说是同块资源的不同区域),具有一定的并发性!
这种同块资源分区使用的情况,需要注意的是:
如果有多个进程需要使用这些子区域资源,子区域资源不够分怎么办?
即当资源不是作为整体使用时,可能会造成过量的进程进入临界资源!进程需求量大于可分配资源的数量。
解决办法:
可以设置一个计数器 int count
,比如等于 16: int count = 16
这个 count
表示这整块资源的子资源的个数
当进程需要进入临界资源占有并使用子资源时,必须先对 count
进行如下操作:
if(count > 0) count--;
else wait;
count--
表示该进程占有了一个子资源可以使用- 当
count == 0
时,进程就必须等待,因为此时没有子资源可以分配使用了 - 当其他进程用完自己占用的资源,会退出,同时
count++
表示归还资源
这样的操作保证了这整块资源的子资源的分配上限是 16 个,不会溢出导致过量进程抢占
保证资源分配是合理的
而当资源整体使用时,不就是 count=1
的情况吗?
合并在一起讲解
- 当资源整体使用时,不同进程使用这块资源是互斥的关系
- 当资源不是整体使用,被划分为多块子资源使用时,这里每块子资源的使用情况不也是这种互斥的关系吗!
比喻例子理解信号量机制:电影院
去电影院看电影前,通常需要买票选座位
而买票这一操作,就是一种将目标座位进行占有的行为
而不是你人本身坐到目标座位上才算占用,你买了票,即使人没去,这个座位还是始终被你占有的
而这个票本质上就是一个计数器,每张票对应的某个座位,票的总量是根据放映厅的座位数确定的,保证了不会卖多票导致座位资源不够的情况
买票的本质:对资源的预订机制!
只要我买了票,不管我是否使用这个资源,该资源都必须给我留着!
通过这个例子也可以理解前面讲解的:当资源不是整体使用,而是被划分为多块子资源使用的情况
只要一个进程访问计数器 count
,并对这个 count--
,就表明该进程对整块资源中的某块子资源进行预定占有!
因此,之后对资源的占用不是直接去占有资源,转而变成对计数器的占有!
我们将这种具有预定机制的计数器称为 信号量!
信号量概述
信号量是一种特殊的变量,用于控制对公共资源的访问,防止多个进程同时访问共享资源导致的数据不一致或错误。在使用信号量时,一个进程占用整块资源实际上就是占用了相应的信号量。对于二元信号量(也称为互斥锁),存在两种状态:1(可用)或0(不可用)。当进程A占用该资源时,信号量状态变为0,其他进程必须等待直到进程A释放资源(即信号量变回1)。
关于信号量的常见问题
问题:信号量是否能在多进程之间被看到、被修改、被访问吗?
答:信号量的设计初衷就是为了在多进程间共享,并允许它们根据信号量的状态来决定是否可以访问某些资源。然而,直接回答“不能”是不准确的。实际上,信号量机制通过特定的方法确保了不同进程能够安全地访问和修改同一个信号量值,而不是各自拥有独立副本。这通常涉及到内核级别的支持,以确保所有进程都操作的是同一份信号量计数器。
问题:如何保护信号量计数器分配的安全性?
信号量本身是用来保护资源不被多进程同时访问的工具。为了确保信号量计数器的安全性和一致性,避免出现竞争条件,引入了原子操作的概念,即 PV
操作。
认识PV操作
我们会将对计数器的两种操作打包成两套操作,进程在执行一套操作时不能被其他进程打断,保证原子性:P 操作和 V 操作
P 操作:
if(count > 0) count--;
else wait;
V 操作:
count++;
具体来说,P操作(尝试减小信号量值并可能阻塞)和V操作(增加信号量值)都是设计为不可中断的操作,即原子操作。这意味着一旦某个进程开始执行P操作或V操作,它将完成整个操作而不会被其他进程打断,从而保证了信号量计数器的安全性和一致性。
综上所述,多进程间想使用信号量就必须解决两个问题:
1、必须让不同进程看到同一个信号量
2、解决原子性加加或减减的操作:打包成PV操作
小结:信号量的概念与本质
-
信号量本质是一个对资源进行预定的计数器
-
信号量常见的操作是 pv 操作,保证了信号量的操作的原子性
-
多进程使用信号量进行IPC操作:
-
先保证让不同进行看到同一个信号量
-
然后通过对信号量的PV操作,操作信号量,达到通信的目的
-
学到这里,可以思考一个问题:
问题:请问为什么可以把信号量归结到进程间通信呢?
信号量没有传递字符串,没有传数据,不像共亨内存、消息队列可以传数据
答:信号量表示资源的使用情况,不同进程使用资源会对信号量进行加加或减减,多进程看到同一个信号在进行增加或减少,本质上不也是一种进程间通信吗
信号量集
信号量集概念
信号量的使用可以有多个,通过信号量集的方式管理起来:sem_t sems[N]
前面讲解过的一个信号量操作接口:其中的 nsems
参数的含义就是需要几个信号量
如果要获取信号量的属性:使用下面这个函数接口
-
参数
semid
:信号量集的 id -
参数
semnum
:目标信号量在该信号量集数组中的下标位置 -
参数
cmd
:就是获取目标信号量的数据的选项
多信号量PV操作:semop
参数解析:
-
semid: 这是一个标识符,用来指代要操作的信号量集。这个标识符是在创建信号量或者获取已有信号量时获得的。
-
sops: 这是一个指向
sembuf
结构数组的指针,该结构定义了要执行的一系列操作。sembuf
结构可能需要自己定义,若系统没有定义的话每个
sembuf
结构包含三个字段:sem_num
: 指定信号量集中要操作的具体信号量的索引号。sem_op
: 表示要对信号量执行的操作值。- 如果是正数,则表示增加信号量的值;
- 如果是负数,则表示减少信号量的值,并可能阻塞直到信号量有足够的值来满足这次减少操作;
- 如果为零,则进程会等待直到信号量的值变为零。
sem_flg
: 操作标志,可以是以下值之一:IPC_NOWAIT
: 如果设置此标志且信号量无法立即满足请求(例如,当尝试减少一个不够大的信号量值时),则操作不会阻塞,而是立即返回错误。
-
nsops: 指明了
sops
数组中的元素个数,即要执行的操作数目。
这个函数相当于传入一个 sembuf
结构数组,对 semid
的信号量集的某些信号量进行批量操作
不过本章节学习中我们只对一个信号量进行操作:即 sembuf
结构数组只有一个元素 sembuf[0]
,参数 nsops
设置为 1
sembuf
结构的设置可以这样:
System V 三大 IPC 的共性
System V
标准的 三个通信方式:共享内存、消息队列、信号量,除了接口有点像,还有什么地方能体现他们的共性呢
1.应用角度,看IPC属性
系统中管理多个 System V
资源如多个共享内存、消息队列、信号量
也是通过先描述再组织的方式,在应用层面被描述成一个结构 struct ???_ds
结构,如 共享内存为 struct shmid_ds
,消息队列为 struct msqid_ds
、信号量为 struct semid_ds
这几个结构中,都有个共同的属性:struct ipc_perm
这个共同属性在一定程度上证明了这三种 IPC 是同类资源!
对于共享内存:
先描述成一个结构:struct shmid_ds
其中第一个属性:
对于消息队列:
先描述成一个结构:struct msqid_ds
其中第一个属性:
对于信号量:
先描述成一个结构:struct semid_ds
其中第一个属性:
你会发现着 System V
标准的三个通信方式的结构中都有一个共同的属性:struct ipc_perm
因此他们三个通信方式都有 key
值
内核中通过 key
值来区分不同的 System V
标准的资源
因此可以推测:在OS层面,IPC是同类资源!
讲解了理论,我们现在来获取一下这些资源瞧一瞧:
以共享内存为例:
我们通过系统调用 shmctl
来获取该结构资源,通过传递选项 IPC_STAT
和一个数据结构 struct shmid_ds
,让系统底层给你返回一个填充好的 数据结构 struct shmid_ds
系统调用 shmctl
的原型
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
关于 cmd
选项:IPC_STAT
这个就是获取该共享内存的一些状态信息,它会将底层的数据结构 struct shmid_ds
获取给你,你再通过指定来获取需要的属性
// int shmctl(int shmid, int cmd, struct shmid_ds *buf);
struct shmid_ds buf;
shmctl(shmid, IPC_STAT, &buf);
cout << "shm_cpid: " << buf.shm_cpid << '\n';
cout << "shm_atime: " << buf.shm_atime << '\n';
cout << "shm_segsz: " << buf.shm_segsz << '\n';
运行结果如下:
time_t shm_atime; /* 上次读取的时间戳 */
time_t shm_dtime; /* 上次写入的时间戳 */
time_t shm_ctime; /* 最后一次改变的时间戳 */
其中 shm_atime
就是一个时间戳:可以使用 date
将该时间戳转换成一种时间格式
上面展示了 IPC
的三种方式的结构中的属性,因此我们可以自己实现 ipcs
命令
只要通过对应的系统调用获取对应结构中的属性即可
2.内核角度,看IPC结构
IPC资源一定是全局的资源!会被所有进程看到!这才能独立于所有进程之外,让通信的进程看到同一个IPC结构,完成进程间通信!
查看内核代码:
内核中的 ipc_ids
结构
内核代码中有个 ipc_ids
结构,其中有个属性 struct ipc_id_ary* entries
,这个指针指向一个 ipc_id_ary
结构
ipc_id_ary
结构有个可以扩容的柔性数组结构,该数组是一个 struct kern_ipc_perm
指针类型数组,也就是说该数组的每个元素都指向一个struct kern_ipc_perm
结构,struct kern_ipc_perm
结构中包含着很多 ipc
属性
在应用层,用于描述一种 IPC 的结构中的第一个属性: ipc_perm
结构,就是通过拷贝 底层的 struct kern_ipc_perm
结构来得到的!!!
这也说明了一点:应用层(即上层)的一部分信息其实都是从底层的结构中拷贝的
再来看内核中共享内存、消息队列、信号量的结构如下:
内核中 SystemV
的三大 IPC 的结构
消息队列
信号量
共享内存
这几个结构中的第一个属性都是:struct kern_ipc_perm
这不正是我们前面讲解的: 内核代码中有个 ipc_ids
结构,其中有个属性 struct ipc_id_ary* entries
,这个指针指向一个 ipc_id_ary
结构, ipc_id_ary
结构有个可以扩容的柔性数组结构,该数组是一个 struct kern_ipc_perm
类型数组,
内核中的一个 struct kern_ipc_perm
指针类型的柔性数组
这不就可以通过柔性数组的指针元素直接指向不同的 ipc
资源结构中的 struct kern_ipc_perm
,达到管理所有 ipc
资源的目的吗!!!
需要改变一下说法:实际上柔性数组的指针元素并不是直接指向不同 ipc
结构的第一个 struct kern_ipc_perm
类型属性,而是直接指向 ipc
结构,通过强转的方式!!!
通过将整个 IPC 结构的指针类型强转成 struct kern_ipc_perm*
指针类型,达到管理 IPC 结构的目的
这个内核 ipc_id_ary
结构中的柔性数组的数组下标就是应用层的系统调用 XXXget
函数的返回值!!!
如系统调用 shmget
创建一个共享内存,该函数的返回值是该新建共享内存的应用层的 id 值 shmid
,这个 ID 其实是内核这个柔性数组的数组下标值
这就是为什么通过 ipcs
查询 id 值,如共享内存的 shmid
都是从 0 开始,递增式的分配
因为就是数组下标!
对 ipc
结构起始位置指针强转成 struct kern_ipc_perm
类型,便于存储到 struct kern_ipc_perm
指针类型数组中
本质也是可以访问到 不同 ipc
结构的第一个 struct kern_ipc_perm
类型属性:因为实际上,指针类型代表着一次可以访问的数据大小,int*
表示一次可以读取 int
大小的数据,现在我们将这整个 IPC 结构强转为 struct kern_ipc_perm
类型,则代表我们一次可访问一个 struct kern_ipc_perm
类型的大小,即 IPC 结构的第一个属性
问题:如果想要访问该 ipc
结构的其他属性呢?
答:只需强转回原来结构的类型:
如:
(struct msg queue*)p[0]->???
这样看来,这些结构的访问方式即如下:
-
强转成
struct kern_ipc_perm
类型,就能直接访问该结构的struct kern_ipc_perm
这一部分的内容 -
强转回原来结构:就能访问其他属性
更细致来看:
强转成 struct kern_ipc_perm
类型:不管是什么 ipc
结构,访问都是同样的 struct kern_ipc_perm
属性
强转回原来结构:就能访问不同 ipc
结构的 个性属性了
这种方式不就是 C++ 中的 多态吗!!!
这就是 C语言实现多态的一种思路!!(面试可能会问噢!)
C语言实现多态
struct A
{
struct Common co;
int a1;
double a2;
float a3;
};
struct B
{
struct Common co;
int b1;
double b2;
float b3;
};
struct C
{
struct Common co;
int c1;
double c2;
float c3;
};
// 三种结构存储在同一个 struct Common 指针数组中:
struct A a;
struct B b;
struct C c;
struct Common p[3] = {
(struct Common*)&a, \
(struct Common*)&b, \
(struct Common*)&c \
}
通过强转就能访问该结构的个性资源:
(struct A)p[0]->a1;
(struct B)p[1]->b1;
(struct C)p[2]->c1;
其中:
struct kern_ipc_perm
可以看成父类
各个 ipc
结构就是子类,都继承父类struct kern_ipc_perm
(每个 ipc
结构中都有一个 struct kern_ipc_perm
结构!)
再谈共享内存:本质是文件?
值得注意的是,共享内存本质上也是一个文件!!
看下面共享内存的内核结构,有一个文件结构属性:struct file* shm_file
共享内存文件中的文件缓冲区就提供的共享内存需要使用的 内存块
之前讲解过,共享内存是直接映射到虚拟地址空间中的共享区的一块空间,我们可以像使用 malloc
空间一样,用指针直接访问读写共享内存,这里有个问题:
问题:这里讲到共享内存是一种文件,为什么还可以直接用指针访问,而不是操作文件的相关系统调用或方法?
答:因为共享内存这个文件实际上会被映射到虚拟地址中!
在虚拟地址空间共享区存在的 struct vm_area_struct
结构中,有一个 struct file* vm_file
结构,这个 struct file
文件结构不就可以用来存放 共享内存这个文件吗?
而上面的 vm_start
和 vm_end
存入 共享内存内存块的起始和结束位置地址
从而达到文件从物理地址到虚拟空间的映射
换句话来说:
在 struct vm_area_struct
结构中的 struct file* vm_file
会指向 共享内存这个文件,而 vm_start
和 vm_end
则会指向该共享内存可使用的内存块的起始和结束位置地址。
简单来说:
我用一个 struct file*
指针管理着你这个文件,用 vm_start
和 vm_end
指向你这个文件中开辟可以使用的空间
这就是为什么我们能使用指针直接访问读写 “共享内存” 这个文件,因为我们可以通过 vm_start
和 vm_end
直接操作到共享内存文件的 数据内存块!
另外:像是共享内存这样的文件,不会占文件描述符,因为不是用户打开的,不需要给文件描述符给用户操作访问该文件
动态库也同理,具体如下;
动态库链接
动态库也是一种文件,打开动态库,加载到内存中,系统为其创建对应的 struct file
动态库链接:动态库进程地址空间中创建一个 struct vm_area_struct
结构,其中的 struct file* vm_file
结构指向动态库文件的 struct file
,而
vm_start
和 vm_end
字段具体表示的是该动态库在进程地址空间中的映射范围。这两个字段定义了动态库在内存中的起始地址和结束地址
进程间通信章节最后小结
对于进程间通信这一章节的学习,我们只需重点理解命名管道匿名管道和内存管理即可。至于细分的消息队列我们不考虑,信号量我们到时候在多线程部分会更为具体的讲解。
重点是,我们一定要理解 ipc
在操作系统当中是如何组织的,例如被描述成各种数据结构
实际上 SystemV
标准的IPC不是重点, 因为我们发现它标识资源的方式用的是它自己定的这一套数组
它和我们之前的文件操作兼容性并不好,要写的话,就得单独写不能和文件
整到一块啊,所以这个我们不做重点了
即:我们进程间通信直接通过文件通信,而 SystemV
这套使用的是他自己的一套标准,要用的话就要另写一套代码