文章目录
- 进程间通信
- 进程间通信的目的
- 进程间通信的分类
- 进程间通信的本质
- 管道
- 初识管道
- 匿名管道
- 创建匿名管道
- 理解协同机制和原子性写入
- 命名管道
- 命名管道创建的俩种方式
- 使用命名管道实现俩个不同进程之间通信
- **管道实现进程间通信的本质**
- 匿名管道vs命名管道
- system V
- system V共享内存
- 查看system V资源的命令行指令
- 创建或获取共享内存
- 释放共享内存
- 法一:使用系统调用接口shmctl
- **法二:使用指令 ipcrm **
- 内存空间特点即对比管道
- 相关概念补充
进程间通信
进程间通信的目的
一个进程就是一个执行流,就像工厂的员工一样,一个人就是一个生产力,肯定是需要交互的,而进程间的交互可能就是进程通信,可能是因为数据传输需要,也可能是一个进程控制另一个进程…
主要目的有以下4种:
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
进程间通信的分类
管道
匿名管道pipe
命名管道
System V IPC
System V 消息队列
System V 共享内存
System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
进程间通信的本质
我们知道进程是具有独立性的,例如:父子进程之间共用一个全局变量,实际上进行修改时,父子进程之间会发生写时拷贝。
而进程间如果要实现通信,就得先让不同的进程看到同一份资源,俩进程看到资源肯定不是单独一个进程所拥有的,而是被所有进程所共享的,所以这个资源一定不可能由进程自身进行提供,那应该由谁提供呢?当然是os!而且所谓的资源实际上就是一段内存
因为提供的方式不一样;就形成了不同的通信方式:文件形式(管道),链表形式(消息队列),原生内存(共享内存)
管道
初识管道
什么是管道?
- 管道是Unix中最古老的进程间通信的形式。
- 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
例:
例如我们在命令行执行 who | wc -l 实际上就是在使用管道
匿名管道
特点:
1.单向传输的管道
2.管道是面向字节流的(调用系统接口 open read write)
3.父子进程间血缘依赖关系,即匿名管道其实是含有继承关系的进程之间通信的俩个文件,读端文件 写端文件)
4.管道的生命周期是随进程的
5.管道自带协同机制(同步和互斥),原子性写入
创建匿名管道
int pipe(int pipdfd[2])
作用:创建并打开匿名管道
参数:fd文件描述符数组 里面装到分别是父子进程读写俩端的文件
返回值:成功即返回0 失败返回-1
如图中所描述:
pipefd[0]:读端的文件的fd
pipefd[1]:写端的文件的fd
灵活记忆:0就像嘴一样,负责吃东西,所以是读取,1就像🖊一样,用来写字,所以是写端
而实际上双端进行通信时,因为管道是面向字节流的,俩方只需要使用read write对管道进行读取即可
调用pipe的实质是,进程打开管道文件,将读写端的struct file*填入进程task_struct的fd_array[]中去
例:父子通信:父进程做读端,子进程做写端
实现方法:
父进程打开匿名管道文件后,再fork子进程,而后父进程关闭写端(pipefd[1]),子进程关闭读端(pipefd[0])
注: 一定是在父进程打开匿名管道文件后,再fork子进程,因为需要俩个进程看到的是同一份资源 ,而如果我们先fork之后再创建并打开匿名管道文件,就不能保证父子进程看到的是同一份资源。
逻辑图理解:
os视角下的匿名管道通信:
进行匿名管道操作的代码:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#include <stdlib.h>
int main()
{int pipe_id[2];int ret=pipe(pipe_id); //打开管道文件if(ret){perror("pipe");return 1;}//pipe_id[0]为管道读入端 pipe_id[1]为管道写入端printf("pipe_id[0]:%d pipe_id[1]:%d \n",pipe_id[0],pipe_id[1]);int id =fork();if(id<0){perror("fork");return 2;}else if(id ==0){//child 进行写操作//关闭管道读文件close(pipe_id[0]);int count=0;while(1){ //sleep(100); //当写端写得满时 读端需要等待 或者是不写char * meg="hello lsh";write(pipe_id[1],meg,strlen(meg));count++;printf("%d\n",count);break;}close(pipe_id[1]); //而当写端退出时,读端read会返回0 表示已经读到文件末尾了 即写文件已经退出了exit(0);}else{//fatherclose(pipe_id[1]);while(1){sleep(1);char buffer[1024*2+1]={0}; //当写端太快时,会停下来等一下读端。或者是读端不读时,写端也会停下来等读端ssize_t s=read(pipe_id[0],buffer,sizeof(buffer));buffer[s]=0; //将末尾设置为\0if(s==0){printf("child quit\n");break;}else if(s>0){printf("parent get child new:%s\n",buffer);}else{printf("fail \n");}}close(pipe_id[0]);int status=0;int pid=waitpid(-1,&status,0);if(pid>0){printf("exit code:%d exit sig:%d \n",(status>>8)&0xFF,status&0x7F); //但读端退出时,写端还在写入,os 会像写端发送13信号,将其停下来}}return 0;
}
理解协同机制和原子性写入
1.读端读得慢,写端会等待读端读取
验证方法:我们让读端先睡眠20秒,再进行数据读取,写端则每次写入一个字节的内容,而我们会发现,写端写到65536字节时,就会停下来等待读端,而且每次读端至少读走4096字节时,写端才会继续朝管道里继续写入
如图:
可知在linux2.6.11之后的版本下管道大小就是为65536字节
2.读端不读或是直接退出,写端被进程使用13号信号终止
实验方法:写端写入,读端不读或者是直接关闭文件
结果:子进程被系统的信号干掉了
站在os系统的角度: 读端关闭了,而写端还在不断写入,就是在不断的往管道里输入数据,相当于在浪费os的资源;os系统当然会把他干掉
3.写端很慢,读端会等待写端写入
结果就是写端一写数据,读端就会将数据读出来,继续等待写端写入
4.写端关闭,读端read函数读到文件末尾
结果:read函数返回0,代表读到文件末尾,此处即代表子进程写端已经关闭
小总结:
- 读端和写端是协同工作的,读写端会互相等待
原子性写入
原子性的通俗理解就是:一件事要么不做,要么就一次做完。例如我们打一桶水一次打完就是原子性的,如果分为俩个半桶就是非原子性的
解释:当我们写入的数据少于4096字节时,我们输入是保持原子性写入的,即我们输入的数据会像连着的字符串一样,而当我们写入的数据超过4096字节时,就会采取非原子性写入原则,即我们写入的数据并不会保持连续状,而是会和其他进程输入的数据打乱和其他进程的数据一起放入管道中
小结:
- 匿名管道是自带协同机制的(同步)
命名管道
特点:
1.以文件形式让毫无关联的进程看到同一份资源
2.命名管道上的数据是不会写入到磁盘里去的,如图:
3.同匿名管道一样,是单向写入的
命名管道创建的俩种方式
一:命令行命令 mkfifo
直接在命令行进行运行该指令,可以直接生成命名管道
也可以在程序中进行命名管道文件写入
二:系统调用接口 mkfifo
参数1:管道文件存放的路径,参数2:管道文件的默认权限,但依然要受我们系统文件掩码的影响
返回值:成功返回0 失败返回-1 并且设置错误码
//创建管道 在读写端创建管道文件都可 但是当写端关闭时,会和匿名管道一样 读端read会返回06 if(mkfifo(PIPE_NAME,PIPE_commison)<0)7 {8 perror("mkfifo");9 return 1; 10 }
使用命名管道实现俩个不同进程之间通信
上面我们说过进程通信是需要不同的进程看到一份相同的资源,匿名管道采用的父子进程,子进程继承父进程的struct files_struct 里面的fd_array实现的不同进程看到同一份资源。
那么os又是以什么样的方式让俩个进程看到同一个命名管道呢?
而实际上,命名管道让俩个不同的进程看到同一份资源的方法是:路径+文件名(具有唯一标识性)—这也是命名管道为什么要有名字的原因
通过指定路径+文件名(可以唯一的标识该文件),让俩个不相关的进程看到同一个资源,同一块内存,然后进行交互
抽象解释图:
注:管道文件在读写端创建都可
头文件包含:
#pragma once
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>#define PIPE_NAME "./fifo"
#define PIPE_commison 0666
写端代码:
#include "com.h"int main()
{//创建管道 在读写端创建管道文件都可 但是当写端关闭时,会和匿名管道一样 读端read会返回0if(mkfifo(PIPE_NAME,PIPE_commison)<0){perror("mkfifo");return 1;}//打开管道文件int fd=open(PIPE_NAME,O_RDWR);if(fd==1){printf("fd fail\n");}while(1){printf("#请输入 : ");fflush(stdout);char buffer[64]={0};ssize_t s=read(0,buffer,sizeof(buffer)-1);if(s>0){buffer[s-1]='\0';printf("%s\n",buffer);write(fd,buffer,strlen(buffer));}else{perror("read");break;}}return 0;
}
读端代吗:
#include "com.h"
#include <stdlib.h>
int main()
{umask(0000);//打开管道文件int fd=open(PIPE_NAME,O_RDONLY);if(fd<0){perror("open");return 2;}while(1){sleep(1);char buffer[64]={0};ssize_t s=read(fd,buffer,sizeof(buffer))-1;if(s>0){//可以定制服务if(strcmp(buffer,"show")==0){pid_t id=fork();if(id==0){execl("/usr/bin/ls","ls",NULL);exit(0);}else{//parentwaitpid(-1,NULL,0);}}else {printf("%s\n",buffer);} }else if(s==0){printf("client quit\n");}else{perror("read");}}return 0;
}
管道实现进程间通信的本质
我们现在大致知道了管道实现进程间通信实际上是不同的进程对管道文件进行写入和读取实现的,而本质其底层是怎么实现的呢?
先来回忆一下我之前博客写的,当我们向普通文件写入的过程是怎么样
进程1调用系统调用write将数据拷贝进test.txt的文件缓冲区中,而后系统调用write又会调用驱动层的写方法将数据写入到磁盘里去
所以进程1调用write函数,实际上write函数做了俩步
- 将数据拷贝到内核级别的文件缓冲区去
- 调用驱动层的写方法将数据写入到磁盘中去
而如果我们将第二步,换成另外一个数据将数据读走,不就实现了进程间通信吗?–俩进程通过os级别文件缓冲区进行通信
而这实际上就是管道通信的实现原理
匿名管道vs命名管道
- 本质是都是文件的缓冲区进行通信
- 匿名管道只能用于父子进程通信,而命名管道可以用在父子进程之间,也可以用在不同进程间通信
- 俩种管道都是面向字节流,单向通信的
system V
- system V通信方式有共享内存,消息队列,信号量(用来实现进程同步和互斥)
- 共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据
而下面主要介绍system V共享内存
system V共享内存
共享内存空间示意图
如图:共享内存同前面的管道一般,也是让不同进程看见同一资源
使用共享内存的步骤:
通过系统调用接口在物理内存里创建出一块内存 --创建共享内存
通过调用将进程挂接该内存 即在我们的页表进行映射–挂接共享内存
释放共享内存的步骤:
去挂接
释放共享内存
查看system V资源的命令行指令
ipcs
作用:查看共享内存相关信息
选项:
-a: 查看信号量,消息队列,共享内存空间相关属性
-m: 只查看共享内存
-q: 只查看消息队列
-s: 只查看信号量
创建或获取共享内存
ftok
作用:创建出key值 (在系统层面进行唯一标识)
pathname: 自定义的路劲名
proj_id: 自定义的id名
返回值:
成功返回0 失败返回-1 并设置错误码
shmget
作用: 开辟共享内存 或者获取共享内存的shmid
key: 即我们上面,通过ftok获取的共享内存在系统层面的标识符 ,不同的进程就是通过相同的key值找到同一个共享内存(注:一个进程挂接的共享内存可能不止一个)
size: 我们所需要的共享内存空间大小,而我们一般开辟都是4kb的整数倍 因为我们os系统开辟都是以内存页(内存页单位大小为:4096 byte)为单位给我们共享内存开辟的
例如: 我们将size填成4097个字节,我们使用起来就是按4097个字节来使用的,而os开辟时实际上并不是直接开辟了4097个字节,而是按照俩个内存页大小给我们开辟的,但我们最后使用起来还是4097个字节,因为如果os背着我们多开空间,可能会造成本该越界访问的操作没有被检测出来(os背不起这个锅),
shmflg:
常用:
IPC_CREAT: 单独使用时,作用为当该共享内存空间不存在时,即创建之,若存在就获取他的shimd
IPC_EXCL: 单独使用没有意义,需和IPC_CREAT起用作用为当该共享内存空间不存在时,即创建之,若存在就返回错误,作用为保证我们使用的是没有被使用过的内存空间
释放共享内存
法一:使用系统调用接口shmctl
shmctl
int id=shmctl(shmid, IPC_RMID, NULL );
目前作用:像命令行的命令一样干掉共享内存
shmid: 前面所说用户级别的标识符(shmget函数的返回值)
cmd: 选项
buf: 通过这个 我们可以获取共享内存的大概信息(输出型参数)
**法二:使用指令 ipcrm **
ipcrm
作用:释放共享内存
格式:
ipcrm -m shmid
注:只有在挂接共享空间的进程数为零时 才能ipcrm 将共享内存释放掉
挂接和去挂接共享内存
shmat
作用:将进程与共享内存空间挂起链接 本质是:在进程的页表建立起虚拟地址和共享内存的映射关系
shmid: 前面所说用户级别的标识符 ,(shmget的返回值)
shmaddr:设置参数,目前只要使用NULL即可
shmflg: 同之前一样 是个参数 此处我们只需要用0即可
返回值:
就像我们c语言malloc一样 返回的是虚拟地址,而我们操作共享内存,就像操作字符指针(指向字符数组)一样
例:
char *mem =(char*)shmat(shmid,NULL,0);
shmdt
作用:解挂起 本质是:在进程的页表去除虚拟地址和共享内存的映射关系
参数:就是我们上面获取的mem地址
例:
int dt=shmdt(mem);
内存空间特点即对比管道
特点:
1.内存空间是没有协同机制的 例:读端并不会等待写端写入才进行操作
2.内存空间是双向的,并没有区分写端读端,俩边皆可操作
3.内存空间读写效率更高 ,因为是直接对内存进行操作 (减少了调用系统接口(read,write),即少了俩次拷贝–拷贝到文件缓冲区)
4.内存空间的生命周期是随os的,所以只能使用命令或者通过shmctl进行操作(也是调用系统命令) ,而管道的生命周期是随进程的
key vs shmid
不同之处:
- **key:**key只是用于,不同进程在寻找同一共享内存时的唯一标识,即用于os层面做唯一标识
- shmid: os给用户进行管理共享内存的唯一标识,用户用于操作共享内存
相同之处:
- 俩者都是对共享内存的唯一标识,只是用于不同场景
头文件代码:
#pragma once
#include <stdio.h>
#include <sys/shm.h>
#include <sys/ipc.h>
#include <sys/types.h>
#include <unistd.h>
#define PATH_NAME "./"
#define SIZE 4097
#define PROJ_ID 0x6666
client.c代码:
#include "com.h"int main()
{//不需要再打开命名空间 只需要获取共享内存的标识号shimd 即可//获取底层的标识符key_t key=ftok(PATH_NAME,PROJ_ID);if(key<0){perror("ftok");return 1;}//获取shimd 命名空间标识符int shmid = shmget(key,SIZE,IPC_CREAT); //单独使用的作用是 没有创建值 存在则获取之printf("shmid:%d\n",shmid);if(shmid<0){perror("shmget");return 2;}//挂接共享内存char *mem =(char*)shmat(shmid,NULL,0);printf("process attach sucess!\n");//if(mem<0)//{// perror("shmat");// return 3;//}//操作char c='A';while(c<'Z'){mem[c-'A']=c;++c;mem[c-'A']=0;sleep(1);}//去挂接int dt=shmdt(mem);if(dt<0){return 4;}return 0;
}
注:shmget最后一个参数选项,创建共享内存时,一定要把权限设置好 ,不然是进行不了读写操作的
service.c代码:
#include "com.h"
#include <unistd.h>
int main()
{key_t key=ftok(PATH_NAME,PROJ_ID);printf("key:%p\n",key);if(key<0){perror("ftok");return 1;}//如果我们的共享内存已经开辟出来了 就会返回-1 int shmid=shmget(key,SIZE,IPC_CREAT|IPC_EXCL|0666); //俩个选线一起用代表 如果该内存空间已经被人开辟了 即返回错误 若是没有被创建 即创建之 printf("shmid:%d\n",shmid);//sleep(10);//将内存空间与该进程挂起联系 建立起映射char *mem=(char*)shmat(shmid,NULL,0);printf("mem attach !\n");//操作while(1){sleep(1);printf("%s\n",mem); //对进程来说里面的内容就是长字符串}//去挂起int dt=shmdt(mem);if(dt<0){perror("shmdt");return 3;}//关闭内存空间int id=shmctl(shmid, IPC_RMID, NULL );if(id<0){perror("shmctl");return 2;}//sleep(10);printf("shmid:%d -> key: %d rm success\n",shmid,key);return 0;
}
相关概念补充
四大概念
- 临界资源:能被多个执行流同时访问的资源,例如:显示器,以及我们进行进程间通信时的管道 ,共享内存,消息队列等都是临界资源。
- 临界区:访问临界资源的代码就是临界区
- 原子性:一个事要么做,要么不做,没有过程即没有中间态,就叫原子性
- 互斥:任何时刻只允许一个执行流进入临界资源,执行其的临界区
消息队列
是以传输数据为目的
信号量
信号量不是以传输数据为目的的,而是通过“共享资源”的方式,来达到多个进程同步和互斥的目的的
信号量本质是一个计数器:衡量临界资源数目的
信号量也是临界资源,为了保证其安全性,所以其的写入是保持原子性的