文章目录
为什么要有进程间通信
- 每一个进程的数据都是存储在物理内存中,进程通过各自的进程虚拟地址空间进行访问。访问的时候,通过各自的页表的映射关系,访问到物理内存
- 从进程的角度看,每个进程都认为自己有4G(在32位OS平台下)的空间,至于物理内存当中是如何存储,页表如何映射,进程是不清楚的。这也造就了进程的独立性
- 进程独立性的优缺点
优点: 每一个进程在运行的时候,都是独立的,数据不会窜
缺点:如果两个进程之间需要交换数据,那么由于进程独立性,就无法方便的交换数据
因此就需要一种手段来建立进程间的联系。这也就是进程间通信存在的原因,
进程间通信本质上是进程和进程之间交换数据的手段
常见的进程间通信方式
主要有6种
信号和网络在后续会专门详解。本篇文章只是对前四种方式的介绍
管道
匿名管道
符号 && 本质
- 符号
管道的符号就是一条竖划线|
e.gps aux | grep xxx
- 本质
匿名管道在内核当中是一块缓冲区,供进程进行读写,交换数据
接口
原型:
int pipe (int pipefd[2]);
参数参数为出参(输出型参数)
也就是pipefd的值是函数pipe进行填充的,调用者进行使用
pipefd是数组,有两个元素,其中pipefd[0]
:管道的读端,pipefd[1]
:管道的写端返回值
成功:0
失败:-1
PCB角度理解管道
PCB中有一个结构体指针files,它指向的是struct files_struct 结构体,该结构体中有一个结构体指针数组 fd_array[ ],
该数组中的每一个元素都是一个结构体指针,该指针指向的就是一个描述文件信息的结构体。
该数组的下标就是文件的文件描述符
下面画一个简图来理解一下:
- 在调用pipe接口后,就会在内核当中产生一块缓冲区。该缓冲区有读写两端,相应的,也会产生两个文件描述符。分别与读端和写端相对应
- 创建一个进程,调用接口pipe后,进程不要退出,通过查看/Pro/进程号/fd文件夹下的文件,可以发现新产生的两个文件描述符与pipe函数的参数pipefd[0]、pipefd[1]的内容相对应
在这里我们只是验证了pipe这个接口的特性,并没有验证两个进程间相互通信
接下来我们通过代码来验证一下,两个进程是如何通信的!
代码验证
-
要让不同的进程通过匿名管道进行交换数据(进程间通信),进程应该具备什么样的条件?
不同的进程,要用同一个匿名管道进行通信,则需要进程拥有该管道的读写两端的文件描述符 -
测试
1、提供两个进程,通过pipe分别创建管道,然后进行通信
两个进程使用相同的代码:
我们发现,两个进程打开的并不是同一个匿名管道,因此也就无法进行通信。
2、提供具有亲缘关系的进程(这里以父子进程为例),然后进行通信
既然两个普通进程无法通过匿名管道进行通信,那我们就提供两个具有亲缘关系的进程。
我们都知道父进程在创建子进程的时候,子进程会拷贝父进程的PCB,而创建的匿名管道也可以通过PCB找到并对其操作。那么,这样就可以满足两个进程拥有同一个匿名管道,也就是意味着可以进行通信了。
但是,要注意:父进程要先创建匿名管道,再创建子进程,否则子进程拷贝的PCB中就没有匿名管道的相关信息,也就无法通信。
先画图阐述一下上面的观点:
现在我们开始使用代码来实践一下
2.1父子进程,父进程从缓冲区读,子进程向缓冲区中写。二者完成进程间通信
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
//使用pipe接口创建匿名管道
int fd[2] = {0};
int ret = pipe(fd);
if(ret < 0)
{
perror("pipe");
return -1;
}
//匿名管道创建成功,通过fork创建子进程
int pid = fork();
if(pid < 0)
{
perror("fork");
return 0;
}
else if(0 == pid)
{
//child
//让子进程从管道中读
sleep(1);//让父进程先写
char buf[1024] = {0};
read(fd[0],buf,sizeof(buf)-1);
printf("我是子进程,我从管道读到的数据是:%s\n",buf);
}
else
{
//father
const char* str = "I am father process!!!";
write(fd[1],str,strlen(str));
}
while(1)
{
sleep(1);
}
return 0;
}
子进程读取成功
2.2父子进程,父进程先关闭缓冲区的读功能(close(pipefd[0])),再向缓冲区写,子进程先关闭写(close(pipefd[1])),再从缓冲区中读。二者完成进程间通信
问:二者可以正常通信吗?
依旧是可以正常通信
为什么父子进程在关闭缓冲区的读写两端后依旧能够通信呢?
这里涉及到IO篇提到的简单文件系统
下面我画图解释一下:
匿名管道特性
半双工
数据只能从管道的写端流向管道的读端,不支持双向通信
没有标识符
匿名管道只能与具有亲缘关系的进程进行进程间通信
创建的匿名管道,在内核当中是没有任何的标识符的,其他的进程是没有是没有办法通过标识符找到这个匿名管道的缓冲区的。
也就是说只能具有亲缘关系的进程可以实现进程间通信
本质理解就是下面这张图:
生命周期跟随进程
进程退出之后。管道也就随之被销毁了管道的大小为64k
验证:无脑向管道中写,不进行读。观察什么时候写满,计算字节数
管道提供字节流服务
5.1从读端进行读的时候,是将数据从管道中读走了
验证:向缓冲区写一次数据,读两次,观察第二次读取时的情况:
我们可以看到,第一次读取时正常的,第二次的输出语句并没有输出,也就是说这条printf语句并没有执行 并且程序也没有退出
原因是管道中的数据被第一次已经读走了,现在里面没有数据,再去通过read读取的时候,就会发生阻塞
通过pstack [pid]
查看进程的栈跟踪信息
5.2 读端可以自定义选择读多少内容
我们通过限定保存读出来的数据的buf的大小,来自定义每次读取的字节数
pipe_size
大小:4096字节
pipe_size是保证读写时原子性的阈值。当读/写小于pipe_size的时候,保证管道读写的原子性
什么是原子性?
阻塞
读写两端的文件描述符初始的属性值为阻塞属性
代码验证:
7.1write一直调用去写,读端不去读,写满之后write会阻塞
这点在前面验证管道大小的时候,已经说明方法,这里直接给出结果
7.2read一直去读,当管道内内部被读完之后,则read会被阻塞
进程向缓冲区写一次,然后读两次,观察结果
设置非阻塞属性
- 接口
int fcntl(int fd,int cmd,.../*arg*/)
参数fd :待操作的文件描述符
cmd
…:可变参数列表返回值:取决于cmd的值
F_GETFL:返回文件描述符的属性信息
F_SETFL:设置成功:0,设置失败:-1
- 使用
设置非阻塞属性的时候,宏的名字是O_NONBLOCK
设置步骤:
- 代码验证
验证1:读设置为非阻塞属性
前提:为了不影响代码验证结果,我们将无用的文件描述符关闭。此处是父进程进行写,子进程进行读。因此在验证的时候关闭子进程的写端和父进程的读端。
1.1写不关闭,一直读
代码:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
//创建匿名管道
int fd[2] = {0};
int ret = pipe(fd);
if(ret < 0)
{
perror("pipe");
return -1;
}
pid_t pid = fork();
if(pid < 0)
{
perror("fork");
return 0;
}
else if(pid == 0)
{
//child
/*
* 1、关闭写
* 2、设置读为非阻塞
* 3、读
* */
close(fd[1]);
int flag = fcntl(fd[0],F_GETFL);
fcntl(fd[0],F_SETFL,flag|O_NONBLOCK);
char buf[1024] = {0};
ssize_t ret = read(fd[0],buf,sizeof(buf)-1);
printf("ret = %ld\n",ret);
perror("read");
}
else
{
//father
/*
* 1、关闭读
* 2、写关闭/写不关闭
* */
close(fd[0]);
//写不关闭,但是也不写
while(1)
{
sleep(1);
}
}
return 0;
}
1.2写关闭,一直读
代码:在1.1的父进程模块加上close(fd[1]);
即可
验证2:写设置为非阻塞属性
前提:为了不影响代码验证结果,我们将无用的文件描述符关闭。此处是父进程进行读,子进程进行写。因此在验证的时候关闭子进程的读端和父进程的写端。
2.1读不关闭,一直写
当把管道写满之后,再调用write函数,返回-1,errno设置为EAGAIN
代码:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
//创建匿名管道
int fd[2] = {0};
int ret = pipe(fd);
if(ret < 0)
{
perror("pipe");
return -1;
}
pid_t pid = fork();
if(pid < 0)
{
perror("fork");
return 0;
}
else if(pid == 0)
{
//child
/*
* 1、关闭读
* 2、设置写为非阻塞
* 3、写
* */
sleep(3);//让父进程先运行,关闭fd[0]
close(fd[0]);
int flag = fcntl(fd[1],F_GETFL);
fcntl(fd[1],F_SETFL,flag|O_NONBLOCK);
ssize_t write_size;
while(1)
{
write_size = write(fd[1],"a",1);
if(write_size < 0)
{
break;
}
}
printf("write_size:%ld\n",write_size);
perror("write");
}
else
{
//father
/*
* 1、关闭写
* 2、读关闭/读不关闭
* */
close(fd[1]);
//读不关闭,但是也不读
while(1)
{
sleep(1);
}
}
return 0;
}
2.2读关闭,一直写
代码:在父进程中加上close(fd[0]);
即可
本质原因:写端的进程收到了SIGPIPE信号,导致写端进程崩溃
扩展
系统接口当中,文件打开方式的宏,在内核当中使用的方式是位图(e.g O_RDONLY、O_CREAT 、O_NONBLOCK)
通过源码来验证我们的猜想
我们在fcntl.h中找到了这个宏的定义!
其中O_NONBLOCK为二进制的1000 0000 0000
与上面的分析一致
命名管道
- 创建命名管道
1.1 mkfifo命令
1.2 函数创建int mkfifo(const char* pathname, mode_t mode)
- 特性
支持不同的进程进行进程间通信,不依赖于亲缘性了 - 代码验证
前提:两个没有亲缘关系的进程通过打开同一个命名管道文件进行进程间交互。写进程每10秒钟写一次,读进程一直读。观察现象。
共享内存
首先明确:共享内存是进程间进行通信的一种手段。
原理
- 共享内存是在物理内存当中的一段空间
- 不同的进程通过各自的页表将该物理内存空间映射到自己进程的虚拟地址空间当中
- 不同的进程通过操作自己的进程虚拟地址空间当中的虚拟地址来操作共享内存
下面我画图理解一下:
接口
- 创建或者获取共享内存接口
int shmget(key_t key,size_t size,int shmflg)
参数key:共享内存的标识符
size:共享内存的大小
shmflg:获取/创建共享内存时,传递的属性信息
返回值
成功:返回共享内存的操作句柄
失败:返回-1
- 将共享内存附加到进程的虚拟地址空间
void* shmat(int shmid , const void* shmaddr , int shmflg)
参数shmid:共享内存操作句柄,就是shmget函数调用成功的返回值
shmaddr:将共享内存附加到进程的哪一个地址上,一般让OS自己分配,所以传递NULL
shmflg:以什么权限将共享内存附加到进程当中(约束进程对共享内存有什么样的权限)SHM_RDONLY:只读
0:可读可写返回值
成功:返回附加的虚拟地址
失败:返回 NULL
- 分离
int shmdt (const void* shmaddr)
参数shmaddr:shmat函数的返回值
返回值
成功:0
失败:-1
- 操作共享内存接口
int shmctl (int shmid , int cmd, struct shmid_ds *buf)
参数:
对于第三个参数buf,它指向的结构体如下:
特性总结
-
生命周期跟随OS
-
共享内存是覆盖写的方式,读的时候是访问地址
写:覆盖上一次的数据(可以理解为清空上一次的数据,重新写)
读:只是访问地址,进行读取。读完之后数据还在(区别于管道的字节流,它是将数据直接从管道拿走了) -
共享内存的删除特性
ipcs命令 && ipcrm命令ipcrm -m [shmid] :删除一个共享内存
3.1一旦共享内存被删掉之后,共享内存的物理内存当中的空间就被销毁了
3.2删除共享内存的时候,若共享内存附加的进程数量为0,则内核当中描述该共享内存的结构体也被释放了
3.3删除共享内存的时候,若共享内存附加的进程数量不为0,则会将该共享内存的key变成0x00000000.表示当前共享内存不能被其他进程所附加,共享内存的状态会被设置为destory。附加的进程一旦全部退出之后,该共享内存在内核的结构体会被操作系统释放
等到进程全部退出后,OS会自动释放该共享内存的内核结构体
代码验证
验证思路:
1、两个进程分别通过shmget来创建或者获取共享内存(注意,两个进程的共享内存标识符,共享内存大小必须一致,否则获取得到就不是同一个共享内存)
2、分别调用shmat接口将共享内存附加到自己的进程虚拟地址空间去
3、进行通信
4、通信完毕,将进程和共享内存分离
验证1:两个进程写完均退出,观察现象
可以正常通信
验证2:让进程不退出,使用ipcs命令查看共享内存的使用情况
消息队列
原理
1、msgqueue采用链表来实现消息队列,该链表由系统内核来维护
2、系统中可能会有很多的msgqueue,每个MQ用消息队列描述符(消息队列id - qid)来区分,qid是唯一的,用来区分不同的MQ
3、在进行进程间通信的时候,一个进程将消息追加到MQ的尾端,另一个进程从消息队列里取数据(不一定按照先进先出的原则取数据,也可以按照消息类型字段来取)
接口
创建消息队列
int msgget(key_t key,int msgflg);
发送消息
int msgsnd(int msqid,const void* msgp,size_t msgsz,int msgflg)
参数msqid:消息队列的ID
msgp:指向msgbuf的指针,用来指定发送的消息
注:OS为函数发送的消息定义了发送格式,但是只定义了一部分,另外一部分需要程序员自己定义
msgsz:要发送消息的长度(消息内容,就是struct msgbuf结构体中char mtext[]数组的大小)
msgflg:创建标记
返回值
成功:0
失败:-1 并设置errno
接收消息
ssize_t msgrcv(int msqid,void* msgp, size_t msgsz,long msgtyp,int msgflg);
返回值成功:返回实际读取消息的字节数
失败:-1并设置errno
操作消息队列接口
int msgctl(int msqid,int cmd,struct msqid_ds *buf)
代码验证
验证思路:
- 创建一个消息队列(生命周期跟随OS)
不同进程想要使用消息队列进行通信的时候,只需要获取同样的消息队列标识符就好了 - 一个进程发送,一个进程接收
基本功能的验证:
验证是否可以通信:
验证了阻塞特性
让发送进程再执行一次,被阻塞的接收进程就会正常读取到数据
验证接收消息的方式 msgtyp
指定msgtyp成功接收
接收阻塞的情况
再次发送,查看情况
使用ipcs命令查看消息队列相关数据
消息数正好为37,与我们分析的一致。
信号量
system V 信号量
不涉及代码,理解原理就好
信号量的生命周期跟随OS
信号量的原理
- 信号量本质是资源计数器,能够保证多个进程访问临界资源,执行临界区代码的时候,互斥访问,同时也可以用于同步
- 临界资源:多个进程都可以访问到的资源(比如共享内存)
- 临界区:访问临界资源时的代码区域称之为临界区
- 互斥访问
同一时刻,多个进程当中,只有一个进程可以访问临界区资源
如何保证互斥访问?
通过信号量的值来保证,每个进程需要先获取信号量,只有获取到信号量才可以访问临界资源,如果获取失败,就会阻塞等待
为什么要互斥访问临界区?
以共享内存为例(深刻理解程序计数器和上下文信息)
前提:有AB两个进程,他们通过共享内存进行信息的交互。进程A和进程B在附加成功后,都能够访问共享内存(可以读也可以写),假设两个进程都进行写,会造成程序结果的二义性吗?
答案是 会
我们从以下几个方面入手分析:
1.这两个进程可以同时往共享内存中写数据吗?
2.只有多核CPU的情况下,才会有程序结果的二义性吗?
并不是。看下面的分析
3.总结一下:
多个进程访问临界资源,如果不加以约束(互斥访问临界资源),就一定会产生程序结果的二义性
所以说:互斥访问存在的原因就是为了保证程序结果不会有二义性!
- 同步访问
当临界资源空闲之后,通知正在等待的进程进行访问
以上就是对进程间通信的基本内容的总结~码字不易,兄弟们要是觉得有所收获,请留下你的足迹!