这里写目录标题
- 为什么有通信
- 通信的两个标准
- 通信的本质
- 管道通信的本质
- 如何实现管道通信
- 管道文件的特点
- 管道的特征
- 如何理解指令上的管道
为什么有通信
在我们的生活中有很多地方都需要用到通信,比如说出去玩要告诉伙伴们我们到哪了,做一件事的时候得通过通信来获取一些关键的数据等等地方都需要通信,通信在生活中有着巨大的作用,那么同样的道理执行多个程序的时候也是要进行通信的比如说数据传输:一个进程需要将它的数据发送给另外一个进程;比如说资源共享:多个进程之间共享同样的资源;比如说通知事件:一个进程需要向另外一个或者一组进程发送消息,通知它们发生了什么事情比如说子进程终止时要通知父进程,比如说进程控制:有些进程希望完全控制另外一个进程的执行,比如说gdb是一个进程,当使用gdb的时候就可以通过gdb进程控制另外一个我们写的进程,此时控制进程希望能够拦截另外一个进程的所有错误和异常并及时的知道他的状态;有时候我们是需要多进程协同来完成某种业务内容,比如说cat file|grep ‘hello’cat指令是一个进程他可以将file文件里面的内容打印到屏幕上面,grep也是一个进程他可以在指定的内容里面查找一些数据,中间的管道可以将cat进程执行的数据传送到grep进程里面从而完成某种功能,那么这就是进程之间的通信。进程之间的通信能够帮助我们更好的更加方便的解决一些问题实现一些功能,但是进程之间是有独立性的,就连最亲的父子进程之间都是完全独立的,那如何来实现通信呢?要想知道这个问题我们就继续往下了解
通信的两个标准
因为进程之间是相互独立的,所以要想让两个进程之间实现通信一定会有很大的成本作为前提,那么为了实现进程和进程之间的通信科学家们就指定了两个标准:一个是prosix标准,一个是system V标准。prosix标准可以让通信的过程可以跨主机,而system V标准则聚焦在本地的通信(共享内存).由于时代发展的很迅速system V标准逐渐被被主流淘汰了原因如下:第一个:当下是万物互联的时代肯定得实现多个设备之间的通信所以得淘汰 第二个:system V标准出来的比较早,设计接口的时候是自己营造的,也就是说这些接口和我们现在使用的文件有关,但是使用起来这些接口和文件没有半毛钱的关系,主流通信的文件描述符都是fd文件描述符的形式,所以system标准就有点水土不服不兼容所以被淘汰。所以对于sysytem v标准我们就学习共享内存这一部分的内容,对于消息队列问题信号量问题我们就不多学习。
通信的本质
要实现通信首先操作系统得直接或者间接的给通信双方提供内存空间,提供内存空间之后还得让两个进程之间看到这个公共资源。不同的通信种类本质上就是:上面所说的资源是操作系统中哪一个模块提供的,如果这个公共资源是文件系统提供的那么我们就称为管道通信,如果这个资源是操作系统中的system V通信模块提供的那么就称之为system V通信方式,如果这个资源是操作系统提供的内存上的一大块内存那么就将其称之为共享内存,如果是一个计数器那么就称之为信号量等等,所以不同的通信种类本质就是操作系统中的某个模块提供的资源。
进程之间通信的难处就在于:1.因为进程具有独立性所以得在保证进程独立性的前提下让不同的进程看到同一份资源 2.看到资源之后就得想办法实现通信说白了就是传递信息和数据读取信息和数据,所以未来我们学的进程之间通信的接口与其说是进程通信的接口倒不如说是让不同进程看到同一份资源的接口拿到数据的接口。
管道通信的本质
我们先来聊聊管道通信,管道通信既不是system V标准也不是prosix标准,我们说不同的通信方式本质上就是不同的模块提供的公共空间,所以对于管道通信来说就是文件系统给两个进程提供的公共空间。当我们打开一个文件的时候操作系统会为这个文件创建一个struct file结构体,这个结构体里面会记录这个文件的在file结构体里面会有file文件的操作方法,文件的各种属性,有属于该文件的内核缓冲区,还有struct page,比如说下面的图片:
打一个文件可执行程序被加载进内存之后会变成一个进程,操作系统为了管理这个进程就又会创建一个task_struct也就是PCB来管理这个进程,
PCB中有一个structs files_struct*file指针指向一个struct files_struct,在files_struct中有一个数组,数组的元素为指向struct_file结构体的指针,比如说下面的图片:
task_struct和struct file_struct是属于进程管理系统的,所以当父进程创建子进程的时候为了保护进程的独立性会给子进程也创建对应的PCB和struct file_struct,但是strcut file属于文件管理系统跟进程管理系统没有关系,所以创建子进程的时候不会创建struct_file,比如说下面的图片:
struct file中含有文件的各种属性和操作方法,但是这里最主要的是struct file中含有一个缓冲区,子进程和父进程都可以往这个缓冲区里面写入内容并且还能相互不冲突,那这个缓冲区不就相当于一个公共的资源了吗?我们说往一个文件里面写入数据本质上是先将数据写入到struct file里面的内核缓冲区中再刷新到系统的内核缓冲区中最后再刷新到文件的里面,而读取文件的时候本质上也是将文件里面的数据存放到文件的struct file结构体里面然后进程再进行读取,这也就说明struct file结构体里面的内核缓冲区既可以实现存放进程传过来的数据,又可以让进程从里面读取数据,那通过这个缓冲区是不是就可以让父子进程读取数据呢?比如说父进程将数据传送到file结构体里面的内核缓冲区,子进程则从内核缓冲区中读取数据,那这不就是一个通信的过程吗?确实是的我们把上面的过程称为管道通信,管道可以分为匿名管道和命名管道,上面使用文件的方式来实现父子进程之间的通信,那么这个内核级文件就称之为管道文件,所以管道文件本质上就是一个文件,一般来说struct file结构体对应着一个文件在磁盘上,操作系统可以将内存里面的数据刷新到磁盘,然后另外一个进程再从磁盘中的文件读取数据,操作系统可以这么做,但是这么做的效率太低了,所以在通信的时候操作系统不会将数据刷新到磁盘上,而是一直存放在内存上等着另外一个进程读取数据,所以这里通信的数据是存放在内存上,然后进程再从内存上读取数据,既然数据不会刷新到磁盘上面,那么理论上来说在磁盘上也就不会存在对应的文件,在之前的学习中我们知道当打开一个文件时,操作系统会给这个文件创建一个struct file对象,并且往这个对象中填入file的操作方法,缓冲区等等,操作系统在打开文件的时候有上述的这些能力,那么操作系统就算不创建文件也会有这样的能力,所以我们上面说的管道文件是一个内存级文件,内存级文件不会关心自己在磁盘中所对应的位置,不会关心自己的数据要不要写入磁盘,他只要在内核中创建struct file对象,给自己创建一个缓冲区,然后把struct file对象的地址填入到struct file*fd_array[]里面就可以了,这样就可以让一个进程看到这个内存级文件,使用fork函数创建子进程时也会创建一个文件描述符表,所以这时两个进程都可以看到同一份公共资源,那么这就是管道通信的原理,管道不需要往磁盘中刷新数据。看到这里想必大家知道了管道通信的原理是什么,那这里就还存在一个问题如何让两个进程看到同一个管道文件?父进程先打开一个内存级文件,然后再使用fork函数创建一个子进程,这样子进程就会继承父进程的文件描述符表,因为父子进程的文件描述符表是一样的,所以此时两个进程就可以通过进程描述符表指向同一个内存级文件,这样就实现了进程之间的通信,我们把这样的文件称为管道文件,因为创建管道的时候没有给管道取名字,所以我们就把这样的方法称为匿名管道。
如何实现管道通信
首先父进程创建管道时必须得以读和写的方式打开一个文件,比如说下面的图片
因为子进程是继承的父进程的读写方式如果父进程可以对缓冲区进行读写的话子进程也可以以对缓冲区进行读写,
一般而言管道只能用来进行单项数据通信,所以得关闭父进程的读端和子进程的写端,反过来也是成立的,
当然这里也可以不关闭父子进程的读写文件描述符,但是你无法保证你没有被关闭的文件描述符会不会不小心被别人用到从而导致错误所以还是关闭为好。这里大家可能会感到疑惑为什么管道得是单行通信的呢?管道难道不能双向通信吗?首先在我们的生活中管道一般都是单项传输资源的,比如说自来水公司通过管道将水源运输到我们的家中,俄罗斯通过管道将石油运输到中国等等都是单项的传递资源,起始当初设计者创建这个东西的时候一定考虑了很多的情况很多的场景,假如说这个管道变成了双向的每个进程都可以往这个缓冲区里面读数据和写数据,那这是不是就会带来一个问题,我读数据的时候得先判断一下这个数据是不是我之前写的,不是我写的我才能将这个数据读取到我的进程里面,当然管道实现双向所带来的问题还不止这一个一定还有很多,所以为了解决这些问题是不是得提出更多的规则对管道进行约束,并且提供更多的接口来供操作者使用,那这也就会大大的提高操作者的使用成本,所以科学家们经过多场景多情况的研究最终确定了将通信的方法实现成为单项,然后根据这个单项的特征将这个通信的方法称为管道,所以是有了这个方式才取名为管道并不是有了管道才实现为单项,当然这里也不是说一定要关闭文件的描述符不关也是可以正常使用的,但是你无法确保自己没有关闭的接口在未来会不会被其他人不小心使用而照成了一些其他的错误,所以这里还是建议大家关闭没有用的接口。看到这里想必大家应该已经知道了匿名管道通信在底层是如何工作,但是这里我们还是有个问题我们如何来创建一个内存级的文件呢?要想知道这个问题就得知道一个名为pipe的函数,我们首先来看看pipe函数的参数是什么:
pipe函数只有一个数组参数,这个参数是一个输出形参数,pipe创建内存级文件时会将文件的输出端写入端的文件描述符放到这个参数数组里面,这个数组有两个元素,下标为0的元素存放的就是文件的读段,下标为1的元素存放的就是文件的写段,比如说文件创建成功之后可以通过文件描述符3读取内容通过文件描述符4向文件里面写入内容,那么数组中下标为0的元素的值就是3,下标为1的元素的值就是4,当文件创建成功之后pipe函数就会返回0,如果文件创建失败就会返回 -1
有了这个函数我们就可以实现通信的第一步创建一个公共的空间,那这里我们就可以尝试写一段代码来实现父子进程的通信,首先创建一个只有两个元素的整型数组,然后调用pipe函数并把这个函数作为参数,这里为了防止以防创建失败,我们就创建一个整型变量来记录一下pipe函数的返回值,如果返回值不等于0的话我们就打印一些数据并结束程序,比如说下面的代码:
#include<iostream>
#include<unistd.h>
using namespace std;
int main()
{
int fd_arr[2];
int tmp=pipe(fd_arr);
if(tmp!=0)
{
cout<<"创建临时文件失败"<<endl;
}
return 0;
}
然后我们就可以通过fork函数创建子进程并用整型变量fd来记录fork函数的返回值,因为创建子进程也是可能会失败,所以这里也得通过一个if语句来判断一下子进程的创建是否成功,因为父子进程的数据是共享的,所以在子进程里面也会存在一个fd_arry数组,所以子进程也可以通过fd_arr数组中的数据来访问管道里面的内容,因为父进程得对子进程进行回收,所以在父进程的最后得使用waitpid函数进行阻塞式等待来回收子进程的数据,那么这里的代码就如下:
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
int main()
{
int fd_arr[2];
int tmp=pipe(fd_arr);
if(tmp!=0)
{
cout<<"创建临时文件失败"<<endl;
}
int pid =fork();
if(pid<0)
{
cout<<"创建子进程失败"<<endl;
}
if(pid==0)
{
//这个是子进程,下面实现通信
exit(0);
}
//下面是父进程执行的代码
waitpid(pid,nullptr,0);//阻塞等待
return 0;
}
因为pipe函数是以读和写的方式创建的文件,所以父进程和子进程干的第一件事情就是关闭该文件的一些接口了,我们想让子进程往里面写入数据,让父进程从里面读取数据,所以子进程就得使用close函数关闭文件的读端也就是fd_arr中第一个元素记录的数据,父进程使用close函数关闭文件的写段也就是fd_arr中第二个元素记录的数据,关闭完这些对应的端口之后子进程就可以往缓冲区里面写入数据,首先创建一个数组数组的每个元素char类型,数组的大小设置为1024,然后就可以使用snprintf函数往这个数组里面输入内容,输入完之后就可以使用write函数将数组里面的内容输出到临时文件里面,比如说下面的代码:
if(pid==0)
{
//这个是子进程,下面实现通信
close(fd_arr[0]);
const char* s="我是子进程我真正跟你发消息";
int cnt=0;
while(true)
{
cnt++;
char buffer[1024];
snprintf(buffer,sizeof(buffer),"child->parent say:%s [%d]",s,cnt);
write(fd_arr[1],buffer,strlen(buffer));
sleep(1);
}
exit(0);
}
然后父进程就是先关闭下标为1的输入端创建一个字符数组,然后使用read函数从临时文件中读取内容并放到数组里面去,这里为了确保字符串的安全性每次读完数据之后都在字符串的最后添加上一个\0,然后打印数组里面的内容就可以了,比如说下面的代码:
//下面是父进程执行的代码
close(fd_arr[1]);
while(true)
{
char buffer[1024];
ssize_t s =read(fd_arr[0],buffer,sizeof(buffer)-1);
if(s>0)
{
buffer[s]=0;
}
cout<<"#"<<buffer<<endl;
}
waitpid(pid,nullptr,0);//阻塞等待
写到这里我们通信的过程就实现了,那么这里完整的代码就如下
#include<iostream>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
int main()
{
int fd_arr[2];
int tmp=pipe(fd_arr);
if(tmp!=0)
{
cout<<"创建临时文件失败"<<endl;
}
int pid =fork();
if(pid<0)
{
cout<<"创建子进程失败"<<endl;
}
if(pid==0)
{
//这个是子进程,下面实现通信
close(fd_arr[0]);
const char* s="我是子进程我真正跟你发消息";
int cnt=0;
while(true)
{
cnt++;
char buffer[1024];
snprintf(buffer,sizeof(buffer),"child->parent say:%s [%d]",s,cnt);
write(fd_arr[1],buffer,strlen(buffer));
sleep(1);
}
exit(0);
}
//下面是父进程执行的代码
close(fd_arr[1]);
while(true)
{
char buffer[1024];
ssize_t s =read(fd_arr[0],buffer,sizeof(buffer)-1);
if(s>0)
{
buffer[s]=0;
}
cout<<"#"<<buffer<<endl;
}
waitpid(pid,nullptr,0);//阻塞等待
return 0;
}
这里大家要注意的两个点:第一就是子进程是没用往屏幕上打印数据的只有父进程往屏幕上打印了数据;第二点就是父进程打印的数据是来自于子进程输出到管道里面的数据,所以如果说程序运行起来如果屏幕上面出现数据的话,那么这就说明父子进程之间实现了通信,代码的运行结果如下:
那么这就说明通过pipe函数确实可以创建一个公共空间,通过write函数和read函数确实可以实现数据的传递和接收,并且使用ls指令查看文件的时候也没有创建新的文件:
那么这就说明我们的通信实现成功了。
管道文件的特点
我们可以把管道之间的通信想象称为一场考试,写入端是一位学霸它已经报送了不用参加这个考试但是为了帮助一位朋友仍然参加了这个考试,而读端就是这个朋友,他坐在学霸的后面准备抄学霸写的试卷,而通信的特点就和这个考试抄别人答案有着相似,考试抄别人的答案可能会出现4种情况,
第一种:
第一种就是写答案的人被一道题难住了而抄答案的人已经把前面的答案抄完了,那这个时候对于抄答案的人来说是不是只能爬在桌子上面发呆等着学霸把那道题做出来再抄啊,所以当写端写的慢读端却读的很快时,操作系统会让读端什么事都不敢原地等着,比如说下面的代码,我们让写端输出一段数据然后休眠20秒就可以看到读端读取一段数据之后也跟着休眠了起来没有执行后面的代码,那这里修改的代码的就如下:
if(pid==0)
{
//这个是子进程,下面实现通信
close(fd_arr[0]);
const char* s="我是子进程我真正跟你发消息";
int cnt=0;
while(true)
{
cnt++;
char buffer[1024];
snprintf(buffer,sizeof(buffer),"child->parent say:%s [%d]",s,cnt);
write(fd_arr[1],buffer,strlen(buffer));
sleep(20);
}
exit(0);
}
//下面是父进程执行的代码
close(fd_arr[1]);
int cnt=1;
while(true)
{
char buffer[1024];
ssize_t s =read(fd_arr[0],buffer,sizeof(buffer)-1);
if(cnt==2)
{
cout<<"我等了好久"<<endl;
}
if(s>0)
{
buffer[s]=0;
}
cout<<"#"<<buffer<<endl;
cnt++;
}
这段代码的运行结果如下:
那么这里便可以验证我们的上面的一点。
第二种:
有时候抄答案的时候写答案的人不小型把答案给遮挡住了,导致抄答案的人看不到答案,当能看到答案的时候别人已经写了一大堆数据了,所以这个时候抄答案的人得马力全开将别人写的数据以最快的速度全部抄下来,那么这时你的卷子上也会出现一大堆的数据,考试的时候有这样的情况对于通信来说也会有这样的情况,管道是有固定的容量的,当管道写满了之后就无法写端是无法再往管道里面写入数据的,他得得等读端将数据读了之后才能继续往里面写入,那么我们把这种情况称为这个是写快读慢,因为写的比较快所以缓冲区中具有很多的数据,这些数据可能是按照行输入的,但是读取的时候可不会管你是行输入还是怎么输入,他都会按照二进制数据进行读取,所以打印的内容是一大坨数据,比如说将代码修改成这样:
if(pid==0)
{
//这个是子进程,下面实现通信
close(fd_arr[0]);
const char* s="我是子进程我真正跟你发消息";
int cnt=0;
while(true)
{
cnt++;
char buffer[1024];
snprintf(buffer,sizeof(buffer),"child->parent say:%s [%d]",s,cnt);
write(fd_arr[1],buffer,strlen(buffer));
}
exit(0);
}
//下面是父进程执行的代码
close(fd_arr[1]);
while(true)
{
sleep(5);
char buffer[1024];
ssize_t s =read(fd_arr[0],buffer,sizeof(buffer)-1);
if(s>0)
{
buffer[s]=0;
}
cout<<"#"<<buffer<<endl;
cout<<endl;
}
那么这里的运行如下:
第三点:
有时候抄作业的时候会出现这样的情况你还没有抄完,但是写作业的人已经交试卷了那这个时候你会怎么做呢!答案是直接交卷因为你本身也不会写啊,没得抄了不就直接交卷了嘛对吧,那么通信的时候也会遇到这种情况,如果写入进程关闭了写端,但是读取端没有被关闭并且公共内存种没有数据的话,read函数不会在那静静的等待而是直接返回0,所以当我们发现read函数返回0之后就可以打印读取结束并结束该进程,比如说下面的代码:
if(pid==0)
{
//这个是子进程,下面实现通信
close(fd_arr[0]);
const char* s="我是子进程我真正跟你发消息";
int cnt=0;
while(true)
{
cnt++;
char buffer[1024];
snprintf(buffer,sizeof(buffer),"child->parent say:%s [%d]",s,cnt);
write(fd_arr[1],buffer,strlen(buffer));
break;
}
exit(0);
}
//下面是父进程执行的代码
close(fd_arr[1]);
while(true)
{
char buffer[1024];
ssize_t s =read(fd_arr[0],buffer,sizeof(buffer)-1);
if(s>0)
{
buffer[s]=0;
}
if(s==0)
{
cout<<"read的返回值:"<<s<<endl;
cout<<"读取数据结束写端已经关闭"<<endl;
break;//进程结束之后会自动关闭对应的文件描述符
}
cout<<"#"<<buffer<<endl;
cout<<endl;
}
这段代码的运行结果如下:
我们可以看到确实符合我们的预期。
第四点
抄作业的时候还会出现第四种情况就是抄作业的人不想抄了,虽然写试卷的人没有把试卷写完但是抄答案的人已经不想抄了他直接交卷了,那么对于这种情况写试卷的人肯定也是直接交卷了不写了,因为我本来就已经保送了现在你又不抄我的了,那我为什么还要写呢?对吧,那么在我们的通信里面也会出现这样的情况,当读端被关闭了写端也会被操作系统异常终止的,因为往内存级文件里面写入数据也是要浪费很多资源的既然都不读了那为什么还要往这个里面写入内容呢对吧,那么这里就可以通过下面的这段代码进行验证:
if(pid==0)
{
//这个是子进程,下面实现通信
close(fd_arr[0]);
const char* s="我是子进程我真正跟你发消息";
int cnt=0;
while(true)
{
cnt++;
char buffer[1024];
snprintf(buffer,sizeof(buffer),"child->parent say:%s [%d]",s,cnt);
write(fd_arr[1],buffer,strlen(buffer));
sleep(1);
}
exit(0);
}
//下面是父进程执行的代码
close(fd_arr[1]);
while(true)
{
char buffer[1024];
ssize_t s =read(fd_arr[0],buffer,sizeof(buffer)-1);
if(s>0)
{
buffer[s]=0;
cout<<buffer<<endl;
break;//读端被关闭了
}
if(s==0)
{
cout<<"read的返回值:"<<s<<endl;
cout<<"读取数据结束写端已经关闭"<<endl;
break;//进程结束之后会自动关闭对应的文件描述符
}
cout<<"#"<<buffer<<endl;
}
这段代码的运行结果如下:
这里可以看到子进程返回了一个异常码,异常码的值为13,那么这里我们可以通过kill -l指令查看一下13对应的是什么异常,那这里执行的结果就如下:
可以看到13对应的异常是跟管道有关的异常,那么这里也就验证了我们上面说的,当我们关闭了读端的时候,写端也会被操作系统通过13号异常进行终止。
管道的特征
1.管道的生命周期随进程。
2.管道可以用来进行具有血缘关系的进程之间进行通信,常用于父子通信。
3.管道是面向字节流的(网络)
4.半双工---单向通信(属于半双工的特殊概念)
5.互斥与同步,上面将的管道的4个特点就是管道的互斥和同步,之所以这么做事为了对共享资源进行保护的方案
如何理解指令上的管道
这里就一个简单的例子cat file | grep xxxx
命令行在执行这个指令的时候会将管道作为分界线,cat file变成一个进程grep xxxx变成一个进程,管道就相当于我们上面程序的匿名管道,左边的cat指令就是匿名管道的写端将数据全部写入到匿名管道里面,管道的右边grep就相当于匿名管道的读端读取管道里面的数据,并对这些数据执行一些特定的操作, 那么这就是本篇文章的全部内容希望大家能够理解。