进程具有独立性,但是进程并不孤僻,进程和进程之间是需要进行相互协作来共同完成某种目的的,而要让进程相互协作,就必须要让进程之间的信息传递起来,比如现在有A进程和B进程,想要让A进程把数据传输到B进程,以数据传输为目的,所以进程之间的通信就可以传输数据为目的实现数据将一个传递给另外一个,实现资源的共享
进程之间的通信是需要借助一些工具的,并不是简单的把进程a拷贝到进程b,或是b拷贝到a,这样就会破坏进程的独立性,进程的通信本质是让不同的进程看到同一份资源,这就是进程通信的本质
管道是进程通信中非常经典的通信方式
这个管道从名字就能看出,它是没有名字的,那么管道是如何实现的呢?原理是什么呢?
先画下面这个图,来方便解释
第一个是文件的属性,例如文件的权限,读写位置,其他字段等等,但是现在想要说明的内容是,当一个文件打开后,会存在一个属性集,叫做inode,那么在对应的Linux内核中,在讲磁盘文件的时候讲到一个文件对应了多个具体的inode属性,指的就是除了文件结构体外的其他属性,而最终会把文件的属性都加载到内存中,这样未来在需要读取文件的时候就不需要去磁盘读取数据,而是可以直接获取对应的信息,例如文件占据的数据库等等
第二个叫做文件对象的方法集,例如创建打开删除等等
第三个是文件的缓冲区,每一个文件都会有自己的文件缓冲区,这个缓冲区被寻找的方式就是通过文件结构体来找到对应的文件的属性,都能找到
最终当文件被打开到进程内部的时候,作为用户层写了一些数据到文件中,这样的命令发出后,会把这些字符串的信息放到对应的缓冲区中,放到缓冲区之后可以通过系统调用写到文件里面,write系统调用参数中的文件描述符就会根据文件描述符索引到对应的表中,然后把我们写的方法和数据从用户层拷贝到缓冲区中,再用方法集中的写方法,把数据刷新到对应的外设信息中
假设现在创建了一个子进程,那么对应的信息需要拷贝吗?答案是未必,因为存在写时拷贝的内容,所以父进程和子进程是可以共享一部分内容的,但是对于进程的PCB,包括struct files_struct这样的内容是需要进行拷贝的,而指针的指向不会发生改变,因此导致的效果就是,在不同的进程上输出对应的信息都会输出在用户的显示器上,就是因为在这样的结构体中存储的文件描述符的指向没有发生改变,是一种浅拷贝
所以上述其实就是前面所说的,进程通信的本质是让不同的进程看到同一份资源,那么上述的文件结构体其实就是这个道理,未来当需要进行调用的时候,父进程就可以通过文件的方式,向自己的缓冲区中写入信息,子进程就可以通过文件描述符读取信息,这样就是进程通信的原理
因此,如果想要让进程a给进程b提供消息,那么如果要通过数据写到内存中再拷贝到磁盘上,再读取消息,这样的效率比较低,只能保证文件是一个内存级别的文件,在磁盘中不需要存在,也没有存在的意义,作为操作系统也不关注名字,只是需要保证父进程和子进程可以看到就可以,而是让数据不刷新到磁盘上,只是内存级别的让父进程把内容交给子进程,这样的文件就叫做管道文件,管道文件是一个纯内存级别的文件,不需要像磁盘一样刷新,而这样的文件也不需要名字,所以叫做匿名管道
管道文件有一个特点,叫做实现了资源共享后只允许单向通信,这是可以理解的,父进程像缓冲区中写消息,子进程也写消息,消息都写到同一个缓冲区中,这样在读取的时候必定会很复杂,哪一部分是我写给它,哪一部分是它写给我,在无形中增加了成本,所以就设计了这样的单向通信的特点
管道文件的接口
对于管道文件的接口来说, 使用的是pipe接口,后面在使用的时候会对其进行详细的讲解等
管道的实现原理
对于管道的实现原理,这里先说理论,后续的代码操作来进行更进一步的分析,对于原理来说,管道的工作原理就是,以读的方式打开一个文件,再以写的方式打开一个文件,最后再fork一个子进程,这样子进程会和父进程一样指向这个文件,此时再根据需求,让父进程和子进程分别断开一个读或者写的方式,最终形成的效果是,父进程只能读或者写掌握其中一种方式,子进程也只能写或者读其中一种方式,而这个文件本身就被叫做管道,这也进而说明了管道确确实实是单向传递的,用下面的图来展示具体的工作原理:
这个图展示的是初态,以读和写的方式分别打开了一个文件,创建了两个对应的文件结构体,有两个独立的文件描述符,只不过指向的是一个内容,这里是很好理解的,那么继续下一步
下一步是创建了子进程,此时子进程的指向和父进程是相同的,它们会有各自的文件描述符结构体对象,但是管理的是同一个文件结构体,这个文件结构体又会管理同一份文件
那么在这一步,就已经基本上具有了管道的性质,也就是说存在了一个父进程向某个地方输入,子进程从某个地方读出,相当于是有了一个父子进程都能看到的资源和对象,这其实就是管道,但是这还不够,管道是需要遵循固定的设计的,所以还得继续转变
到了这一步,就已经设计好了管道,父进程只能读,子进程只能写,当然这里只是设计模式是这样设计的,反过来也是可以的
那么至此,对于管道来说已经完成了它自己的需求,下面讨论的是对于接口的设置问题,如何设置对应的fd呢?
文件接口传入的是一个数组,这个数组也是一个输出型参数,其中数组的元素是2,那么就有下标为0和下标为1两种文件,其中下标为0对应的是读文件,下标为1对应的是写文件,那么下面根据代码实操,具体来了解管道的情况进而理解出管道的性质
// 管道没有数据,读端进行等待
int test1()
{
// 1. 建立管道
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n == 0);
cout << "pipefd[0]: " << pipefd[0] << " "
<< "pipefd[1]: " << pipefd[1] << endl;
// 2. 创建子进程
pid_t id = fork();
if (id < 0)
{
perror("fork fail\n");
return 1;
}
// 3. 父子进程关闭通道,形成管道,其中子进程负责写,父进程负责读
if (id == 0)
{
// 子进程
close(pipefd[0]);
// 向管道中输入信息
int count = 0;
while (true)
{
char message[MAX];
// 向数组中写入信息
snprintf(message, sizeof(message), "i am child, my pid: %d, count: %d", getpid(), count);
write(pipefd[1], message, strlen(message));
count++;
sleep(1);
}
exit(0);
}
else
{
// 父进程
// 父进程负责从管道中读取信息,打印出来
close(pipefd[1]);
// 从管道中读取信息
char buffer[MAX];
while (true)
{
ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
cout << "father pid:" << getpid() << " child say:" << buffer << endl;
}
}
}
}
从上述的代码中可以看出,如果管道内没有数据,就意味着读端必须要等待,直到管道中存在数据,换句话说,最终形成的效果是,子进程写一部分内容,父进程读一部分内容,子进程写父进程读,整体上来讲就体现出了非常强烈的顺序性,这背后反映出的是管道要给读写端提供同步机制,所谓同步,就是父进程和子进程两个进程执行的时候需要具有一定的顺序性
// 管道被写满了,写端必须等待,直到有空间为止
int test2()
{
// 1. 建立管道
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n == 0);
cout << "pipefd[0]: " << pipefd[0] << " "
<< "pipefd[1]: " << pipefd[1] << endl;
// 2. 创建子进程
pid_t id = fork();
if (id < 0)
{
perror("fork fail\n");
return 1;
}
// 3. 父子进程关闭通道,形成管道,其中子进程负责写,父进程负责读
if (id == 0)
{
// 子进程
close(pipefd[0]);
// 向管道中输入信息
int count = 0;
while (true)
{
char message[MAX];
// 向数组中写入信息
snprintf(message, sizeof(message), "i am child, my pid: %d, count: %d", getpid(), count);
write(pipefd[1], message, strlen(message));
count++;
}
exit(0);
}
else
{
// 父进程
// 父进程负责从管道中读取信息,打印出来
close(pipefd[1]);
// 从管道中读取信息
char buffer[MAX];
while (true)
{
sleep(5);
ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
cout << "father pid:" << getpid() << " child say:" << buffer << endl;
}
}
}
}
从上图中可能无法看出具体含义,但是稍加解释如下:
父进程每隔五秒会读取一次,一次读取的就是这么一批内容,这说明的现象就是,父进程一次读一批,但是这一批又是连起来的,说明子进程在这个时间内没有往管道内写信息
父进程由于在特殊的设计之下,父进程没有读取,所以子进程在运行起来之后,一瞬间就会把管道写满,写满之后子进程开始等待,只有父进程把管道里面的东西读取完毕后,子进程才能重新进行写入,从这当中可以看出的是,如果数据很多的情况下,我一次性其实就可以读取到很多很多的信息,这个特征叫做匿名管道是面向字节流的,简单来说就是,父进程并不会因为子进程曾经是用什么样的顺序写的,而是会采取直接全部读取的方式来读取,并不关心数据底层内部的格式,这样的特征就叫做字节流
// 写端关闭,读端一直读取,读端读到返回值为0
int test3()
{
// 1. 建立管道
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n == 0);
cout << "pipefd[0]: " << pipefd[0] << " "
<< "pipefd[1]: " << pipefd[1] << endl;
// 2. 创建子进程
pid_t id = fork();
if (id < 0)
{
perror("fork fail\n");
return 1;
}
// 3. 父子进程关闭通道,形成管道,其中子进程负责写,父进程负责读
if (id == 0)
{
// 子进程
close(pipefd[0]);
// 向管道中输入信息
int count = 0;
while (count < 5)
{
char message[MAX];
// 向数组中写入信息
snprintf(message, sizeof(message), "i am child, my pid: %d, count: %d", getpid(), count);
write(pipefd[1], message, strlen(message));
count++;
}
cout << "child quit" << endl;
close(pipefd[1]);
exit(0);
}
else
{
// 父进程
// 父进程负责从管道中读取信息,打印出来
close(pipefd[1]);
// 从管道中读取信息
char buffer[MAX];
while (true)
{
sleep(5);
ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
cout << "father pid:" << getpid() << " child say:" << buffer << endl;
}
else if(n == 0)
{
cout << "child quit, father quit" << endl;
break;
}
}
}
pid_t rid = waitpid(id, nullptr, 0);
if(rid == id)
cout << "wait success" << endl;
return 0;
}
从上述的代码现象中可以看出的是,当子进程退出后,父进程就会从read函数中读取到0作为返回值,也就随之退出了,因此得出结论是,如果直接关闭写端,而读端就会读取到0作为返回值,此时就叫做读取到了文件的结尾,既然读取到了文件的结尾,那么也就没有继续读取的意义了,那么就会也退出,之后父进程回收子进程的相关信息,两个进程就都退出了
而默认情况下,如果没有数据可以读取的时候,父进程的read是会以阻塞的状态等待着子进程,而当子进程退出后,父进程阻塞都不阻塞了,直接就返回0退出了,只要进程退出了,它所对应的文件描述符就会随着进程的退出而被自动释放,这说明,当进程退出后,文件描述符也会随之而关闭,而如果父子进程都退出了,那么管道文件的对象内的引用计数器也会随之变成零,那么整个管道就释放掉了
基于上述原因,可以得出的结论是,管道的生命周期是和进程挂钩的,当进程退出后,管道的生命周期也就停止了
改造现在的程序如下:
// 读端关闭,写端一直写入,写端会被操作系统终止
int test4()
{
// 1. 建立管道
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n == 0);
cout << "pipefd[0]: " << pipefd[0] << " "
<< "pipefd[1]: " << pipefd[1] << endl;
// 2. 创建子进程
pid_t id = fork();
if (id < 0)
{
perror("fork fail\n");
return 1;
}
// 3. 父子进程关闭通道,形成管道,其中子进程负责写,父进程负责读
if (id == 0)
{
// 子进程
close(pipefd[0]);
// 向管道中输入信息
int count = 0;
while (true)
{
char message[MAX];
// 向数组中写入信息
snprintf(message, sizeof(message), "i am child, my pid: %d, count: %d", getpid(), count);
write(pipefd[1], message, strlen(message));
count++;
}
cout << "child quit" << endl;
// close(pipefd[1]);
exit(0);
}
else
{
// 父进程
// 父进程负责从管道中读取信息,打印出来
close(pipefd[1]);
// 从管道中读取信息
char buffer[MAX];
int cnt = 3;
while (cnt--)
{
ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
cout << "father pid:" << getpid() << " child say:" << buffer << endl;
}
else if (n == 0)
{
cout << "child quit, father quit" << endl;
break;
}
sleep(1);
}
close(pipefd[0]);
sleep(5);
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid == id)
cout << "wait success" << "exit code is:" << (status & 0x7F) << endl;
return 0;
}
对进程进行监控,如下图所示,可以看出,子进程被现在退出,但是没人回收资源,所以子进程现在进入了僵尸状态
从退出码表中可以看到,13对应的信号正式SIGPIPE,那么这可以看出是一个什么结论呢?
对于操作系统来说,操作系统不会浪费任何空间,因此对于上述场景来说,读端都退出了,写端的写入自然是没有意义的,那么这个过程就是在浪费空间,管道本身就占据着缓冲区的资源,而还要去消耗CPU的资源去做拷贝工作,这样的行为是没有意义的,因此操作系统会直接做出杀掉写端进程的操作,从对于进程的监控也能看出,子进程直接异常退出了,并且从退出码也能看出,这个进程是收到了13号信号,所以才退出的
下面用上述的这些理论来解释几个管道的应用场景
sleep 10000 | sleep 20000 | sleep 30000
监视进程可以看到:
这个命令本身没有什么意义,但是对于理解管道是有帮助的,假设现在有这样的一条命令,随着进程的启动,bash创建了这三个进程,从PID和PPID可以看出确实是这样,那么它们三个之间就是典型的兄弟关系,上述现象说明,如果现在要自己实现这样类似于命令行一样的管道,那么就要对整个命令做遍历,凡是遇到这样的管道就统计,最后可以统计出有多少个管道,管道隔离了多少个区间,就会有多少个进程,这样就能在执行命令之前,就提前开辟好需要的这些管道,对应的文件描述符数组也都可以提前准备好,申请好之后,管道会进行连续的for循环,创建出很多个子进程,每一个子进程执行的是提取的每一个不同的子命令,之后不同的进程之间就会形成特定的读写端,形成一个链式的模样,一个程序执行之后写到管道中,另外一个管道读取了信息后再写入它负责的那个管道中,也就是说要做的是重定向
理解了这个原理后,再解释一个比较常用的命令:
who | wc -l
wc命令是用来查看指定文件的行数列数字节数信息的一个命令,一般而言的使用如下:
对应的可以查看到目标文件的各个信息,那么如果带上管道,对应的效果就可以理解成,who命令本身调用会打印出当前设备正在使用的人的信息,而打印的这些信息作为标准输入传递到了wc命令,wc命令接受到who命令的信息就会处理信息,进而打印出对应的信息,用下面的图来展示:
最终形成的效果是,who命令本来是要输出信息输出到显示器上的,但是现在由于发生了重定向,所以输出的信息就被输出到了管道文件中,wc -l命令本身是要从标准输入中获取信息的,但是现在由于发生了重定向,所以它获取信息的地点发生了变化,它从管道文件中获取了信息,获取的内容正是刚才who命令输出的结果,于是解析who命令的输出结果,最后再输出到显示器上,输出到显示器的原因也是因为没有发生重定向,如果再继续增添重定向,那么输出的信息就又被输出到了另外的地方