systme V共享内存(version1)
system V是有关共享内存的一个标准 , 具体的实现有 **共享内存 / 消息队列 / 信号量 **三种 .
一. 共享内存:
1.核心原理:
- 在学习程序地址空间的时候 , 有一个示意图 , 在栈区和堆区之间存在一个共享区 , 这便是共享内存的来源 .
- 包括程序在加载时 , 动态库的代码和数据也是处在段空间 , 以达到一份资源多个进程使用的目的.
- 共享内存服务于进程间通信 , 本质还是让多个进程看到同一份资源 , 因此"共享"一词非常见名知义.
2.优缺点:
- 优点 : 速度快 , 因为各个进程直接就能访问这块空间 , 不需要经过任何系统调用或者中间层
- 缺点 : 通信双方对对方的行为是全然不知的 , 因此在缺乏并发编程中所需要的
互斥性
/顺序性
. 需要通过之后的信号量来解决.
二.共享内存相关函数:
1,ftok
- ftok函数里包含了一套算法 , 通过接受传入的路径和项目id来生成一个唯一的key值.
//声明和定义
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
//测试代码int i = ftok(".",1);int j = ftok(".",2);int k = ftok(".",1);std::cout << "i: "<< i << std::endl;std::cout << "j: " << j << std::endl;std::cout << "k: " << k <<std::endl;
#运行结果(i和k由同样的路径和项目id生成了同样的key值)
i: 16981300
j: 33758516
k: 16981300
2,shmget
- shmget函数用于在共享区申请一块共享内存 , 返回一个整形值shm_id来表示这块内存
- 注意 : 如果是创建方,还需要在
//声明和定义
#include <sys/shm.h>int shmget(key_t key, size_t size, int shmflg);
//第一个参数key就是由ftok生成的值
//第二个参数size就是想要开辟的共享内存大小
//第三个参数shmfla就是通过位掩码传参,指定创建方式,创建方传递IPC_CREAT | IPC_EXCL // |权限掩码
// 使用方传递 IPC_CREAT即可
// IPC_CREAT用于申请空间
// IPC_EXCL确保创建成功
//测试代码
int key = ftok("./common",1);
int id = shmget(key,4096,IPC_CREAT|IPC_EXCL);
3,shmat
- shmat函数用于让进程和已申请的共享内存绑定 , 此后才能使用.
//函数声明和定义
#include <sys/shm.h>
void *shmat(int shmid, const void *_Nullable shmaddr, int shmflg);
//参数一shmid就是函数shmget的返回值
//参数二shmaddr用来指定共享内存的映射地址,可以填nullptr,让操作系统决定
//参数三shmflg是一些选项,这里暂时不用
//返回值是一个void*类型的指针,标识共享内存,可以强制类型转换后使用
//测试代码
int key = ftok("./common",1);
int shm_id = shmget(key,4096,IPC_CREAT|IPC_EXCL);char* ptr = (char*)shmat(shm_id,nullptr,0);//此后就可以通过ptr来访问共享内存了!
4,shmdt
shmdt函数用于让进程和共享内存解绑,共享内存只有在所有进程都解绑后才能真正释放.
//函数声明和定义
#include <sys/shm.h>
int shmdt(const void *shmaddr);
//返回值用来表示是否解绑成功
//参数一shmaddr就是shmat函数返回的共享内存的虚拟地址
//测试代码
int key = ftok("./common",1);
int shm_id = shmget(key,4096,IPC_CREAT|IPC_EXCL);
char* ptr = (char*)shmat(shm_id,nullptr,0);int r = shmdt(ptr);
5,shmctl
- shmctl函数是用来执行对共享内存的一些控制操作的
//函数声明和定义
#include <sys/shm.h>
int shmctl(int shmid, int op, struct shmid_ds *buf);
//返回值用来标识异常情况,-1为异常,0为正常
//参数一就是shmget返回的shm_id值
//参数而是选项 , 常见的有IPC_RMID用来根据shm_id删除
//测试代码
int key = ftok("./common",1);
int shm_id = shmget(key,4096,IPC_CREAT|IPC_EXCL);
char* ptr = (char*)shmat(shm_id,nullptr,0);
int r = shmdt(ptr); int ret = shmctl(shm_id,IPC_RMID,nullptr);
三.基于共享内存的简易进程间通信:
通过共享内存来实现进程间通信十分简单 , 只要通过相关函数让进程和某一块共享内存绑定后 , 在程序里就可以像通过指针访问堆空间那样访问共享内存了.
1. 设计核心:职责分离与高效通信
整个通信机制建立在一个基本原则上:数据要快,通知要准。
[!NOTE] 核心策略:
共享内存负责跑数据:实现高性能。
命名管道负责发信号:实现严格同步。
1.1. 高性能数据传输([[Shm Class]])
-
共享内存的巨大优势在于,一旦建立,client 和 server 就能直接访问同一块内存区域。
-
这避免了传统 IPC 方式中内核态与用户态之间的数据拷贝,极大地提升了数据交换速度。
-
在我们的设计中,client 不断地向这块内存写入新数据(A 到 Z),而 server 负责从中读取。数据是 Shm 类中的
char* ptr
。
1.2. 轻量级进程同步([[NamedPipe Class]])
[!同步的必要性]
- 在程序中 , 我希望一个进程写两个字符,另一个进程就读两个进程.
- 但是,两个进程虽然读取同一块共享内存,彼此之间却是独立的,也就是另一个进程一直在自顾自的读.
- 因此就需要一种方法,使得一个进程写入两个字符后能够通知另一个进程来读.
- 这里使用了管道作为传递这个信息的媒介
-
NamedPipe 在这里不承担大量数据传输任务,它充当一个信号机制(Notifier)。
-
client 写入一个极小的字节(例如代码中的 ’c’),目的是唤醒正在等待的 server 进程。
-
server 通过 Wait() 阻塞等待管道数据。一旦数据到达,它立即知道共享内存中的数据已更新,可以开始读取。
2. 关键类的实现逻辑
2.1. Shm 类:共享内存的生命周期管理
Shm 类通过区分两个身份 CREATOR (Server) 和 USER (Client) 来管理资源,确保资源的安全创建和释放。
步骤 | 角色 | 动作 | 目的 |
---|---|---|---|
分配 | CREATOR | 调用 Create() | 使用 IPC_EXCL 确保它是内存段的唯一创建者。 |
获取 | USER | 调用 Get() | 获取已存在的内存段 ID (shmget )。 |
挂载 | 共同 | 调用 Attach() | 使用 shmat 将内存段映射到各自进程地址空间。 |
销毁 | CREATOR | 析构函数中调用 Destory() | 只有 Server 负责使用 shmctl(IPC_RMID) 释放共享内存,避免 client 意外销毁。 |
2.2. NamedPipe 类:实现阻塞与唤醒
NamedPipe 类利用了 FIFO 文件在 open 和 read 时的阻塞特性来实现同步。
方法 | 角色 | 机制 | 效果 |
---|---|---|---|
Wait() | Server | open(O_RDONLY) 和 read() | Server 进程阻塞,暂停 CPU 运行,等待数据。 |
Awake() | Client | open(O_WRONLY) 和 write(’c’) | 写入操作立即解除 server 的阻塞,唤醒它。 |
3. 程序的实际运行机制
-
启动阶段: server 进程创建共享内存和命名管道,随后进入 np.Wait() 状态,开始阻塞等待。
-
数据写入: client 进程获取共享内存,开始循环。它向 ptr 地址写入一对新字符(例如 ’AA’),并确保字符串以 KaTeX parse error: Undefined control sequence: \0 at position 8: \text{'\̲0̲'} 结尾。
-
发送通知: 数据写入完成后,client 立刻调用 np.Awake() 向管道写入一个字节。
-
接收响应: 管道中的数据解除 server 的阻塞,server 进程被唤醒,并立即从共享内存中读取数据 (
printf("%s\n", ptr);
)。 -
循环往复: server 打印后再次进入 np.Wait() 沉睡,等待 client 的下一个信号。
四.消息队列:
消息队列和上面谈到的共享内存属于同一个system V标准 , 因此在设计和使用上极其的类似.
1.核心原理:
可以把消息队列想象成操作系统内核里的一个"快递站" , 由数据结构队列来实现 .
假设进程A和B之间要通信 , 言下之意就是要能看到同一份资源 , 只需要访问这个共享的队列即可.
- 发送 : 进程A把发送的数据打成一个包裹(队列节点) , 交给操作系统 , 由操作系统将这个节点链接到队列之中.
- 接受 : 进程B通过访问这个队列 , 拿到进程A发送的包裹(访问到指定队列节点) , 这样就完成了一次通信.
[!困境]
进程B怎么知道快递站的那个包裹(队列里的那个节点)是自己的呢?
[!解法]
- 快递站的包裹里除了有我们需要的物件 , 包裹面上还有这个包裹的相关信息.
- 因此 , 进程A在发送数据时 , 除了发送数据本身 , 再附带上数据的身份信息即可 . 对于数据来说,这个包裹就是一个结构体—既包含物件(数据),也包含身份信息(属性)
这个过程是双向的 , 进程A可以发信息给进程B , B同样可以发送给A
2.接口与命令
消息队列的接口和共享内存惊人地相似,这就是“标准”的力量,学起来有很好的可迁移性 。
- 获取 Key: 同样使用
ftok
来生成一个全局唯一的key
值,让不同进程能找到同一个资源 。 - 创建/获取 (
msgget
): 类似于shmget
,通过key
和flag
(如IPC_CREAT | 0666
) 来创建或获取一个消息队列的标识符 (ID) 。 - 发送/接收 (
msgsnd
/msgrcv
):- 发送 (
msgsnd
): 指定要往哪个队列 ID 发送数据。你需要自己定义一个结构体,里面必须包含一个long
类型的mtype
(消息类型) 和一个数据缓冲区mtext
。 - 接收 (
msgrcv
): 除了指定从哪个队列 ID 接收,还要多传一个msgtyp
参数,告诉内核你只想接收哪种类型的消息 [cite: 16]。 - 一个细节:
msgsnd
和msgrcv
中的msgsz
参数,严格来说指的是消息正文 (mtext
) 的大小,而不是整个结构体的大小 [cite: 18]。
- 发送 (
- 控制/删除 (
msgctl
): 和shmctl
一样功能强大。IPC_RMID
: 立刻删除消息队列 [cite: 14]。IPC_STAT
: 从内核拷贝消息队列的属性信息到一个用户空间的结构体 (msqid_ds
) 中 。
- 命令行工具:
- 查看:
ipcs -q
- 删除:
ipcrm -q <msqid>
- 查看:
3.简单代码实践:
//client.cpp
#include <iostream>
#include <sys/ipc.h> //ftok
#include<sys/msg.h> //msgget msgsnd
struct my_msgbuf
{
long mtype;
char mtext[2];
};int main()
{//基于消息队列的进程间通信(写端)int key = ftok(PATH_NAME,PRO_ID);int msg_id = msgget(key,CLIENT);my_msgbuf data;data.mtype = 1;data.mtext[0] = 'a';data.mtext[1] = '\0';msgsnd(msg_id,&data,2,0);sleep(5);
}
//server.cpp
#include <iostream>
#include <sys/ipc.h> //ftok
#include<sys/msg.h> //msgget msgrcv
struct my_msgbuf
{long mtype;char mtext[2];
};
int main()
{// 基于消息队列的进程间通信int key = ftok(PATH_NAME, PRO_ID);int msg_id = msgget(key, SERVER);my_msgbuf data;//msgrcv(msg_id,&data,2,1,0);msgrcv(msg_id,&data,1,1,0);data.mtext[1] = '\0';std::cout << "接收到信息 : " << data.mtext << std::endl;sleep(6);msgctl(msg_id,IPC_RMID,nullptr);
4.和共享内存的差别:
共享内存 | 消息队列 | |
---|---|---|
效率 | 进程直接操作内存,飞快 | 涉及用户态->内核态->用户态,略慢 |
数据同步机制 | 完全没有 | 数据以结构体对象传递 , 保证写入的原子性 |
五.并发编程的基础概念:
在了解信号量前 , 势必要涉猎一点并发编程的概念 . 共享内存速度虽快,却缺乏保护机制 .
比如一个进程在像共享内存里写 , 另一个也在写 , 这样就会互相覆盖,导致数据丢失.
1. 共享资源 :
进程间通信的本质就是让不同的进程看到同一份资源 , 也就是共享了 , 这个好理解.
2.临界资源:
乍一看, "临界"一词很抽象 , 但是对于临界资源的英文翻译是
critical resource
, 即关键的资源 .
[!临界资源-critical resource]
英文翻译首先为其奠定了基调 , 即十分重要和关键 .
随后可以这样理解 , 试想一种情况 : 进程A期望给进程B发送一段字符串"aabbcc" ,这就是很关键的内容 , 如果进程B读到的是不完整的"aa",那就会出各种问题 .
因此临界区的概念属于逻辑上的划分,如下图
![[临界区.png]]
3.互斥:
[!ATM机的例子]
银行的 ATM 小隔间。ATM 机就是临界资源,取钱的一系列操作就是临界区。门锁就是互斥机制。你进去后把门一锁,在你出来之前,谁也进不去,保证了你取钱过程的独占和安全
要想解决这种情况 , 就需要一种机制 , 使得同一块资源在同一时间只能被一个进程修改
4.同步:
同步机制在一定程度上确保了临界资源的完整性
- 互斥强调
独占
, 而同步强调顺序
. - 它指的是当多个进程或者线程在访问资源时,要严格遵循一定的顺序.
- 比如让进程A写一点,然后进程B才能读,进程B读完后进程A才能继续写
5.原子性:
原子性是用来描述操作的不可分割性 , 即一次数据操纵(读or写)要么成功失败 , 不存在中间态(读一部分or写一部分).
6.总结:
总之 , 为了确保在多进程下安全的共享资源 , 就需要明确临界资源的概念 , 通过
互斥
来保证一个地方一次只能来一个
和同步
来保证必须一个一个来
. 最后通过原子性
保证这些动作在执行过程中不要被打断 ,从而实现目的.
六.信号量:
信号量像一个红绿灯 , 限定一个地方现在能不能让进程过去.
两种类型 :
- 二元信号量 : 如果一块资源只能由一个进程独占 , 那就可以用两态来标识资源的占据与否 .
- 多元信号量 : 如果一块资源被划分为了很多块 , 就可以用一个信号量来标识这块区域资源的占用情况 . 类似于一个计数器 , 每占用一块 , 计数器-- . 计数器归零,则不允许新的进程来访问