进程间通信(IPC)介绍
1.IPC概述
进程通信(IPC)是指不同进程之间传递数据、交换信息或协同工作的一种机制。在OS中,进程是资源分配的基本单位,且彼此拥有独立的地址空间(内存隔离),无法直接访问对方的数据,因此需要专门的通信方式来实现进程间的协作。
2.IPC的目的
**1.数据传输:**一个进程需要将它的数据发送给另一个进程。
**2.资源共享:**多个进程之间共享同样的资源。
**3.通知事件:**一个进程需要向另一个进程发消息。
**4.进程控制:**有些进程希望能够控制另一个进程的执行。并拦截该进程所有的陷入和异常,并能及时知道它的状态改变。
3.怎样通信
IPC通信的最主要本质内容是让不同的进程,看到os中的同一份资源(然后才有通信的条件)。对于os中的一份资源可以理解为一份"共享内存",但这份共享内存并不是其中一个进程曾提供的,而是os通过双方调用系统调用去访问这段内存资源。(仔细想想如果这份共享内存是某个进程提供的,那进程本身就具备通信能力,不符合实际情况)。
4.IPC分类
管道类:匿名管道(pipe)、命名管道(FIFO)。
System V IPC:共享内存、消息队列、信号量(生命周期随内核)。
POSIX IPC:消息队列、共享内存、信号量、互斥量、条件变量等(兼容性更强)。
管道通信
什么是管道
**简单理解:**我们把从一个进程连接到另一个进程的一个数据流成为一个"管道"。

匿名管道
匿名管道只能用来实现进行父子间的通信。可以把他理解为基于文件缓冲区技术来实现匿名管道。

上图解释:我们可以根据上图简单理解基于父子的管道通信(只是简单的理解,匿名管道实际上并不是这个样)
对于父进程fork创建子进程,子进程只会拷贝父进程的进程管理部分,并不会拷贝文件管理部分,这也能说明创建出的子进程文件描述符表指向的内容跟父进程指向的是同一份,父进程能看到的文件缓冲区,子进程也能看到。因此我们可以理解父子进程能够通信,回归本质就是两进程可以看到同一份资源。
匿名管道原理

1.父进程调用pipe函数创建管道,得到两个文件描述符:fd[0](读端)、fd[1](写端);
2.父进程调用fork创建子进程,子进程会继承父进程的文件描述符表,因此也拥有管道的读端和写端;
3.为实现单向通信,父进程关闭读端(fd[0]),子进程关闭写端(fd[1]),形成"父写子读"的通信通道(反之亦然);
4.数据流向:父进程通过fd[1]写入数据到内核缓冲区,子进程通过fd[0]从缓冲区读取数据,内核负责缓冲区的同步与互斥。
真正的匿名管道是内存级别的,并不会像文件操作会把缓冲区刷新到磁盘上,而是由os在内存中生成的,所以这个类似缓冲区并没有名字---匿名管道。
匿名通信代码展示
cpp
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <cstring>
#include <wait.h>
using namespace std;
// 模拟实现匿名通道
int main()
{
// 1.建立通道
int fd[2] = {0}; // fd[0]--读 fd[1]--写
int n = pipe(fd);
if (n < 0)
{
perror("pipe error\n");
}
// 2.创建子进程 --父读子写
pid_t pid = fork();
if (pid == 0)
{
// 子进程
close(fd[0]);
char buffer[1024];
int cnt = 1;
while (cnt++)
{
snprintf(buffer, sizeof(buffer), "I am child,my pid=%d,my ppid=%d,cnt=%d\n", getpid(), getppid(), cnt);
write(fd[1], buffer, strlen(buffer));
sleep(1);
}
close(fd[1]);
exit(-1);
}
// 父进程 读
close(fd[1]);
char buffer1[1024] = {0};
while (1)
{
size_t m = read(fd[0], buffer1, sizeof(buffer1) - 1);
if (m > 0)
{
// 读取成功
buffer1[m] = 0;
cout << "child say:" << buffer1 << endl;
}
}
waitpid(pid, nullptr, 0);
close(fd[0]);
return 0;
}
//运行结果
child say:I am child,my pid=1730990,my ppid=1730989,cnt=2
child say:I am child,my pid=1730990,my ppid=1730989,cnt=3
child say:I am child,my pid=1730990,my ppid=1730989,cnt=4
child say:I am child,my pid=1730990,my ppid=1730989,cnt=5
child say:I am child,my pid=1730990,my ppid=1730989,cnt=6
以上代码可以简单模拟父子进程之间的通信。
主要步骤:
1.建立管道
2.创建子进程
3.关闭读/写端
4.进行通信
匿名通道通信的几种特性
1.匿名管道只能用来进行具有血缘关系的进程进行进程间的通信(父子进程)。
2.匿名管道自带同步机制:简单理解就是写端写满写端就阻塞,等待读者去读区, 读者读完读方就等待,等待写者写。这个同步机制也就保证了父子进程往显示器打印数据时不会乱。
3.匿名管道是面向字节流的。
4.匿名管道是单向的:任何时刻只允许一方发一方收。也称之为"半双工通信"。
5.管道文件的生命周期,是随进程的。如果两个通信进程都关闭了,管道文件没有被处理。这时管道文件就随系统生命周期的关闭而关闭,因为内存级管道文件是os创建的内存级文件,所以os有权限也有能力自动关闭。
匿名管道读写规则
1.写慢,读快-----读阻塞。
2.写快,读慢---满了 写阻塞。
3.写关 继续读, ---read就会读到返回值为0,表示文件结尾。退出
4.写继续,读关闭 -----写的没有意义(没人去读)。
管道的建立是os创建的,没有意义os也会主动去回收。
第四种是os的一个bug,os直接杀掉write段进程,发送异常信号(13 SIGPIPE),父进程接收信息wait(pid &status,0);这个信息就是子进程(读端)的退出信号。
命名管道
匿名管道通信的限制就是只能在两个有"血缘关系"的进程间进行通信,而命名管道可以在两个不相关的进程之间进行通信。
命名管道的形成原理
命名管道也是基于文件创建的,能进行通信的本质也是让不同的进程看到同一份内存资源。而这份内存资源我们可以理解为文件加载到内存后os创建的一共文件内核缓冲区(如下图)。

上图解释:
两个进程同时打开一个文件,os只会加载一次该文件,实际上两者能看到的同一份资源就是加载文件的inode和代码数据(为了方便理解我们把这份资源看作是在同一个缓冲区内)。
os会创建struct_file,两进程通过读写可以完成通信。
另外写的数据不会被刷新到磁盘上。打开的文件,有路由+名字(命名管道的由来),这也可以确保唯一性。
命名管道的创建方式
cpp
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *filename, mode_t mode);
// 参数:filename - 管道文件名;mode - 文件权限(如0644)
// 返回值:成功返回0,失败返回-1
基于命名管道实现的(C/S)通信(源码)
cpp
//common.hpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
using namespace std;
#define PATH "."
#define FILENAME "fifo"
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0) // 命名管道
class NamedFifo
{
public:
NamedFifo(const string &path, const string &name)
: _path(path), _name(name)
{
_filepath = path +"/" + name;
// 创建管道 mkfifo
int m = mkfifo(_filepath.c_str(), 0666);
if (m != 0)
{
cerr << "mkfifo error" << endl;
ERR_EXIT("mkfifo");
}
cout << "mkfifo sucess!!!" << endl;
};
~NamedFifo()
{
unlink(_filepath.c_str());
cout << "unlink sucess!!!" << endl;
};
private:
string _path;
string _name;
string _filepath;
};
class FileOper
{
public:
FileOper(const string &path, const string &name)
: _path(path), _name(name)
{
_filepath = path +"/" + name;
};
void OpenForRead()
{
// 打开管道文件 进行读取数据
_fd = open(_filepath.c_str(), O_RDONLY);
if (_fd < 0)
{
cerr << "open error!!!" << endl;
ERR_EXIT("open");
}
cout << "open sucess!!!" << endl;
// 读取client数据
}
void OpenForWrite()
{
// 只需要打开管道文件进行操作不需要再次打开文件
_fd = open(_filepath.c_str(), O_WRONLY);
if (_fd < 0)
{
cerr << "open error" << endl;
ERR_EXIT("open");
}
cout << "open sucess!!!" << endl;
}
void Read()
{
while (true)
{
char buffer[1024];
int n = read(_fd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
cout << "client say:" << buffer << endl;
}
else if (n == 0)
{
cout << "client close,me too" << endl;
break;
}
else
{
cout << "read errer" << endl;
ERR_EXIT("read");
}
}
}
void Write()
{
// 进行操作
int cnt = 1;
while (true)
{
string message;
cout << "please input:";
getline(cin, message);
message += ",message num:" + to_string(cnt++) + ",pid:" + to_string(getpid());
write(_fd, message.c_str(), message.size());
}
}
void Close()
{
if (_fd > 0)
{
close(_fd);
}
}
~FileOper() {
};
private:
string _path;
string _name;
string _filepath;
int _fd;
};
cpp
//client.cc
#include "common.hpp"
using namespace std;
int main()
{
FileOper WriteFile(PATH, FILENAME);
WriteFile.OpenForWrite();
WriteFile.Write();
WriteFile.Close();
return 0;
}
cpp
//service.cc
#include "common.hpp"
using namespace std;
int main()
{
// 创建管道
NamedFifo Fifo(PATH, FILENAME);
// 文件读写
FileOper ReadFile(PATH, FILENAME);
ReadFile.OpenForRead();
ReadFile.Read();
ReadFile.Close();
return 0;
}
**注意事项:**通信的时候,如果write方没有执行open的时候,read方就要走自己的open函数中 进行阻塞(open函数中有阻塞函数)。直到write方打开了open函数。
匿名管道和命名管道的区别
FIFO(命名管道)与pipe(匿名管道)之间唯⼀的区别在它们创建与打开的方式不同,⼀但这些工作完成之后,它们具有相同的语义。
匿名管道由pipe函数创建并打开,命名管道由mkfifo函数创建,打开用open。
system V 共享内存
共享内存加载原理

简单理解共享内存(图解):
每个进程有自己独立的虚拟地址空间,在堆和栈中间开辟的一段共享内存,来做物理内存和虚拟地址的一种转换,所以说不同进程可以通过映射去访问同一块物理空间,这块物理空间叫共享内存。
注意:
1.这里映射到虚拟地址空间要与动态库加载做一下区分:动态库加载是从磁盘中加载的文件,而共享内存,这段物理内存是由os创建出来的。
2.在整个os中需要共享的内存不只一个,对于这些内存的管理方式:os采用先描述,后组织的方式。所谓的先描述就是对应的struct_pcb结构体,在组织就是通过一些容器进行管理。
3.共享内存=描述pcb+对应的内存空间。
4.对于如何确定一块物理内存被多少进程所共享,os在申青对应的pcb结构体中通过设置了引用计数来确定共享的进程有多少。
共享内存创建系统接口
ftok函数
ftok(char *pathname,int proj id); //构建出一种key值。两个进程约定,都必须调用。
shmget函数
shmget(key_t key,size_t size,int shmflg); //创建共享内存
key: 不同进程创建共享内存,在不同进程中约定一个相同的key来唯一表示共享内存得到唯一性。ftok获取
size: 创建共享内存的大小
shmflag: ICP_CREAT:创建共享内存,"共享内存"不存在就创建,存在就打开使用(获取)
**ICP_EXECL:**与ICP_CREAT合用(IPC_EXEC | IPC_CREAT),不存在就创建,若存在就出错返回(原因:如果shmget成功返回一定是一个全新的共享内存。)主要用作创建共享内存。返回值:shmid
查看创建的共享内存命令:ipcs -m
删除已经创建的共享内存:ipcrm -m shmid
删除共享内存 ipcrm -m shmid 必须删除的是shmId不能是key值。对于共享内存的key值,只给内核 来进行唯一性区分。而在用户层只能根据shmid来进行区分。对于我们所使用的指令(ipcrm)当然也属于用户层面。所以删除时需要根据shmid进行删除。
shmat函数
//共享内存地址映射 物理地址----->虚拟地址
//创建物理共享内存后,需要把这块内存挂载到进程的虚拟地址上
void *shmat(int shmid,const void * shmaddr,int shmflg);
shmid: 申请的共享内存标志
shmaddr: 虚拟地址,固定地址进行挂接。用户不能盲目挂接。平常nullptr就行。
**shmflg:**0权限问题,默认设置即可共享虚拟地址的起始大小+偏移量---->访问虚拟地址的任何数据。
反回值为虚拟地址的起始地址。
shmdt函数
//将共享内存与当前进程进行脱离
shmdt(const void* shamaddr);
***shamaddr:*shmat返回的指针(并不等于删除共享内存)
shmctl函数
//共享内存控制管理 可用做删除共享内存
shmctl(int shmid,int cmd.shmid_ds *bus);
shmid: 共享内存id号
cmd: 执行的命令 IPC_RMID //删除
**bus:**nullptr建立好共享内存后,如果进程结束了,如果没有进行删除共享内容,共享内存资源会一直存在。 --共享内存的资源,生命周期随内核,所以我们要用shmctl函数对创建的共享内存进行删除。
利用共享内存实现(C/S)通信
注:主要是为了练习使用相关接口
cpp
//shm.hpp
#pragma once
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <string>
#include <unistd.h>
#define CREATER "creater"
#define USER "user"
using namespace std;
const string pathname = ".";
const int gsize = 4096;
int projid = 0x66;
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
class Shm
{
private:
void CreateHelper(int sig)
{
// 共享内存创建
_shmid = shmget(_key, gsize, sig);
if (_shmid == -1)
{
ERR_EXIT("shmget");
}
cout << "shmget sucess!!!,shmid:" << _shmid << endl;
}
void ShmCreat()
{
CreateHelper(IPC_CREAT | IPC_EXCL | 0666);
}
void ShmGet()
{
CreateHelper(IPC_CREAT);
}
public:
Shm(const string &pathname, const int &projid, const string &_user)
: _pathname(pathname), _projid(projid), _start_mem(nullptr)
{
_key = ftok(_pathname.c_str(), _projid);
if (_key < 0)
{
ERR_EXIT("ftok");
}
cout << "ftok sucess!!!,key:" << _key << endl;
if (_user == CREATER)
{
ShmCreat();
}
else if (_user == USER)
{
ShmGet();
}
}
void *ShmAttch()
{
// 物理地址映射到虚拟地址
_start_mem = shmat(_shmid, nullptr, 0);
if ((long long)_start_mem < 0)
{
ERR_EXIT("shmat");
}
cout << "attach sucess!!!,_start_mem:" << _start_mem << endl;
return _start_mem;
}
void Detach()
{
// 取消映射
if (_user == CREATER)
{
int n = shmdt(_start_mem);
if (n == 0)
{
cout << "detach sucess!!!" << endl;
}
}
}
~Shm() {}
private:
key_t _key;
int _shmid;
const string _pathname;
int _projid;
void *_start_mem;
string _user;
};
cpp
//service.cc
#include"shm.hpp"
using namespace std;
int main(){
Shm shm(pathname,projid,CREATER);
//共享内存创建
char *mem=(char*)shm.ShmAttch();
int cnt=10;
while(cnt--){
printf("%s\n",mem);
sleep(1);
}
shm.Detach();
return 0;
}
cpp
//.client.cc
#include"shm.hpp"
int main(){
Shm shm(pathname,projid,USER);
char* mem=(char*)shm.ShmAttch();
int index=0;
for(char c='a';c<='z';index++){
sleep(1);
mem[index]=c;
c++;
}
return 0;
}
关于共享内存存在的一些问题
1.在共享内存操作过程中并没有 使用系统调用。
原因:映射到进程的虚拟地址的共享区,共享区属于用户空间,是可以被用户直接访问的。这也可以说明我们进行"共享内存"级别的通信时,并不需要通过系统调用的方式,直接就可以通信。间接的证明了这种通信方法速度比较快。
2.共享内存存在数据不同步,没有保护机制的缺点。
原因:对于共享内存的读写问题,写是直接往内存空间进行写,读是直接从内存空间进行读的。所以很容易造成数据读写不一致问题。这种可以对代码进行一定的保护(比如加锁,(后边信号量会讲))。
system V 消息队列(选学)
注:简单介绍,没有代码实现
原理介绍:

图解:
我们知道所有的通信本质就是让不同的进程看到同一份资源。
基于消息队列的通信本质也就是os在内核中维护的一个基于队列实现的共享资源。
细节理解:
1.队列的创建会创建秒速队列的节点+队列结构。
2.消息队列中的数据快会带有标识,这个标识用来区分是A发送的数据还是B发送的数据。
3.创建的消息队列生命周期和共享内存的生命周期一样,随内核,需要使用相关接口进行删除。
相关接口
//创建
int msgget(key_t key ,int msgflg);key:ftok获取
msgflg:位标识符 IPC_CREAT | IPC_EXCL
int 返回值(用户识别到的唯一消息队列id号)
//删除 控制消息队列
int msgctl(int msqid,int cmd,struct msqid_ds *buf);msqid:这是获取的消息队列id
cmd/buf:IPC_RMID(获取属性)要执行的命令
//进程向指定队列收发消息
//发
int msgsnd(int msqid,void *msgp,size_t msgsz,int msgflg);msqid:指定的消息队列
msgp:发送消息的数据块
msgsz:发送数据大小
msgflg:默认为0,阻塞发送
//收
size_t msgrcv(int msqid,void *msgp,size_t msgsz,long msgtyp,int msgflg);msgtyp:读取的数据类型,读取的数据来=来自哪里。a/b发送标识
ipcs -q //查看ipc消息队列
system V 信号量(选学)
注:简单介绍,没有代码实现
并发编程相关概念
1.多个执行流(进程),能看到的同一份公共资源叫共享资源。
2.系统中某些资源⼀次只允许⼀个进程使用,称这样的资源为临界资源 或互斥资源。
3.访问这个临界资源的程序叫做临界区。//访问临界资源的那部分代码
4.其他不访问临界资源的程序段叫做非临界区。
共享资源vs临界资源唯一区别就是临界资源被保护。
**保护方式:**同步和互斥(也称加锁)
同步: 多个执行流,访问临界资源的时候,具有一定的顺序性。
**互斥:**任何时刻只允许一个执行流访问临界资源。
所谓的对共享资源进行保护,本质是对访问共享资源的代码(临界区)进行保护。
**原子性:**一件事情只有两态,0/1。
加锁是为了保护临界区的安全,但锁也是共享的,申请锁的时候。必须是原子的,谁申请谁访问,不申请就不能访问(不能拿到锁)。
信号量
定义:信号量的本质是一个计数器表明临界资源中资源数量的多少。
深刻理解:

图解:
我们可以想象申请了一块整体资源,但申请的资源我们并不需要对整块资源进行利用,而是使用其中一部分资源即可。对于这些部分资源的划分我们称之为信号量。资源快划分数量多少信号量就有多少。信号量==共享资源
至此我们理解:某个进程如果想要申请一块资源,信号量就++,释放一块资源,信号量就--
这也变相说明进程对信号量的申请,其实就是对资源的预定机制。
PV操作预定机制
申请资源 seg-- (原子性)p操作
释放资源 seg++ (原子性)v操作
锁的设计也是如此(暂不介绍)
信号量相关接口
int semget(key_t key,int nsems,int semflg);
key:ftok
msems:创建多少给信号量(资源的划分)。
semflg:IPC_CREAT | IPC_EXCL
查看创建得信号量 ipcs -s
semop(semid );//对哪个信号量进行操作。
int semctl(int semid,int semnum,int cmd,...);执行删除中间哪个参数没用
semnum是对信号量得一个初始化(信号量需要在semctl中初始化)二次调用semctl()
对semnum第几个信号量,...就是对这个信号量进行操作。
