目录
[效果演示 + 结论总结:](#效果演示 + 结论总结:)
[4.1、system V消息队列](#4.1、system V消息队列)
[4.2、system V信号量](#4.2、system V信号量)
[下面正式进入信号量 ⏩️](#下面正式进入信号量 ⏩️)

前言:
共享内存是最快的进程间通信IPC技术,但凡事有两面性,那么它肯定有不足之处。
以及消息队列,信号量又是什么?今天我们就彻底弄清楚!
一、共享内存原理
共享内存顾名思义就是让多个进程直接看到同一块内存,通过对共享内存读写实现通信。
而我们作为用户,其实只能使用虚拟地址来操作,那么就一定要让多个进程的地址空间通过页表映射到同一块物理内存。
然后进程拿着各自的虚拟地址实现对共享内存的读写。
这不就是动态库链接用到的那一套吗,多个进程的共享区也是通过页表映射到同一份动态库代码。
事实如此,进程就是在共享区映射到共享内存!!!
是不是
!
1.1、共享内存示意图

由于我们此时已经拿到了虚拟地址,
**写:**通过起始虚拟地址直接写,如 (char*)start_mem[i] = 'x';
**读:**通过起始虚拟地址直接读,如 printf("%s", start_mem);
此时我们根本都不需要什么系统调用!!!
注意:共享内存可以进行双向通信!!!
1.2、共享内存数据结构
OS中可能存在多组进程通过共享内存通信,也就是同时存在多个共享内存。
❓️❓️ 要不要管理?怎么管理?
那么肯定存在描述共享内存的结构体对象:
struct shmid_kernel {
struct ipc_perm shm_perm; // 权限信息(key、uid、gid、mode)
struct file *shm_file; // 对应匿名文件
unsigned long shm_nattch; // 挂接进程数(shmat 次数)
size_t shm_segsz; // 共享内存大小
time_t shm_atime; // 最后挂接时间
time_t shm_dtime; // 最后断开时间
time_t shm_ctime; // 最后修改时间
// ... 页信息、状态标志等
};
因此管理方式:先描述, 再组织!!!
但我们如何获得共享内存,建立映射关系呢?
下面介绍相关系统调用接口:
二、共享内存函数
2.1、获取共享内存

int shmget(key_t key, size_t size, int shmflg);

参数说明:
(1)key
两个进程要通过共享内存通信,当一个进程创建了共享内存,那另一个进程要怎么找到这个共享内存?
你可能会想,进程A会对进程B说:"我创建了一个共享内存xxx",然后进程B找到xxx共享内存并建立虚拟到物理的映射。
那么此时两进程不是已经能够通信了吗?

代码不是程序员自己写的嘛,那你约定一个key,要通信的进程根据一个key找到同一个共享内存。

关于key值有一个专门构建的函数:

- pathname:一般是当前路径------"."
- proj_id:可以随便找一个数
++注意:++多个进程通信要约定同一个key,参数pathname 和 proj_id必须一致。
(2)size
你想要创建的共享内存的大小。
(3)shmflg

**IPC_CREAT:**根据key查找,如果共享内存不存在则创建,已经存在则获取并返回;
**IPC_CREAT | IPC_EXCL:**根据key查找,不存在就创建,存在则报错返回,所以该标志位组合用来创建全新的共享内存;
**IPC_CREAT | IPC_EXCL | 0666:**最常用,全新创建并修改权限为可读可写。
返回值说明:

创建成功返回该共享内存的描述符------shmid,失败返回-1,错误码被设置。
++问题:++ key也是用来找同一个共享内存,与shmid有什么区别?
key由进程约定,是用来定位内核中同一块共享内存,即创建 和查找;
shmid在创建成功后返回,用来操作 和管理,如后续shmat,shmctl,shmdt都用shmid完成。
一句话:key 用来创建和查找,shmid用来干活。
2.2、建立映射关系
在物理内存创建好共享内存后,我们作为用户只能根据虚拟地址读写。
所以建立地址空间到共享内存的映射必不可少。

at 即attach:匹配,关联的意思。
shmat会在地址空间的共享区申请空间与共享内存建立页表映射。

参数说明:
(1)shmid
即创建共享内存时的返回值。
(2)*shmaddr 和 shmflg
直接设置为nullptr 和 0就可以了。
返回值说明:
成功返回起始虚拟地址,失败返回-1,错误码被设置。
🔆有了起始虚拟地址,我们就可以直接拿着虚拟地址进行向共享内存读写数据了。

2.3、回收共享内存资源
我们在内存申请了空间,那么势必就要对共享内存回收和释放。
❓️❓️ 你肯定会问:进程退出,OS不是会自动回收释放嘛?
结论:共享内存的生命周期并不是随进程的,进程退出后,共享内存还存在。
下面我们马上就会根据实验看到这一现象!!!
回收和释放共享内存资源分为两步:
(1)步骤一:解除共享内存与地址空间的映射关系

系统接口:shmdt,参数即起始虚拟地址。
(2)步骤二:真正释放销毁共享内存

参数说明:
1)shmid
共享内存描述符。
2)cmd
标志位,我们介绍两个比较常用的:
IPC_RMID 表示删除共享内存;
IPC_STAT 获取共享内存属性,用buf参数输出。
3)buf
输出型参数,当标志位cmd为 IPC_STAT时,获取到共享内存的属性数据。
补充:获取共享内存属性数据示意图:

key值被保存在共享内存的内核数据结构中,你说多个进程怎么通过key找到同一块共享内存!!!
三、通信实战
上面所有的准备工作到这里就已经完毕了!!!

下面我们进行实战演练(练习系统接口),同时补充和解决上面遗留的一些细节问题:
实例一:两个进程简单通信
例如:server进程读,client进程写
server 为创建者身份,client 为用户身份。
++说明:++我们通过c++面向对象的方式实现,模块化更高,更加清晰。
***三部曲:***创建共享内存------建立映射------回收释放共享内存
(1)创建共享内存------构造函数
共享内存只创建一次,而我们有server身份和client身份。
所以,server负责创建,那么调用shmget时,shmflg标志位应该为:
IPC_CREAT | IPC_EXCL | 0666 ;
client只获取就好,shmflg标志位应该为:IPC_CREAT。
我们在实例化server 和 client对象时传入参数就可以在shmget时区分。
(2)建立映射
server 和 client和相同,直接调用shmat即可。
(3)回收释放------析构
由于我们只是server创建共享内存,所以我们只在server对象的析构函数中回收释放共享内存就好,即调用 shmdt 和 shmctl。
补充指令:
(1)ipcs -m
bash
// 查看共享内存
ipcs -m

(2)ipcrm -m shmid
bash
// 删除shmid共享内存
ipcrm -m shmid
代码实现:
构建脚本:Makefile
bash
# //////////////////Makefile自动化构建///////////////////
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++11
client:client.cc
g++ -o $@ $^ -std=c++11
.PNONY:clean
clean:
rm -rf server client
核心封装:共享内存操作类 comm.hpp
cpp
#pragma once
#include <iostream>
#include <cstdio>
#include <string>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <unistd.h>
void *gdefault_virtualaddr = nullptr; // 初始化起始虚拟地址
const int gdefaultid = -1; // 初始化共享内存shmid
const int gsize = 4096; // 共享内存大小
const std::string path = "."; // 路径参数
const int projid = 0x3f; // ftok参数
key_t key = -1;
#define CREATER "creater"
#define USER "user"
// 处理异常
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0);
class Shm
{
private:
// 创建共享内存
void CreatHelper(int flg)
{
// 根据key值创建共享内存
_shmid = shmget(_key, _size, flg);
if (_shmid < 0)
ERR_EXIT("shmget\n");
printf("shmid: %d\n", _shmid);
}
// creater负责创建共享内存(server)
void Creat()
{
CreatHelper(IPC_CREAT | IPC_EXCL | 0666);
}
// user 获取就好(client)
void Get()
{
CreatHelper(IPC_CREAT);
}
// 共享内存与进程解除关联
void Detach()
{
int n = shmdt(_start_mem);
if(n == 0)
{
printf("Detach success!\n");
}
}
// 回收释放申请的共享内存,仅server执行
void Destory()
{
// 先解除进程与共享内存的关联
Detach();
int n = shmctl(_shmid, IPC_RMID, nullptr);
if (n < 0)
{
ERR_EXIT("shmctl\n");
}
printf("Destory success!\n");
}
public:
Shm(const std::string &usertype)
: _shmid(gdefaultid),
_size(gsize),
_start_mem(gdefault_virtualaddr),
_key(key),
_usertype(usertype)
{
// 获取key值
_key = ftok(path.c_str(), projid);
if (_key < 0)
ERR_EXIT("ftok\n");
printf("key: 0x%x\n", _key);
if (_usertype == CREATER)
{
Creat();
}
else if (_usertype == USER)
{
Get();
}
else
{}
}
// 在虚拟地址空间申请空间,与共享内存关联
void Attach()
{
// 在地址空间(共享区)申请空间,并返回起始地址
_start_mem = shmat(_shmid, nullptr, 0);
if ((long)_start_mem < 0)
{
ERR_EXIT("shmat\n");
}
printf("Attach success!\n");
}
// 获取起始地址
void *VirtualAddr()
{
if (_start_mem == nullptr)
{
return nullptr;
}
printf("Virtualaddress: %p\n", _start_mem);
return _start_mem;
}
~Shm()
{
// server析构
if (_usertype == CREATER)
{
Destory();
}
}
private:
int _shmid;
size_t _size;
void *_start_mem;
key_t _key;
std::string _usertype;
};
服务端代码:server.cc
cpp
// ////////////////////// server ///////////////////////////
#include "comm.hpp"
int main()
{
Shm shm(CREATER);
shm.Attach();
sleep(2);
char* mem = (char*)shm.VirtualAddr();
int cnt = 10;
// 读
while(cnt--)
{
sleep(1);
printf("%s\n", mem);
}
return 0;
}
客户端代码:client.cc
cpp
// /////////////////////// client ///////////////////////
#include "comm.hpp"
int main()
{
Shm shm(USER);
// 在自己的地址空间(共享区)申请空间,与共享内存关联
shm.Attach();
char * mem = (char*)shm.VirtualAddr();
// 写入
for(char ch = 'A'; ch <= 'D'; ch++)
{
sleep(1);
mem[ch-'A'] = ch;
}
return 0;
}
效果演示 + 结论总结:

监视脚本:
bash
while :; do ipcs -m; sleep 1; done

现象一: 一开始有两个进程与共享内存关联,nattch = 2,client写完后退出,nattch变为1,最后当我们ctrl + c将server进程退出后,nattch变为0,没什么问题。
当我们用ipcs -m查看共享内存属性时:

结论: 发现共享内存并没有被释放,即共享内存生命周期不随进程。
**现象二:**进程正常终止,当我们发现server读到的数据其实是杂乱的,当client写时,server立马读。
不会像管道那样,只允许一个进程对管道操作,即写进程写时,读进程阻塞,反之。
所以管道正常通信时,我们读到的数据是完整的,即管道具有同步机制。
而共享内存没有同步机制,这也就是它为什么最快的原因,现在懂了吧。


结论: 共享内存没有同步机制,数据不一致,没有保护机制。
再补充一个结论:再内核中,共享内存在创建时,它的大小必须是4KB(4096bite)的整数倍,如果超过或不足4KB的整数倍,则向上取整,例如:申请4097bite,实际开辟4096 * 2。
实例二:结合命名管道解决共享内存无法同步问题
💡 有什么办法解决共享内存没有同步机制这个问题:
下面介绍用命名管道实现共享内存同步通信。
原理:
只有当client进程通过命名管道发送一个数据后,server才被唤醒,再读共享内存的数据,当client没有向server发送消息时,server进程其实在阻塞。
这种多个执行流在访问共享内存时具有一定的顺序性,叫做同步。
代码可以在我们gitee中的Linux_learning仓库中查看
四、补充
下面两种system V IPC方式了解一下即可,

首先:为什么有了共享内存,还要引入消息队列和信号量?
共享内存 :负责传数据 ,速度最快 ,但👉没有同步、没有互斥、不安全。
消息队列 :专门补 数据规整 + 异步通信(自带报文、自带读写阻塞)
信号量 :专门补 互斥 + 同步(解决多进程抢资源、竞态问题)
4.1、system V消息队列
首先介绍一个概念
报文: 一段完整,独立,有边界的数据包。
读的时候只能以报文为单位读取,解决了共享内存数据混乱的问题。
原理示意图:

消息队列是双向通信,通信双方就可以向队列写,也可以从队列读。
内核中可能同时存在多个消息队列,需要管理:先描述, 再组织!!!
系统接口:
基本和共享内存一样,所以我们下面简单说明一下:
(1)创建消息队列

(2)发送消息(报文)

参数:
1️⃣ msqid 即msgget返回值;
2️⃣ msgsz 即发送数据包中数据块 mtext的大小;
3️⃣ msgflg 默认0就好;
4️⃣ msgp 即要发送的消息(报文),它其实是一个结构体,包含消息类型(即标识消息是哪一个进程发送过来的),以及数据块。

发送数据前我们需要先创建一个 struct msgbuf 结构体对象,由用户自己约定mtype,数据单独放在mtext中。
***细节一:***不同进程的 mtype 应当 不同,因为读消息时进程不能读自己的消息。
***细节二:***msgsz 其实是mtext的大小,并非结构体对象大小。
***细节三:***mtype 必须大于0。
细节四: 发送的消息被加入队列尾部,保持发送顺序,因此发送是严格 FIFO 的。
(3)接收消息(报文)
1️⃣ msqid 即msgget返回值;
2️⃣ msgp指向struct msgbuf 结构体对象,读到的数据放在里面(输出型参数);
3️⃣ msgsz 要读的数据的大小;
4️⃣ msgtyp 即要接受的消息(报文)的类型,即要读哪一个进程发送过来的消息;
5️⃣ msgflg 设置为0就好。
与普通 FIFO 队列不同,进程可以通过指定的 mtype 选择性接收特定类型的消息。
例如:进程 A 只接收 mtype = 2 的消息,进程 B 只接收 mtype = 1 的消息,实现了定向通信,无需按队列物理顺序读取。
(4)获取消息队列属性

果然,不出意外的消息队列 几乎与共享内存一致,内核数据结构中也是key。
(5)回收释放消息队列

(6)查看消息队列(指令)
bash
// 查看指令
ipcs -q
// 删除指令
ipcrm -q msqid

总结:
这种设计使得消息队列既保留了 FIFO 的有序性,又通过消息类型实现了多对多、定向的异步通信,同时自带同步阻塞机制(队空读阻塞、队满写阻塞),是一种高效、安全的进程间通信方式。
消息队列生命周期也不是随进程的!!!
4.2、system V信号量
信号量同样是一种解决共享内存数据不一致的方案!!!
消息队列是将消息进行了打包,来让你一条一条读。
信号量则是通过保护临界资源(❓️),来实现数据一致。
引入一批概念:
***共享资源:***多进程通信时看到的同一份资源就是共享资源,如共享内存。
***临界资源:***被保护起来的共享资源就叫做临界资源。
***临界区:***进程中涉及到访问共享资源的代码段即临界区。

共享内存数据不一致本质是多个进程可以同时对共享内存进行读写,没有任何保护机制。
而读写 是谁在读写?
进程中某段程序(代码)在读写。
📳那我们对这部分代码(临界区)进行约束一下,保证任意时刻只有一个操作流访问共享资源。
对临界区进行约束,相当于变相保护了共享资源。

这种在任意时刻只能有一个执行流访问共享资源 的模式,叫做 互斥。
上面我们通过命名管道解决共享内存数据不一致问题时,还引入了 同步的概念。
即多个执行流在访问共享资源 时具有一定的顺序性 ,就叫做 同步。
可以把它想象成 我们在ATM机取钱。
有个问题:锁也要被共享,即共享资源,谁来保护锁?
再来一把锁吗,这不变成鸡生蛋,还是蛋生鸡了。
所以要求锁在设计时要具有原子性!!!

原子性: 要么完整做完,要么完全不做,中间不会被打断、不会被拆分(没有中间过程)。
对应到 锁,进程要么竞争到锁,要么等其他进程解锁。
说实话到这里还是很抽象。
再举个栗子:
✔️消息队列在写数据时要么不写,要么写完整的一条;接受时,要么不读,要么读完整的一条消息。
所以消息队列再带原子性。
❌️ 共享内存在写的时候,读进程正常读,相当于打断了写过程。
共享内存不具备原子性,导致数据不一致。
下面正式进入信号量 ⏩️
先抛结论:
信号量本质是一个计数器,描述临界资源中,资源数量的多少。

***举个栗子:***电影院
把电影院某个放映厅看作临界资源,其中的座位数就是信号量。
我们买票看电影,就是在临界资源中申请资源。
有座位就可以买到票看电影(访问资源);当前已经没有座位了,就不能看电影(访问资源)。
此时,临界资源不再整体使用,而是进行划分。

假设当前信号量为sem = 16
来一个进程想要访问临界资源,先 sem--,如果sem > 0,表示还有资源,分配给进程;sem = 0,进程阻塞。
当某个进程访问结束,sem++,再把资源分配给阻塞的进程。
总结:
1️⃣所以说,进程想要访问临界资源的一小块,必须先申请信号量。
申请信号量的本质就是:对资源的预定机制。
2️⃣信号量的核心是两个原子操作,由内核保证不被打断:
**P操作:**申请资源,若信号量值 > 0:减 1,直接通过;若值 = 0:进程阻塞等待;
**V操作:**释放资源,信号量值 + 1,唤醒等待的进程。
注意:
资源给了进程之后,其他进程不能访问该资源,此时,多进程可以并发访问资源,互不影响!
🔆 补充 :二元信号量(互斥锁)
这个最常用!!!
bash
// 初始化:sem = 1
P(sem); // 进入临界区:sem = 0,其他进程会阻塞
// ...
// 访问共享资源(比如共享内存)
// ...
V(sem); // 离开临界区:sem = 1,唤醒等待进程
相当于放映厅只有一个座位,任意时刻只能有一个人看电影。
***有个问题:***信号量和通信有什么关系?
因为所有进程在申请临界资源时都得看到信号量,信号量本身就是共享资源。
通信的一组进程,任意一个进程通过对信号量进行P操作和V操作,实际都是在控制其他进程。
例如当sem = 0时,一个进程退出,sem++,唤醒阻塞的进程。
系统接口:
(1)申请信号量

一般nsems就完全够用了,如果申请多个,就是信号量数组。
(2)初始化信号量

有点瑕疵就是:创建与初始化是分开的。
(3)信号量的使用

一次可能对多个信号量操作,一般都是一个,所以*sops是一个结构体数组。
(3)删除信号量

(4)查看信号量(指令)
bash
// 查看信号量属性
ipcs -s
// 删除信号量
ipcrm -s semid

五、总结
为什么共享内存,消息队列和信号量是system V标准?
因为他们底层都是通过key值来作为唯一标识的,拥有统一的内核管理方式和调用接口。
你可能注意到了一个细节:
描述共享内存,消息队列和信号量的结构体的第一个属性都是xxx_perm:

所以内核在管理时用一个ipc_id_array的结构体来统一管理,而结构体中包含一个柔性数组(方便扩容)。
柔性数组为kern_ipc_perm结构体指针类型。
当我们申请共享内存,消息队列或者信号量时,柔性数组就会扩容。
我们之前使用的shmid,msqid 和semid就是柔性数组下标。
由于他们第一个属性都是ipc_perm,所以就可以用kern_ipc_prem指向一个共享内存,消息队列或者信号量对象(结构体对象的地址就是结构体首元素的地址)。
kern_ipc_perm结构体类似于C++基类,而通过kern_ipc_perm又刚好能够找到key 值,所以key就能作为唯一标识区分他们。
所以你在申请共享内存,消息队列和信号量时,在三者之间key要不同。
那除了key,我们怎么访问其他属性呢?
可以将柔性数组元素强转为对应的指针类型,就能够访问了。
例如:(msg_queue*)p[0] -> other
这不就是用C语言实现多态嘛!!!
p[i] 强转成什么类型呢?
内核可能通过我们在申请共享内存,消息队列或信号量时使用的不同的系统调用来区分。
😄 创作不易,你的点赞和关注都是对我莫大的鼓励,再次感谢您的观看😘