什么是管道?
我们通过前面学到的进程方面的知识和文件系统方面的知识都知道,一个进程PCB会有自己指向的文件描述符表,这些文件描述符表中的数组下标0,1,2都是对应着键盘,显示器,显示器这样的设备,然后数组下标为3的指向的是新的文件,而每一个文件都有自己对应的文件页缓冲区,一个新文件都有自己的内容和属性,我们如果把一个文件打开了,首先要有这个文件的属性集inode,第一个是这个新文件对象会指向struct inode,第二个这个新文件对象也会指向自己对应的方法集,第三个则是执行自己的文件页缓冲区,当然inode也会指向自己对应的缓冲区,在上层今天你作为一个用户,你定义了一个const char* 的字符串放在了对应的缓冲区里,你把消息通过write通过系统调用把缓冲区中的字符串通过对应的进程,通过该进程PCB指向的文件描述符表找到对应的新文件,比如说给上层用户返回的文件描述符fd是3,那么就可以通过fd找到指定的文件,然后把用户缓冲区层的字符串刷新拷贝到对应的文件页缓冲区,然后再使用方法集中的写方法,把文件页缓冲区中的内容刷新到对应的外设磁盘当中,所以数据就存在了磁盘当中。如果今天该进程作为父进程创建了新的子进程,我们都知道子进程中的task_struct 中的大部分内容都是从父进程那里来的,除了PID,PPID等这些内容以外,所以子进程会将父进程管理的文件描述符表struct files_struct拷贝一份,然后由于我们这一部分都是进程创建,进程管理方面的,但是文件及其对应的0,1,2对应的键盘,显示器,显示器这些都是文件管理部分,所以这些都不会被拷贝一份,所以父子进程指向的文件描述符表中的文件都是同一个,但是文件描述符表中的内容是被拷贝了的。那么父子进程都printf都会向显示器打印由于他们指向的是同一个显示器,然后要向同一个显示器进行写入,所以我们会看到有些内容会被打印两遍,0,1,2这些都是特殊的文件,关键在于我们的父进程打开了一个普通文件,那么我们下面就对这个普通文件作为重点考虑对象,由于进程是独立的,所以父进程与子进程是并列的关系,所以我们就让不同的进程看到了同一份资源。所以未来要想做到进程间通信,我们只需要让父进程通过将上层缓冲区的内容拷贝到这个普通文件的文件缓冲区,也就是对文件进行写入,此时我们的子进程就可以通过它的文件描述符表找到这个文件,然后去读这个文件中的内容。这不就完成了进程间的通信吗?
如果我们的父进程打开一个文件是一个普通文件,那么我们就必须把我们所对应的文件,因为有对应磁盘上的路径,那么就会刷新到磁盘上,但是如果我们今天进程间通信还要将我们文件中的数据写到磁盘上,那么效率就太低了,所以为了让效率不那么低,对这类文件就不再进行写入到磁盘,它是存内存级别的文件,我们把这种文件就称之为管道文件。
管道文件实现了之后有一个特点:只允许单向通信。
关于这个特点我们提出两个问题:
1.为什么只允许单向通信,为什么不允许双向通信?
其实原因非常的简单,就是工程师在实现的时候,这部分会实现的比较简单一些。因为如果是双向通信,那么父子进程都能同时对同一个管道文件上进行写入,那么这样实现的话又要进行区分,想必实现下去肯定会很复杂,而如果只允许单向通信的话,两个进程之间,只允许一个进程写入,允许另一个进程读,如果另一个进程要进行写,那么相对应的进程就允许读,所以这样实现的话就不用对写入的内容进行区分,这样实现起来会很简单。我们也可以通过建立两个管道来进行互相对文件读写达到进程间通信。
2.为什么将这个称之为管道文件?
主要是因为这种通信风格特别像我们平时生活中所说的管道,比如说A点像B点进行单向通信比较符合管道的特征,所以给它取名字叫管道,而不是说因为它是管道,所以我们故意把它设计成这个样子的,所以是因为特征才命名为管道的。
- 管道是Unix中最古老的进程间通信的形式。
- 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
站在文件描述符角度-深度理解管道
第1步
我们都清楚fd是文件描述符,图中那个像数组一样的方块就是一个文件描述符表,0,1,2分别对应着键盘,显示器,显示器。
第2步
然后父进程以读和写的方式同时将同一个文件进行打开,然后父进程fork出子进程,fork出子进程之后,直接拷贝父进程的文件描述符表,那么此时子进程的文件描述符表中也指向了同一文件的读端和写端,也就相当于子进程也以读写的方式打开了一次该文件。
第3步
父进程关闭fd[0],子进程关闭fd[1],说白了就是父进程留下来了写端,子进程留下来了读端。至此我们就形成了单向通信的管道通路。
下面我们继续来了解一下这样做的原理:
为什么我们的父进程一开始就要打开读写端将文件以读写的方式打开两次呢?因为我们假设如果我们只以写的方式将该文件进行打开的话,当父进程进行fork的时候子进程也只能以写的方式打开该文件,那么这样就形成不了我们前面谈到的只允许管道文件单向通信了。以读的方式打开也同上。但是如果我们父进程同时以读写的方式将文件打开两次,那么父进程fork的时候子进程也以读写的方式将文件打开。那么就有了双向的通信了,但是为了保证只允许管道文件单向通信,我们父进程会将读端关闭,子进程将写端关闭,那么不久构建出来一条单向通信方式了吗?
其实呢,相比上面的图还需要描述的更加清晰的话,我们可以看下图:
文件cnt的引用计数会记录以读或者写方式指向该文件的指针的数量。如果想要将其关闭,要等cnt减到0为止,由于要实现进程管理与文件管理解耦,所以当父进程或者子进程进行关闭文件时要cnt--,让父进程或者子进程认为关闭了,但是文件到底关闭不关闭取决于cnt是否为0.所以这样就做到了进程管理与文件管理互不影响。这才是更详细的创建管道的过程。
匿名管道
#include <unistd.h>
功能:创建一无名管道
原型
int pipe(int fd[2]);
参数
fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
返回值:成功返回0,失败返回错误代码
所以通过上面的理解,我们可以知道这个fd应该是一个输出型参数,用于返回读写段对应的文件描述符。所以下面我们经过上面的理论描述之后,我们用代码来进行测验
我们可以看一下pipe这个接口的手册:
下面我们在vscode上先创建两个文件:Makefile mypipe.cc
写这样一段代码来进行测试。
然后通过在xshell上面编译运行:
这是我们的第一步,就是建立管道。下面是测试代码:
#include<iostream>
#include<cassert>
#include<unistd.h>
#include<cstring>
#include<sys/types.h>
#include<sys/wait.h>
#define MAX 1024
using namespace std;
int main()
{
//第1步,创建管道
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n==0);
(void)n;//防止编译器警告,意料之中用assert,意料之外用if
cout<<"pipefd[0]:"<<pipefd[0]<<",pipefd[1]:"<<pipefd[1]<<endl;
//第2步,创建子进程
pid_t id = fork();
if(id<0)
{
perror("error");
return 1;
}
//子写,父读
//第3步,父子关闭不需要的fd,形成单向通信的管道
if(id==0)
{
//child
close(pipefd[0]);
//只向管道写入,没有打印
int cnt = 10;
while(cnt)
{
char message[MAX];
snprintf(message,sizeof(message),"hello father,I am a child,pid: %d, cnt: %d",getpid(),cnt--);
//子写
write(pipefd[1],message,strlen(message));
sleep(1);
}
exit(0);
}
//father
close(pipefd[1]);
//父读
char buffer[MAX];
while(true)
{
ssize_t n = read(pipefd[0],buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n] = 0; //'\0',当作字符串
cout<<getpid()<<","<<"child say:"<<buffer<<"to me!"<<endl;
}
}
pid_t rid = waitpid(id,nullptr,0);
if(rid == id)
{
cout<<"wait success!"<<endl;
}
return 0;
}
运行结果:
上述用到的接口手册:
snprintf:
write:
read:
waitpid
那么上述代码的父子进程有没有完成通信呢?看到运行结果肯定是完成了,很明显子进程写入的数据交给了父进程,但是我们的父子进程代码是共享的,数据是写时拷贝的,但是如果父子进程不写时拷贝,也就是说进程都不对数据进行写入,那么我们父进程可以把数据以继承的方式交给子进程,但是我们没有办法把变化的数据交给子进程,但是这种情况是以进程创建的时候给一下,但是如果是改变的数据是不可能以父进程与进程共享数据的方式来进行传递的,而这种数据是要通过管道的方式来进行传递,所以这就叫做管道通信。
下面我们来谈管道的4种情况和管道的5种特性:
a.管道的4种情况:
1.正常情况,如果管道没有数据了,读端必须等待,直到有数据为止
上述代码中我们只让子进程在while循环中休眠了1s,而父进程没有休眠,那么如果我们其他的代码不变,让子进程休眠100s,我们观察一下现象:
运行结果:
监视窗口:
也就是说父进程要等子进程在管道中去写,然后写了之后再读,如果子进程不进行写入,管道中没有内容,那么父进程也不会再进行读取了,那么最终的效果就是子进程写一个,父进程读一个,那么就会造成父子进程有很强烈的先后顺序。
2.正常情况,如果管道被写满了,写端必须等待,直到有空间为止(读端读走数据)
如果我们今天写端一直写,让读端休眠200s,那么会发生什么呢?
我们把代码进行部分修改:
我们下面来进行测试:
我们发现写到一定程度就不会再写了,卡在这了。写端这里一直是死循环,既然他会停下来,那就说明了,管道是有大小的,管道文件是有上限的,这里其实也有同步的特点。
3.写端关闭,读端一直读取,读端会读到read返回值为0,表示读到文件结尾。
下面我们用这段代码来进行测试:
#include<iostream>
#include<cassert>
#include<unistd.h>
#include<cstring>
#include<sys/types.h>
#include<sys/wait.h>
#define MAX 1024
using namespace std;
int main()
{
//第1步,创建管道
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n==0);
(void)n;//防止编译器警告,意料之中用assert,意料之外用if
cout<<"pipefd[0]:"<<pipefd[0]<<",pipefd[1]:"<<pipefd[1]<<endl;
//第2步,创建子进程
pid_t id = fork();
if(id<0)
{
perror("error");
return 1;
}
//子写,父读
//第3步,父子关闭不需要的fd,形成单向通信的管道
if(id==0)
{
//if(fork()>0) exit(0);
//child
close(pipefd[0]);
//只向管道写入,没有打印
int cnt = 0;
while(true)
{
// char c = 'a';
// write(pipefd[1],&c,1);
// cnt++;
// cout<<"write..."<<cnt<<endl;
char message[MAX];
snprintf(message,sizeof(message),"hello father,I am a child,pid: %d, cnt: %d",getpid(),cnt);
//子写
cnt++;
write(pipefd[1],message,strlen(message));
sleep(1);
//cout<<"write..."<<cnt<<endl;
if(cnt>3) break;
}
cout<<"child close w piont"<<endl;
close(pipefd[1]);
exit(0);
}
//father
close(pipefd[1]);
//父读
char buffer[MAX];
while(true)
{
//sleep(2000);
ssize_t n = read(pipefd[0],buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n] = 0; //'\0',当作字符串
cout<<getpid()<<","<<"child say:"<<buffer<<" to me!"<<endl;
}
else if(n==0)
{
cout<<"child quit ,me too!"<<endl;
break;
}
sleep(1);
cout<<"father return val(n):"<<n<<endl;
}
pid_t rid = waitpid(id,nullptr,0);
if(rid == id)
{
cout<<"wait success!"<<endl;
}
return 0;
}
运行结果:
同时说明了:管道的生命周期是随进程的
下面我们用这段代码来进行测试:
#include<iostream>
#include<cassert>
#include<unistd.h>
#include<cstring>
#include<sys/types.h>
#include<sys/wait.h>
#define MAX 1024
using namespace std;
int main()
{
//第1步,创建管道
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n==0);
(void)n;//防止编译器警告,意料之中用assert,意料之外用if
cout<<"pipefd[0]:"<<pipefd[0]<<",pipefd[1]:"<<pipefd[1]<<endl;
//第2步,创建子进程
pid_t id = fork();
if(id<0)
{
perror("error");
return 1;
}
//子写,父读
//第3步,父子关闭不需要的fd,形成单向通信的管道
if(id==0)
{
//if(fork()>0) exit(0);
//child
close(pipefd[0]);
//只向管道写入,没有打印
int cnt = 0;
while(true)
{
char c = 'a';
write(pipefd[1],&c,1);
cnt++;
cout<<"write..."<<cnt<<endl;
// char message[MAX];
// snprintf(message,sizeof(message),"hello father,I am a child,pid: %d, cnt: %d",getpid(),cnt--);
// //子写
// write(pipefd[1],message,strlen(message));
// cout<<"write..."<<cnt<<endl;
}
exit(0);
}
//father
close(pipefd[1]);
//父读
char buffer[MAX];
while(true)
{
sleep(2000);
ssize_t n = read(pipefd[0],buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n] = 0; //'\0',当作字符串
cout<<getpid()<<","<<"child say:"<<buffer<<" to me!"<<endl;
}
}
pid_t rid = waitpid(id,nullptr,0);
if(rid == id)
{
cout<<"wait success!"<<endl;
}
return 0;
}
这段代码的功能相当于就是让读功能暂时不读数据,然后一直写下去,cnt是用来计数的
运行之后:
我们发现是写到65536,我们用计算器来看:
用二进制来表示是:00010000000000000000
不同的系统和不同版本的内核的管道大小是不一样的。
用这个命令可以查看管道大小:
4.读端关闭,写端一直写入,操作系统会直接杀掉写端进程,通过向目标进程发送SIGPIPE(13)信号,终止目标进程.
下面我们用这段代码来进行测试:
#include <iostream>
#include <cassert>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define MAX 1024
using namespace std;
int main()
{
// 第1步,建立管道
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n == 0);
(void)n; // 防止编译器告警,意料之中,用assert,意料之外,用if
cout << "pipefd[0]: " << pipefd[0] << ", pipefd[1]: " << pipefd[1] << endl;
// 第2步,创建子进程
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
// 子写,父读
// 第3步,父子关闭不需要的fd,形成单向通信的管道
if (id == 0)
{
// if(fork() > 0) exit(0); //
// child
close(pipefd[0]);
// w - 只向管道写入,没有打印
int cnt = 0;
while(true)
{
// char c = 'a';
// write(pipefd[1], &c, 1);
// cnt++;
// cout << "write ....: " << cnt << endl; // 课堂上我们的机器的pipe空间大小是64KB
char message[MAX];
snprintf(message, sizeof(message), "hello father, I am child, pid: %d, cnt: %d", getpid(), cnt);
cnt++;
write(pipefd[1], message, strlen(message));
sleep(1);
// if(cnt > 3) break;
}
cout << "child close w piont" << endl;
// close(pipefd[1]);
exit(0);
}
// 父进程
close(pipefd[1]);
// r
char buffer[MAX];
while(true)
{
// sleep(2000);
ssize_t n = read(pipefd[0], buffer, sizeof(buffer)-1);
if(n > 0)
{
buffer[n] = 0; // '\0', 当做字符串
cout << getpid() << ", " << "child say: " << buffer << " to me!" << endl;
}
else if(n == 0)
{
cout << "child quit, me too !" << endl;
break;
}
cout << "father return val(n): " << n << endl;
sleep(1);
break;
}
cout << "read point close"<< endl;
close(pipefd[0]);
sleep(5);
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid == id)
{
cout << "wait success, child exit sig: " << (status&0x7F) << endl;
}
return 0;
}
运行结果:
我们发现最后收到的确实是13号信号。
b.管道的5种特性:
1.匿名管道可以允许具有血缘关系的进程之间进行进程间通信,常用于父子,仅限于此。
如果我们在子进程那里继续fork,然后让子进程直接退出,那么这样写去就可以让爷孙两进程进行互相通信
也就是说只要在一个继承体系当中,只要能把文件描述符继承下去,只要能把文件对象继承下去,那么我们照样可以用管道来进行通信。
2.匿名管道默认要给读写端提供同步机制
我们下面把代码修改成这样:
#include<iostream>
#include<cassert>
#include<unistd.h>
#include<cstring>
#include<sys/types.h>
#include<sys/wait.h>
#define MAX 1024
using namespace std;
int main()
{
//第1步,创建管道
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n==0);
(void)n;//防止编译器警告,意料之中用assert,意料之外用if
cout<<"pipefd[0]:"<<pipefd[0]<<",pipefd[1]:"<<pipefd[1]<<endl;
//第2步,创建子进程
pid_t id = fork();
if(id<0)
{
perror("error");
return 1;
}
//子写,父读
//第3步,父子关闭不需要的fd,形成单向通信的管道
if(id==0)
{
//if(fork()>0) exit(0);
//child
close(pipefd[0]);
//只向管道写入,没有打印
int cnt = 1000000;
while(true)
{
char message[MAX];
snprintf(message,sizeof(message),"hello father,I am a child,pid: %d, cnt: %d",getpid(),cnt--);
//子写
write(pipefd[1],message,strlen(message));
cout<<"write..."<<endl;
}
exit(0);
}
//father
close(pipefd[1]);
//父读
char buffer[MAX];
while(true)
{
sleep(2);
ssize_t n = read(pipefd[0],buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n] = 0; //'\0',当作字符串
cout<<getpid()<<","<<"child say:"<<buffer<<" to me!"<<endl;
}
}
pid_t rid = waitpid(id,nullptr,0);
if(rid == id)
{
cout<<"wait success!"<<endl;
}
return 0;
}
然后运行结果:
我们可以看到先写满,然后再把整个内容读出来,然后又写,这里可能写的时候是一行一行写的,可能写了很多次才把缓冲区写满,但是读的话可能没有什么限制,能读多少就尽量的多读,将缓冲区一次性读完也没问题,也就是说怎么写和怎么读并没有关系,虽然读写有一定的顺序关系,所以我们就说:匿名管道是面向字节流的。
3.匿名管道是面向字节流的
4.管道的生命周期是随进程的
5.管道是单向通信的,半双工通信的一种特殊情况。