在我们之前所学进程的知识中,每一个进程是独立的。但是进程之间也可以通信的。
进程间通信的本质是什么呢?进程之间是相互独立的。怎么实现的通信呢?一个进程是不能直接访问另一个进程的空间的。所以想要完成通信,需要由操作系统单独划分一片缓冲区,可以让两个进程都可以看到并且访问到的空间。一个进程想要把数据传送给另一个进程时,可以先把数据放到缓冲区,然后另一个进程去读取。这样就完成了进程间的通信,也保证了进程的独立性不被破坏。所以进程间通信的本质就是让不同的进程先看到同一份资源。
进程通信的目的
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态。
匿名管道
匿名管道的原理
管道是进程间的一种最古老的通信方式,既然是进程间的通信方式,那就先要有两个进程,两个进程之间是怎么进行通信的呢?
当一个进程需要使用管道和另一个进程通信时,OS会给进程一个内存级文件(只在内存中存在,不会存储在磁盘中)。然后以读方式和写方式分别打开同一个文件。然后进程fork创建子进程之后会把PCB拷贝一份给子进程,子进程只需要修改若干个数据即可,PCB中有一个struct file_struct中有数组存放文件描述符,子进程也只会拷贝一分父进程的PCB给自己,但是并不会把指向的文件拷贝一份,所以两个进程就会指向同一个匿名管道文件,struct file_struct中存放的文件地址是一样的,所以文件也是同样的。(在打开管道的时候必须以读和写的方式分别打开一个管道,否则父进程只有读方式子进程也是只有读方式,父进程只有写方式fork之后子进程也只有写方式。)
这样父进程和子进程就都可以对一个文件进行IO操作,如果进程是只读数据的,那么把写方式的fd文件操作符关闭。如果进程只写数据,把读方式打开管道的文件流关闭。这样就可以进行进程间的通信。
因为这种通信方式只能单向读取或者写入,所以被命名为管道。如果向再写入或者读取,只能再开一条管道。
管道的操作
- 指令操作
统计文本文件有多少行
cat mylog.txt | wc -l
可以联合多条指令做数据的加工
这个操作就是将cat进程的标准输出重定向到管道中,将wc标准输入重定向到管道中。
- 函数接口
在代码中创建一个管道有一个专门的接口函数
#include <unistd>
int pipe(int pipefd[2]);
//传一个大小为2的整形数组,第一个值是只读的fd,第二个是只写的fd。
//管道创建成功返回0,失败返回-1。
父子进程管道通信代码实例:
//子进程给父进程发送消息
#include <iostream>
#include <unistd.h>
#include <string.h>
#include <cassert>
int main(){
int pipefd[2]={0};
//1.创建管道
int n = pipe(pipefd);
if(n<0){
std::cout<<"pipe error"<<errno<<strerror(errno)<<std::endl;
}
//2.创建子进程
pid_t id = fork();
assert(id!=-1);
if(id==0){//子进程
//3. 关闭不需要的fd,让父进程只读,子进程只写
close(pipefd[0]);
//4.开始通信
const std::string namestr = "hello , i am child";
int cnt = 1;
char buffer[1024];
while(true){
snprintf(buffer,sizeof(buffer),"%s %d mypid:%d\n",namestr.c_str(),cnt++,getpid());
write(pipefd[1],buffer,strlen(buffer));
sleep(1);
}
}
//父进程
//3. 关闭不需要的fd,让父进程只读,子进程只写
close(pipefd[1]);
//4. 开始通信
char buffer[1024];
while(true){
int n = read(pipefd[0],buffer,sizeof(buffer)-1);
if(n>0){
buffer[n]='\0';
std::cout<<"I am father process: i receive signal"<<buffer<<std::endl;
}
}
return 0;
}
匿名管道的特点
- 管道是单向通信的。
- 管道的本质是文件,因为fd的生命周期跟随进程,管道的声明周期是随进程的。
- 管道通信,通常用来进行具有”血缘“关系的进程,进行进程间通信,常用与父子通信
- pipe打开管道,并不知道管道的名字,所以是匿名管道
- 在管道通信中,写入的次数和读取的次数,不是严格匹配的,读写次数的多少没有多少强相关
- 管道文件大小216个字节,如果写满了就会进入阻塞状态,等待读取数据。如果读空了就进入等待状态,等待数据的写入。这是一种同步机制。
4种特殊情况
- 如果我们read读取完了所有的管道资源,如果对方不发,我就只能等待
- 如果我们write端将管道写满了,我们还能写吗?不能
- 如果我关闭了写端,读取完毕管道数据,再读取read就会返回0,表明读到了文件结尾
- 写端一只写,读端关闭,会发生什么呢?没有意义。OS不会维护无意义,低效率,或者浪费资源的事情。OS会杀死一直写入的进程!OS会通过信号(SIGPIPE)来终止进程。
命名管道
上面的匿名管道只能用于两个具有“血缘关系”的进程之间进行通信,如果想要让两个毫不相关的进程进行通信,需要用到命名管道(FIFO)。命名管道本质和匿名管道是一样的,都是让两个进程之间都能看到同一份资源。
命名管道是一个需要被创建在目录下的内存级文件
指令创建命名管道
mkfifo 文件名
函数接口创建命名管道
NAME
mkfifo - make a FIFO special file (a named pipe)
SYNOPSIS
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);//第一个参数传创建管道文件的路径,第二个参数传入管道文件权限
//创建成功返回0,失败返回-1
那么命名管道是如何让两个毫不相关的进程进行通信,原理是怎么实现的?
命名管道需要用mkfifo手动创建一个目录下的管道文件,而每一个文件在内存中都有对应的struct file结构体,当一个进程把这个文件结构体的地址存放在进程的文件操作符数组内时,文件结构体的引用计数就会加一,也就是说假如有两个进程同时要打开这个文件,并不会在内存中为这个文件创建两个struct file,而是两个进程指向同一个struct file,所以这两个进程指向的就是同一个文件。这样就可以让这两个进程实现命名管道的通信,这个文件只是为了进程间的通信,也就没必要往磁盘存储数据,所以这个管道文件在文件中只需要有inode编号,没有数据也就不需要维护data block,所以这个文件是一个内存级文件。
一个文件都会有绝对路径和相对路径,所以想要让两个进程都找到这个管道文件,可以根据路径来寻找。
可以模拟创建服务端和客户端,让两个进程进行互相通信
//server端和client端的共同头文件
#pragma once
#include <iostream>
#include <string>
#define NUM 1024
const std::string fifoname = "./fifo";
uint32_t mode = 0666;
//server端,负责接收client发送的数据
#include <iostream>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "comm.hpp"
using namespace std;
int main(){
umask(0);//不影响OS,只会影响当前进程
//创建管道文件
int n = mkfifo(fifoname.c_str(),mode);
if(n!=0){
cout<<errno<<":"<<strerror(errno)<<endl;
return 1;
}
cout<<"create fifo file success"<<endl;
//2.让服务端开启管道文件
int rfd = open(fifoname.c_str(),O_RDONLY);
if(rfd<0){
cout<<errno<<":"<<strerror(errno)<<endl;
return 2;
}
cout<<"opend fifo success,begin ipc"<<endl;
//3.正常通信
char buffer[NUM];
while(true){
buffer[0]=0;
ssize_t n = read(rfd,buffer,sizeof(buffer)-1);//读取管道文件的数据
if(n>0){
//buffer[n]=0;
printf("%s",buffer);
fflush(stdout);//刷新缓冲区
}else if(n==0){
cout<<"client quit me too"<<endl;
break;
}else{
cout<<errno<<":"<<strerror(errno)<<endl;
break;
}
}
close(rfd);
unlink(fifoname.c_str());//删除管道文件
return 0;
}
//client端,负责发送数据到server端
#include <iostream>
#include <cassert>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "comm.hpp"
using namespace std;
int main(){
//1.不需要创建管道文件,只需要打开对应的文件即可
int wfd = open(fifoname.c_str(),O_WRONLY);
if(wfd<0){
cout<<errno<<":"<<strerror(errno)<<endl;
return 1;
}
//可以进行通信了
char arr[]="i am process A\n";
write(wfd,arr,sizeof(arr));//向管道文件写入数据
close(wfd);
return 0;
}
system V共享内存
这种通信方式是操作系统单独设计出来的一种通信方式,进程之间是相互独立的,所以不管什么通信方式,都脱离不了让两个进程看到同一份资源这个理念。
共享内存原理
使用共享内存进行通信,就是先在内存中开辟一段空间,然后把这段空间通过页表映射到要通信的两个进程的进程地址空间内的共享区(也就是放动态库的地方),这样两个进程就可以通过自己的地址空间对同一块物理空间进行访问。
共享内存使用步骤:
- 创建内存块
- 通过页表关联进程
- 取消关联
- 释放共享内存
共享内存的使用
//创建共享内存
int shmget(key_t key, size_t size, int shmflg);
//key是由ftok生成的唯一值。
//size表示申请内存的大小
//shmflg参数
// IPC_CREAT and IPC_EXCL
// 单独使用IPC_CREAT: 创建一个共享内存,如果共享内存不存在,就创建之,如果已经存在,获取已经存在的共享内存并返回
// IPC_EXCL不能单独使用,一般都要配合IPC_CREAT
// IPC_CREAT | IPC_EXCL: 创建一个共享内存,如果共享内存不存在,就创建之, 如果已经存在,则立马出错返回 -- 如果创建成功,对应的shm,一定是最新的!创建共享内存的还可以将共享内存的访问权限异或进去
既然内存中不止一组内存用共享内存进行通信,那么两个进程怎么来确定同一个共享内存块呢?就是使用下面这个内存接口,两个进程传同样的pathname和proj_id参数OS就会通过特定算法生成同一个key,大概率是不会重复的,把这个key值传进shmget函数的第一个参数就可以了。
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
//路径字符串, //项目id
共享内存的管理
在内存中,可能会有多个进程使用共享内存的方式进行通信,这样的话内存中那个就会有多个共享内存块,多个内存块是需要被OS管理的。管理的本质就是先描述,再组织。所以每个内存块都会有一个struct结构体。OS会对结构体进行管理就是对共享内存块进行管理。
ipcs //查看ipc通信系统支持的三种通信策略,消息队列、共享内存、信号量
ipcs -m//查看共享内存
两个进程通信结束时,进程会退出,但是共享内存不会自动退出,因为共享内存生命周期不随进程随OS,如果不手动删掉共享内存,第二次创建共享内存就会失败。删除共享内存用的是shmget返回的shmid,而不是ftok返回的key值
进程连接共享内存
shmat()函数 – at:attach
第一次创建完共享内存时,它还不能被任何进程访问,shmat()函数的作用就是用来启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间。它的原型如下:
void *shmat(int shm_id, const void *shm_addr, int shmflg);
第一个参数,shm_id是由shmget()函数返回的共享内存标识。
第二个参数,shm_addr指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址。
第三个参数,shm_flg是一组标志位,通常为0。
调用成功时返回一个指向共享内存第一个字节的指针,如果调用失败返回-1.
进程和共享内存分离
shmdt()函数 – dt:detach
该函数用于将共享内存从当前进程中分离。注意,将共享内存分离并不是删除它,只是使该共享内存对当前进程不再可用。它的原型如下:
int shmdt(const void *shmaddr);
参数shmaddr是shmat()函数返回的地址指针,调用成功时返回0,失败时返回-1.
删除共享内存方式:
ipcrm -m shmid //删除指令
//系统接口删除
#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
1.第一个参数就是共享内存的shmid
2.第二个参数是选项,例如:
IPC_STAT 获取共享内存的属性信息,必须具有读权限,才能读取成功
IPC_RMID 删除共享内存
共享内存优缺点和特性
特点:共享内存的大小是以PAGE页(4kb)为单位的。例如:我们申请了4097个字节(4096byte==4kb),但是系统也会申请4096*2byte,只是回显给我们的依旧是4097.
优点:共享内存在通信时,并没有使用任何接口,只需要将数据写入地址空间,映射到共享内存中,就可以被所有进程看到了。因为这种特性,可以让进程通信的时候减少拷贝次数,所以共享内存是所有进程通信中最快的通信方式。
缺点:但是共享内存却没有任何保护机制(同步互斥)。所以我们可以通过别的方法让共享内存实现互斥,例如:我们可以利用命名管道让共享内存实现互斥。
让server端和client端用共享内存进行通信,利用命名管道让共享内存实现互斥。
//client端和server端的共同头文件
#pragma once
#include <iostream>
#include <iostream>
#include <sys/types.h>
#include <string.h>
#include <stdio.h>
#include <string>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/ipc.h>
#include <fcntl.h>
#include <sys/shm.h>
using namespace std;
#define PATHNAME "."
#define PROJID 0X6666
#define NUM 1024
//创建fifoname,如果client写完了,向fifoname写入信号数据,通知server读取
const std::string fifoname = "./fifo";
//创建fifoname1,如果server读完了,向fifoname1写入信号数据,通知client继续写入
const std::string fifoname1 = "./fifo1";
uint32_t mode = 0666;
const int gsize = 4096;
//生成唯一key值
key_t getKey(){
key_t k = ftok(PATHNAME,PROJID);
if(k==-1){
cerr<<"error:"<<errno<<":"<<strerror(errno)<<endl;
exit(1);
}
return k;
}
//获取或者创建共享内存
static int createShmHelper(key_t k,int size,int flag)
{
int shmid = shmget(k,gsize,flag);
if(shmid==-1){
cerr<<"error:"<<errno<<":"<<strerror(errno)<<endl;
exit(2);
}
return shmid;
}
//创建共享内存
int createShm(key_t k,int size){
umask(0);
return createShmHelper(k,size,IPC_CREAT|IPC_EXCL|0666);
}
//获取共享内存
int getShm(key_t k,int size){
return createShmHelper(k,size,IPC_CREAT);
}
//打开命名管道的文件
int my_open(const char* file,int flag){
int fd = open(file,flag);
if(fd<0){
cout<<errno<<":"<<strerror(errno)<<endl;
return 2;
}
return fd;
}
//创建命名管道
void my_mkfifo(const char* file,int flag){
umask(0);
int n = mkfifo(file,flag);
if(n!=0){
cout<<errno<<":"<<strerror(errno)<<endl;
}
}
//server接收client端发送的数据
#include "comm.hpp"
int main(){
//生成唯一key值
key_t k = getKey();
//创建共享内存
int shmid = createShm(k,gsize);
//连接共享内存
char* addr = (char*)shmat(shmid,nullptr,0);
//创建命名管道
my_mkfifo(fifoname.c_str(),mode);
//以读方式打开命名管道
int rfd = my_open(fifoname.c_str(),O_RDONLY);
//创建命名管道
my_mkfifo(fifoname1.c_str(),mode);
//以写方式打开命名管道
int wfd = my_open(fifoname1.c_str(),O_WRONLY);
char buffer[1024];
while(true){
buffer[0]=0;
//读取client发送的信号,如果client端没有写入,就一直阻塞等待
ssize_t n = read(rfd,buffer,sizeof(buffer)-1);
if(n>0){
printf("%s",buffer);
fflush(stdout);
//打印共享内存数据
cout<< addr <<endl;
//通知client端,读取完毕
char arr[]="server读完了,你可以继续写了\n";
write(wfd,arr,sizeof(arr));
}else if(n==0){
cout<<"client quit me too"<<endl;
break;
}else{
cout<<errno<<":"<<strerror(errno)<<endl;
break;
}
}
//关闭共享内存和命名管道
close(rfd);
unlink(fifoname.c_str());
close(wfd);
unlink(fifoname1.c_str());
shmdt(addr);
shmctl(shmid,IPC_RMID,nullptr);
return 0;
}
#include "comm.hpp"
int main(){
//获取唯一key
key_t k = getKey();
//获取共享内存
int shmid = getShm(k,gsize);
//连接共享内存
char* addr = (char*)shmat(shmid,nullptr,0);
//打开
int wfd = my_open(fifoname.c_str(),O_WRONLY);
int rfd = my_open(fifoname1.c_str(),O_RDONLY);
while(true){
int i=0;
while(true){
char ch;
ch = getchar();
if(ch=='\n')break;
addr[i]=ch;
i++;
}
addr[i]='\0';
char arr[]="client写完了,你可以开始读取共享内存了\n";
write(wfd,arr,sizeof(arr));
char buff[1024]={0};
ssize_t n = read(rfd,buff,sizeof(buff)-1);
if(n>0){
//buffer[n]=0;
printf("%s",buff);
fflush(stdout);
}else{
cout<<errno<<":"<<strerror(errno)<<endl;
break;
}
}
close(wfd);
close(rfd);
shmdt(addr);
return 0;
}
这就是进程间常用的通信方式的详细剖析,希望对您有所帮助。