目录
前言
1. System V IPC
2. 共享内存
系统调用接口
shmget
ftok
shmat
shmdt
shmctl
共享内存的读写
共享内存的描述对象
3. 消息队列
msgget
msgsnd
msgctl
消息队列描述对象
4. 信号量
系统调用接口
semget
semctl
信号量描述对象
5. 系统层面IPC资源
6. 补充
总结
前言
前边学习了管道通信,本文再来聊一聊其他的进程间通信方式:共享内存、消息队列、信号量;
1. System V IPC
System V IPC(Inter-Process Communication)是 Unix 操作系统的一种进程间通信机制。它提供了一组系统调用,允许不同进程之间共享数据和信息;主要有三种通信方式:共享内存、消息队列、信号量;通常用于需要高效数据交换和同步的应用场景;
2. 共享内存
· 前边提到的的两种管道都可以实现进程之间单向通信,但对于一些数据量大且需要频繁传输数据时效率就显得有点低,为了满足需求,并统一标准,于是便出现共享内存这一通信标准;
共享内存作为一种进程间通信的标准,主要是为了解决管道通信在大数据量和频繁传输数据时效率较低的问题。共享内存允许多个进程共享同一块内存区域,进程可以直接访问内存中的数据,避免了数据的复制和传输开销,从而提高了通信的效率;
共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据;
进程间通信的前提:让不同的进程看到同一份资源(由操作系统提供);
操作系统只允许两个进程使用共享内存通信吗?
肯定不是,操作系统允许系统中同时存在多个共享内存对于创建的多个共享内存当然也需要进行管理;先描述,再组织;存在一个struct shm的结构体;用于对共享内存的描述;(共享内存对象)每个新建的共享内存都有一个结构体描述对象,这样对多个共享内存的管理,就转换成了对结构体对象的管理;
构建信道:
- 创建共享内存区域。
- 将共享内存映射到进程地址空间
- 进行通信
关闭信道:
- 解除共享内存映射
- 减少引用计数。
- 释放共享内存
问题来了,如何保证第二个参与通信的进程,找到的就是同一个共享内存呢?匿名管道可以通过父子进程继承的方式找到,命名管道可以通过文件系统找到,共享内存如何保证信道成功建立呢?
存在一个唯一的标识,进行识别
系统调用接口
shmget
shmget 用于创建一个新的共享内存段,或者获取一个已经存在的共享内存段的标识符;
shmflg标志参数,目前只需了解两个:
- IPC_CREAT:共享内存不存在就创建(创建后把标识符写到共享内存属性中),存在就获取与返回共享内存标识符
- IPC_EXCL:不单独使用,一般与IPC_CREAT一起使用(IPC_EXCL | IPC CREAT):如果不存在就创建,如果存在就出错返回;主要用于确保创键的是一个新的共享内存;
补充:
- 共享内存(IPC资源)的生命周期是随内核的;
- ipcrm -m 删除共享内存
- ipcs -m查看存在的共享内存
- shmid:共享内存id,使用这个共享内存时,我们一般使用shmid来进行操作共享内存
- Key:一般不在应用层使用,只用来标识shm的唯一性;
ftok
前边我们提到如何让第二个进程找到共享内存,答案就是标识符,这里的key就是标识符;通信双方约定一个标识符,一方将标识符写入到共享内存对象中,另一方可以遍历进行查找标识符,就可以保证它们找到的是同一份资源;
这里的标识符,理论上是可以随便写一个,只不过随便写的方式很容易与系统中已经存在的标识符冲突,所以一般会使用算法来生成唯一的标识符;这个接口就是ftok ;
ftok()函数会使用指定的路径名和项目标识符来计算一个key值,该key值在系统中是唯一的,返回的key值可以作为参数传递给shmget()函数,从而指定共享内存段的标识符;
pathname参数是一个指向以null结尾的路径名的指针。它的作用是为了生成一个唯一的key值。ftok()函数会使用指定的路径名来获取文件的inode号,然后将其与proj id参数合并,生成一个唯一的key值;
这个路径名并不需要对应一个真实存在的文件,它只是用作生成key值的一个标识。通常情况下会选择一个在系统中唯一的、不会轻易更改的路径名来确保生成的key值是唯一的,以避免不同的进程之间出现key冲突;
pathname参数要指向一个可靠的文件路径,比如一个特定的系统文件或者应用程序中的固定路径。这样可以确保在不同的系统中或者不同的程序中,使用相同的路径名生成的key值是相同的;
shmat
shmat() 用于将共享内存段连接到调用进程的地址空间,形成一个虚拟地址的映射
接口返回值void*返回共享内存在共享区映射的起始地址;
shmdt
shmdt 用于将共享内存段从进程的地址空间中分离,即解除该共享内存段与进程地址空间的映射关系
成功,shmdt()函数返回0;如果失败,返回-1,并设置errno来指示错误类型 ;
shmctl
shmctl控制共享内存段的函数,包含获取共享内存段信息、设置共享内存段的权限、删除共享内存段等;它的功能很多,当前我们主要使用删除的功能;
IPC RMID:删除共享内存段
为什么我们使用标识符key需要使用者自己生成,为什么OS不直接自己生成?
就是为了让使用者知道,然后好让目标进程通过这个值找到共享内存;如果让系统生成,使用者不知道标识符,那目标进程又如何知道这个标识符呢?内存中有多个共享内存,目标进程又如何去找自己的共享内存呢?根本不可能;要想找到共享内存就必须两进程提前约定好标识符,这样才能找到共享内存;
共享内存的读写
共享内存在用户空间,如何控制读写呢?
在共享内存的开头或者结尾,留了一块区域,用来存储共享内存的属性,比如:读位置、写位置;
删除数据呢?和vector删除一样,不需要清空数据,只需要数据变无效即可;
特点:
- 共享内存通信不提供同步机制,它是直接裸露给使用者的,所以一定要注意共享内存的使用安全问题;
- 共享内存是所有进程通信速度最快的;
- 共享内存可以提供较大的空间;
为什么说共享内存是最快的通信方式?
对比一下管道:
进程A要想把数据通过管道发送给进程B,先从用户空间拷贝到管道(内核空间),进程B通过read把数据从管道再拷贝一份到自己的用户空间;
这里是很影响效率的具体分析一下:先把数据写到自定义的缓冲区,然后再把缓冲区的数据拷贝到内核空间的管道,读方再把数据从内核空间拷贝到自己定义的缓冲区;
共享内存:
进程A只要写入数据,就直接写入到了共享内存中,而进程B立马就能看到;共享内存的写入原理和malloc申请的空间使用类似,都是直接写入到内存中;不需要什么缓冲区,更不需要来回进行拷贝;共享内存不需要内核级别的拷贝,所以相对于管道来说效率更高,在应用场景中减少了拷贝的次数 ;
共享内存的描述对象
以上是部分属性;
编写一个简单的程序使用并验证一下:
comm.hpp
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <cstring>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>const std::string pathname = "/home/test/test/share_memory";
const int proj_id = 0x11223344;
//共享内存的大小建议设置成4096的整数倍
const int size = 4096;
key_t Getkey()
{key_t k = ftok(pathname.c_str(), proj_id);//把字符串转化成C字符串风格if (k < 0){std::cerr << "error: " << errno << " errorstring: " << strerror(errno) << std::endl;exit(1);}//std::cout << "key: " << k << std::endl;return k;
}std::string ToHex(int id)
{char buffer[1024];snprintf(buffer, sizeof(buffer), "0x%x", id);return buffer;
}
int CreateShmHelper(key_t key, int flag)
{int shmid = shmget(key, size, flag);if (shmid < 0){std::cerr << "error: " << errno << " errorstring: " << strerror(errno) << std::endl;exit(2);}return shmid;
}int CreateShm(key_t key)
{return CreateShmHelper(key, IPC_CREAT | IPC_EXCL | 0664);
}int GetShm(key_t key)
{return CreateShmHelper(key, IPC_CREAT);
}
test.cc
class Init
{
public:Init(){key_t key = Getkey();std::cout << "key: " << ToHex(key) << std::endl;shmid = CreateShm(key);// sleep(5);std::cout << "shmid: " << shmid << std::endl;std::cout << "开始将shm映射到进程的地址空间中" << std::endl;// sleep(5);s = (char*)shmat(shmid, nullptr, 0);// 打开管道用于发送信息进行控制,如果管道写端没有连接就一直阻塞// fd = open(filename.c_str(), O_RDONLY);}~Init(){shmdt(s);std::cout << "开始将shm从进程的地址空间中移除" << std::endl;// sleep(5);shmctl(shmid, IPC_RMID, nullptr);std::cout << "开始将shm从OS中删除" << std::endl;// sleep(10);// close(fd);// unlink(filename.c_str());}public:int shmid;int fd;char* s;
};int main(){Init init;//获取共享内存属性struct shmid_ds ds;shmctl(init.shmid,IPC_STAT, &ds);std::cout << ToHex(ds.shm_perm.__key) << std::endl;std::cout << ds.shm_segsz << std::endl;std::cout << ds.shm_atime << std::endl;std::cout << ds.shm_nattch << std::endl;
}
使用命令查看:
这也就验证了之前所提到的共享内存对象,共享内存 = 共享内存空间 +共享内存属性
3. 消息队列
消息队列:提供一个进程给另一个进程发送数据块的能力;
结构:
消息队列是OS中维护的一个数据结构当一个进程想要把数据发送给另一个进程,就可以把数据块连接在消息队列上,成为消息队列的一个节点;
msgget
msgget接口可以用于获取已经存在的消息队列的标识符,还可以用于创建新的消息队列;
key需要在用户层进行设置,并且这个key可以被A进程看到,也可以被B进程看到还要写到消息队列的属性中;和shmget中的类似;
msgflg也是一样的,示例:
int msgid = msgget(key, IPC_CREAT | IPC_EXCL);
这个队列中,不仅A进程可以加入数据块,B进程也可以;这时就产生了新的问题:它们如何知道哪个是自己想要的节点?
为了分辨出哪个是节点是进程需要的,所以队列中的数据块需要有类型;
msgsnd
发送消息
消息节点:
使用示例:
struct msgbuf { long mtype; // 消息类型 char mtext[256]; // 消息内容
};int msgid = msgget(key, IPC_CREAT | 0666);// 填充内容
struct msgbuf message;
message.mtype = 1; // 设置消息类别
snprintf(message.mtext, sizeof(message.mtext), "Hello, World!");
msgsnd(msgid, &message, strlen(message.mtext) + 1, 0)
msgctl
这里依然和shmctl的接口很类似,当前主要使用它的删除功能(删除消息队列)
使用示例:
msgctl(msgid, IPC_RMID, nullptr); // 删除消息队列
msgctl(msgid, IPC_STAT, &ds); // 获取消息队列状态
消息队列的属性和接口,根共享内存都是十分相似;在系统中,也是允许同时有多个消息队列存在的;所以在内核中也需要把消息队列管理起来;先描述,再组织;消息队列 =队列 +队列属性
消息队列描述对象
也有一个 struct ipc_perm类型的对象;
消息队列中的struct_ipc_perm 和共享内存中的struct ipc_perm两个其实是完全一样的用于记录该对象的权限、拥有者、访问权限等信息;
4. 信号量
如何理解信号量?信号量本质其实就是一个计数器;作用:用来保护共享资源
举个例子:
比如:看电影,我们去电影院看电影,去看之前需要先买票,是买到票后,指定的座位属于你,还是坐到座位上后座位才属于你?(排除没素质的情况)当然是买完票以后,那个座位就属于你了;
电影院和座位就是多人共享公共资源;买票的本质就是对资源的预定!电影院中有固定的座位,那么在购票系统中就会存在一个计数器,用于记录剩下的座位数;如果买票成功计数器就减减;而这里的座位和电影院就是OS公共资源,每个人就是进程;而购票系统的计数器就是信号量;规定好信号量初始值,就可以控制访问资源的进程数量;
信号量:表示资源数目的计数器,每个执行流(进程)想要访问公共资源内的某一份资源,不能让执行流直接访问,而是应该先申请信号量资源,本质就是对信号量计数器做--操作。只要--成功就完成了对资源的预定机制;
如果申请不成功?执行流就会被挂起
访问公共资源的代码执行前,先申请信号量,如果申请成功才让访问,申请失败就挂起阻塞;这样就形成了保护机制;
当信号量的数值设为1;int sem = 1;这时就只允许一个执行流读取公共资源;执行流在读取时,其他执行流只能阻塞等待 sem 就只有两种状态:1、0;二元信号量---互斥锁---完成互斥功能;
信号量存在的本质就是为了保护公共资源,信号量是公共资源,那谁来保护信号量?信号量都没法保护,那又怎么保护其他公共资源?
但好在信号量公共资源内容较为简单,申请时只需要--操作,--操作只要符合原子性就可以;也就是说要么申请完成,要么不申请;不存在其他情况;这样就避免了出现其他情况;
申请信号量的操作 —P
释放信号量的操作—V
细节分析:
- 信号量是公共资源的计数器,那么就需要先让所有进程都能看到信号量资源 —— 只能由操作系统提供——纳入到IPC体系;
- 信号量本质也是公共资源
对信号量管理的理解
// 单个信号量
{struct sem;int count;struct task struct *wait queue;
}
系统调用接口
semget
nsems表示要创建的几个信号量;其余两个参数和shmget一样的;
示例:
int semid = semget(key, n, IPC_CREAT | 0666); // 创建一个信号量 0666 是读写权限
// n表示创建信号量的个数
system V 允许创建一批(至少一个)信号量;如何支持创建一批?在创建时设置n(创建个数)
semctl
创建了信号量,如何设置信号量的计数?使用 semctl
函数来实现;
示例:
// 设置信号量的值为初始值
// 初始化计数为1
// 0 表示您要设置第一个信号量的值
semctl(semid, 0, SETVAL, 1);
设置一组信号量呢?
int initial_values[] = {1, 1, 1}; // 假设每个信号量的初始值为 1 for (int i = 0; i < num_semaphores; i++) { semctl(semid, i, SETVAL, initial_values[i]) {}
当然也可以删除信号量:
semctl(semid, 0, IPC_RMID)
信号量描述对象
信号量允许存在多个,那么就需要对其进行管理,那么自然也有信号量描述对象;发现他也有struct ipc_perm对象;
5. 系统层面IPC资源
站在OS角度,内核是如何看待ICP资源的?它是OS内单独设计的模块
共享内存、消息队列、信号量,它们在自己的描述对象开始都有一个ipc_perm 结构体通过对这个结构体的管理就可以达到对这三种IPC资源的管理 ;
回顾一下柔性数组:
kern_ipc_perm跟共享内存、消息队列以及信号量中的ipc_perm的内容一致,可以把它们看待成同一个结构体;
统一使用该数组实现对IPC资源的管理,遍历这个数组,就可以通过 key 来判断某个IPC资源是否存在;
数组中存放的是各种IPC资源的struct ipc_perm 结构体对象地址,每当需要增加一个IPC对象(共享内存、消息队列、信号量)时,就只需在数组中新增一个struct ipc_perm对象地址即可;
比如:想要访问共享内存对象中除struct ipc_perm 外的其他资源怎么办?直接使用*p肯定不行;只需要强转一下类型就可以;比如:p[1]存放的是消息队列的ipc_perm
((msg_queue*)p[1])->...
把struct ipc perm*类型的指针强转成消息队列对象类型的指针,这样就可以访问了;
这个设计模式很类似于多态;通过对相同类型的对象访问,就可以实现对不同IPC资源的管理 ;
想要更深入的了解可以拜读参考这篇文章:进程间通信:共享内存和信号量的统一封装机制原理与实现
6. 补充
进程间通信流程:
进程间通信 -->多个执行流看到同一份资源(公共资源) --> 并发访问 --> 数据干扰问题 --> 保护数据
--> 同步和互斥;
- 互斥:任何时刻只允许一个执行流(进程)访问公共资源,比如:管道,只允许一个读一个写;互斥可以通过加锁完成;
- 同步:多个执行流执行时按照一定顺序执行 ;
被保护起来的公共资源--临界资源;
访问临界资源的代码,被称为临界区;
总结
好了以上便是本文的全部内容,希望对你有所帮助或启发,感谢阅读!