目录
前言:
1.共享内存
1.1.什么是共享内存
1.2.共享内存使用接口
shmget函数
shmat函数
shmdt函数
shmctl函数
2.共享内存实现通信
2.1.代码实现
comm.hpp
server,cpp
client.cpp
2.2.共享内存的缺点
2.3.实现通信的同步化
2.4共享内存通信的优势
3.初识消息队列和信号量
3.1.消息队列
3.2.信号量
4.System V IPC的统一管理
前言:
System V IPC(系统5的进程间通信)是在Unix操作系统上实现进程间通信(IPC)的一种机制。它引入了一大类进程间通信方法,包括共享内存、消息队列和信号灯集等IPC对象。每个IPC对象都有唯一的ID,这个ID在创建时由系统分配,并且IPC对象在创建后一直存在,直到被显式地删除或系统关闭时自动释放。
在这篇博客中,我们重点介绍System V IPC中的共享内存部分,以及学习消息队列、信号量的一些接口的使用和 Linux下实现的System V IPC统一的数据结构体系……
1.共享内存
1.1.什么是共享内存
我们已经清楚:进程间通信的本质是让不同的进程看到同一份资源。那么共享内存这种进程间通信方式的实现是通过让不同的进程通过“共享”某一块内存来实现的。
共享内存是通过在物理内存中开辟一块内存,使得这块内存通过页表映射,到两个(多个)不同进程的进程地址空间中,从而让不同的进程可以访问到这一块物理内存,实现间接的数据交互的一种进程间通信方式。
讲完生硬的概念,我们来进入一个场景,共享内存就相当于隔绝了两个村子的河流,而两个进程就相当于这两个村子的两个人,当这两个人(进程)需要传送东西(进行数据交互)就能通过这个河流(共享内存) 来实现,那么通过这个河流,就能实现两个人的通信了……
1.2.共享内存使用接口
shmget函数
在这个图中:我们看到了key是共享内存的名字,那这个key我们怎么获得的呢?
// 通过ftok算法形成一个唯一的key
key_t key = ftok("名称", 传入一个int参数);
因为实际场景下会出现许多个共享内存同时进行通信的情况,这时系统为了对这些共享内存进行管理就需要给他们一个唯一的标识符shmid(类比:文件fd、进程pid),而这个shmid是通过一个唯一的key值来实现的。并且如果我们需要进程进程间通信,那么我们也需要让不同的进程能够访问到同一个key和同一个shmid。
具体实现如下:
// 同一个文件名
const string filename = "/home/Czh_Linux/code/shared_memory";
// 同一个proj_id
const int proj_id = 2022044026;
// 获取唯一标识符
key_t GetKey()
{
// 通过ftok算法形成一个唯一的key
// 通过相同的filename、和project_id
key_t key = ftok(filename.c_str(), proj_id);
if (key < 0)
{
cerr << "errno: " << errno << ", errstring: " << strerror(errno) << endl;
exit(1);
}
return key;
}
这里我们传入文件名的原因:可以让不同的进程更加方便的访问到同一个key……我们在获取了唯一的key之后,接下来就是学习shmget如何使用了。
shmget的具体使用分为两种情形:
创建一段新的共享内存:
// 参数(唯一标识符key,共享内存的大小,打开方式|权限)
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0644); // 共享内存的id
这段代码中 :表示创建一段共享内存,则需传入权限,并且通过IPC_EXCL防止重复创建,并且提供权限
打开一段已有的共享内存:
// 打开方式直接设置为0即可
int shmid = shmget(key, 4096, 0);
但是实际上我们使用时,一般将这两种情况进行封装改造一下:
// 创建/打开 共享内存封装函数
int ShmOption(key_t key, int flag)
{
// 参数(唯一标识符key,共享内存的大小,打开方式|权限)
int shmid = shmget(key, MEMORYSIZE, flag); // 共享内存的id
cout << shmid << endl;
// key作为在内核中标识shm的唯一性
// 对共享内存进行操作时,我们是通过shmid来进行的
if (shmid < 0)
{
cerr << "shmid error: " << errno << ", errstring: " << strerror(errno) << endl;
exit(2);
}
return shmid;
}
// 创建新的共享内存,并返回shmid
int CreateShm(key_t key)
{
cout << "新的共享内存创建完成" << endl;
// 创建,则需传入权限,并且通过O_EXCL防止重复创建,并且提供权限
return ShmOption(key, IPC_CREAT | IPC_EXCL | 0644);
}
// 打开原有共享内存,并返回shmid
int GetShm(key_t key)
{
// 打开,则只需通过O_CREAT打开
return ShmOption(key, 0);
}
所以我们外部调用的时候,通过不同的接口实现即可,另外GetKey()函数在上面有,也是我们自己实现的封装的小模块
值得一提的是:我们可以分别通过下面这两个Linux指令实现对共享内存的查看和删除
// 查看当前的共享内存
ipcs -m
// 删除当前共享内存
ipcrm -m "对应的shmid"
另外:如果我们在进程中通过shmget开辟出一块共享内存,却没有通过shmctl进行释放,我们调用ipcs -m就会发现:即使进程退出后,这块共享内存也依旧保留着物理内存中,也就是:共享内存的生命周期是随内核的
shmat函数
我们在1.1.中讲述了,当我们创建了共享内存,只是在物理内存中开辟了一块区域,而我们要通过进程使用这块区域,就需要将这块区域从物理内存挂接到进程地址空间,也就是实现页表的链接。这时我们可以通过shmat函数来实现……
// 以一个字符串数组为例
// 传入nullptr表示不对地址有过多的要求,传入0表示我们正常挂接
char *str = (char *)shmat(shmid, nullptr, 0);
当我们创建了一个str,操作系统就会为它从进程地址空间中找到一块虚拟地址来存放,而通过shmat函数,就实现了这一块虚拟地址映射到共享内存中,最终能够实现:某一个进程对str进行一些操作,其他的进程就能知道操作了什么,进而获取了信息。那么str在这里就扮演着,被进程们看到的一份资源的角色。
shmdt函数
对应上面我们挂接到共享内存的str,那么我们接触链接就通过共享内存式的释放这个str即可
// 内填挂接到共享内存的进程地址空间的地址
shmdt(str);
shmctl函数
值得注意:这里的表述是控制共享内存,而不是删除共享内存,但是这个接口可以实现删除这段共享内存的功能,从物理内存上释放这块内存……
// 找到共享内存的id,通过传入IPC_RMID 来实现
shmctl(shmid, IPC_RMID, nullptr);
到了这里我们对共享内存接口的学习就结束了,接下来我们将有一个demo级别的共享内存通信。
2.共享内存实现通信
2.1.代码实现
comm.hpp
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <cstring>
#include <fcntl.h>
#include <unistd.h>
using namespace std;
// 同一个文件名
const string filename = "/home/Czh_Linux/code/shared_memory";
// 同一个proj_id
const int proj_id = 2022044026;
// 建议设置为4096的整数倍
#define MEMORYSIZE 4096 // 设定的共享内存大小
// 获取唯一标识符
key_t GetKey()
{
// 通过ftok算法形成一个唯一的key
// 通过相同的filename、和project_id
key_t key = ftok(filename.c_str(), proj_id);
if (key < 0)
{
cerr << "errno: " << errno << ", errstring: " << strerror(errno) << endl;
exit(1);
}
return key;
}
// 将key转化为16进制
string ToHex(int id)
{
char buffer[1024];
snprintf(buffer, sizeof(buffer), "0x%x", id);
return buffer;
}
// 创建/打开 共享内存封装函数
int ShmOption(key_t key, int flag)
{
// 参数(唯一标识符key,共享内存的大小,打开方式|权限)
int shmid = shmget(key, MEMORYSIZE, flag); // 共享内存的id
cout << shmid << endl;
// key作为在内核中标识shm的唯一性
// 对共享内存进行操作时,我们是通过shmid来进行的
if (shmid < 0)
{
cerr << "shmid error: " << errno << ", errstring: " << strerror(errno) << endl;
exit(2);
}
return shmid;
}
// 创建新的共享内存,并返回shmid
int CreateShm(key_t key)
{
cout << "新的共享内存创建完成" << endl;
// 创建,则需传入权限,并且通过O_EXCL防止重复创建,并且提供权限
return ShmOption(key, IPC_CREAT | IPC_EXCL | 0644);
}
// 打开原有共享内存,并返回shmid
int GetShm(key_t key)
{
// 打开,则只需通过O_CREAT打开
return ShmOption(key, 0);
}
server,cpp
#include "comm.hpp"
int main()
{
key_t key = GetKey();
cout << "key: " << ToHex(key) << endl;
int shmid = CreateShm(key);
cout << "共享内存id: " << shmid << endl;
sleep(5);
// 挂接到共享内存
char *str = (char *)shmat(shmid, nullptr, 0);
// 实现通信
cout << "开始进行通信" << endl;
while(1)
{
sleep(1);
cout<<"挂接到共享内存的内容:"<<str<<endl;
}
shmdt(str);
cout << "将shm从进程地址空间中移除……" << endl;
sleep(5);
shmctl(shmid, IPC_RMID, nullptr);
}
这段代码中,我们通过创建共享内存再实现str挂接到共享内存中,接着通过打印str内容。
client.cpp
#include "comm.hpp"
int main()
{
key_t key = GetKey();
cout << "key: " << ToHex(key) << endl;
int shmid = GetShm(key);
cout << "client 连接上共享内存……, id: " << shmid << endl;
sleep(5);
char *str = (char *)shmat(shmid, nullptr, 0);
// 进行通信
char c = 'a';
for (; c <= 'z'; c++)
{
// 对共享内存对应的地址空间进行写入
str[c - 'a'] = c;
cout << "write: " << c << " done" << endl;
sleep(2);
}
shmdt(str);
}
client作为实现通信数据的发送端,这里我们不断的向str中插入abcd……这一系列字符然后,再server端打印,获取结果,这时server就能够通过str了解到共享内存的变化,也就是验证了能看到同一份资源。
2.2.共享内存的缺点
当我们运行1.3.这个代码demo时,发现我们数据的读取并不是同步的,并不是:写段发送一段数据,读端才进行接收,而是读、写段互不干扰,这里我们可以通过修改sleep的时间来体现,并且这些通信的数据是可以被任意修改的……
那么就有:
- 共享内存通信,没有提供同步的机制,并且数据直接裸露给所有使用者,需要考虑使用安全问题
- 会导致数据流无法同步,使得接受端、发送端的信息不一致
因为共享内存原生的通信方式无法进行同步通信,那么我们能不能嵌套一下我们学过的管道通信这一种同步通信方式来实现同步通信呢?
2.3.实现通信的同步化
这里我们只用修改一下,client和server的代码即可,需要进行测试就,更改对应代码
// 共享内存 + 命名管道通信
void client2()
{
key_t key = GetKey();
cout << "key: " << ToHex(key) << endl;
int shmid = GetShm(key);
cout << "client 连接上共享内存……, id: " << shmid << endl;
sleep(5);
char *str = (char *)shmat(shmid, nullptr, 0);
// 进行通信
int r_open = open("fifo", O_WRONLY);
char c = 'a';
for (; c <= 'z'; c++)
{
// 对共享内存对应的地址空间进行写入
str[c - 'a'] = c;
cout << "write: " << c << " done" << endl;
write(r_open, str, sizeof(str));
sleep(2);
}
close(r_open);
shmdt(str);
}
// 通过共享内存和命名管道进行通信
void server2()
{
// 创建管道文件fifo,权限0666
mkfifo("fifo", 0666);
key_t key = GetKey();
cout << "key: " << ToHex(key) << endl;
int shmid = CreateShm(key);
cout << "共享内存id: " << shmid << endl;
sleep(5);
char *str = (char *)shmat(shmid, nullptr, 0);
// 通过只读方式接收信息
int r_open = open("fifo", O_RDONLY);
// 实现通信
cout << "开始进行通信" << endl;
while (1)
{
read(r_open, str, sizeof(str));
cout << "挂接到共享内存的内容:" << str << endl;
sleep(1);
}
shmdt(str);
cout << "将shm从进程地址空间中移除……" << endl;
sleep(5);
close(r_open);
shmctl(shmid, IPC_RMID, nullptr);
}
如图: 当我们打开服务器时,cilent还没有运行之前,我们发现server处于阻塞状态,这时因为管道文件的读功能,在没有数据进行写入时,会等待直到管道的另一端进行写入。
而当我们进行调用时:发现数据是具有同步性的,并且当我们关掉client后,server端也没有新的数据写入,而是在while(1)中因为代码逻辑不断打印着当前管道的内容……
2.4共享内存通信的优势
共享内存是最快的IPC形式。一旦内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据
对于管道通信,我们知道管道通信的本质就是:数据通过管道文件的缓冲区从一个进程到另一个进程的转移,而当涉及到数据的转移其实就是,数据从一块缓冲区到另一块缓冲区的拷贝……
如图:在上层中这一句Hello World从键盘写入到进程,然后通过管道被另一个进程读取,然后打印在显示屏上。而实际上这一句Hello World是通过键盘缓冲区拷贝到用户级缓冲区再拷贝到管道文件缓冲区再拷贝到用户缓冲区最后拷贝到显示器上。
在这一个简单的场景中就需要实现4次拷贝,而拷贝是一种较大的系统资源消耗的行为,这也导致可管道通信的效率不是最优的,因为文件管理和内存管理是割裂开来的,无法避免拷贝的行为。
而对于共享内存,这段数据并不用通过进程的缓冲区进行互相拷贝,而是只用通过传输到物理内存中,直接通过写入、读取挂接到物理内存中的数据即可,减少了文件缓冲区这一块不必要的拷贝
所以通过共享内存这个进程间通信,我们可以尽可能的减少拷贝和系统接口的调用,这也就是共享内存是最快的进程间通信方式,实现进程不再通过执行进入内核的系统调用来传递彼此的数据
3.初识消息队列和信号量
3.1.消息队列
消息队列进程间通信机制中的一种重要组件,它允许不同进程之间进行数据的发送和接收。消息队列以链表式的结构组织数据,并存放在内核中,由各个进程通过特定的消息队列标识符来引用和进行数据传送。
消息队列的通信本质:提供一个队列,允许进程往这个队列中,发送、接收一个一个数据块,这个数据块的本质就是一个结构体,内部存储着 数据内容 和 发送方信息。
通过这个机制,我们随意传入数据进入消息队列,接收数据块时只要判断一下传入者信息就能够获取想要的得到的信息,并且当我们想要接收数据时,我们可以在任意时刻接收某一个数据,也就是消息队列允许进程间进行异步通信,又因为它的独特机制(比较于管道)对数据块进行标识,可以更加灵活地对数据进行读取
具体的接口使用可以看一位大神写的这篇优秀的博客:Linux进程间通信-消息队列(IPC、mq)C/C++代码接口_c语言消息队列-CSDN博客
这里建议大家看完这篇博客后,自己实现一下server、client通过消息队列通信的模块!!!
值得一提的是:当我们学习完相关接口的使用时,我们发现共享内存、消息队列的接口出奇的相似,这也是System V IPC标准的一种体现。同理通过msgctl这个函数,我们也可以确定消息队列的生命周期是内核级别的。而因为息队列存放在内核中,并由内核来维护,具有较高的可靠性和安全性。
3.2.信号量
信号量(Semaphore)是在多线程或多进程环境下使用的一种设施,主要用于控制对共享资源的访问。它可以看作是一个计数器,用于记录可用资源的数量。信号量的主要目的是实现进程或线程间的同步与互斥,以确保对共享资源的正确和安全的访问。
这一部分,因为涉及的内容过多,我们在下一篇博客中具体讲解,但是又因为信号量也是属于System V IPC这个标准的,我们可以知道它函数接口的调用也是类似与共享内存和消息队列的。
4.System V IPC的统一管理
操作系统在实际场景中,往往是需要多块共享内存、多个消息队列、多个信号量机制来进行不同进程间的通信,那么这些共享内存、消息队列、信号量要如何进行维护、进行管理的呢?那就又回到了操作系统的六字真言:先描述再组织
以共享内存的代码为例:
void test()
{
key_t key = GetKey();
cout << "key: " << ToHex(key) << endl;
int shmid = CreateShm(key);
struct shmid_ds ds;
// 通过IPC_STAT将该shmid中维护的结构体数据加载进ds中
shmctl(shmid, IPC_STAT, &ds);
cout << ds.shm_perm.__key << endl;
cout << ToHex(ds.shm_perm.__key) << endl;
cout << ds.shm_nattch << endl;
sleep(5);
shmctl(shmid, IPC_RMID, nullptr);
}
- 代码中的struct shmid_ds是操作系统维护共享内存的结构体对象,内部存储着该共享内存的属性,那么我们就能够延伸到操作系统对共享内存的管理,本质上就是对管理着共享内存结构体的结构体数据进行增删查改
- 当我们通过共享内存控制的接口,可以将对应的shmid共享内存的数据加载进我们定义的对象,这样子就能够实现对管理信息的访问了。
接着我们看一下IPC结构体是如何管理各自的数据的:
那么操作系统是如何管理这些不同的IPC对象的呢?我们在上图中看到不同的IPC对象中维护着一个相同的结构体struct ipc_perm,而这个结构体对象就是操作系统实际上管理IPC对象的载体。
结合这两张图,我们发现struct ipc_perm就是操作系统维护IPC对象的最基本的单位,也可以看做一个“基类”,只要我们管理好最基本的struct ipc_perm,就能对不同的IPC对象进行较好的管理。到了这里我们就对System V IPC这个体系有了较好的理解了……