Linux操作系统之进程间通信:管道概念
目录
前言:
一、进程间通信概念
二、什么是管道呢?
三、匿名管道
四、匿名管道的使用
总结:
前言:
本篇文章将会为大家带来进程间通信的基础概念,随后为大家大致了解一下我们古老的进程间通信的方法——管道。
希望通过本篇文章,能够帮助大家掌握进程间通信的基本概念,以及掌握匿名命名管道原理操作!
一、进程间通信概念
我们曾经说过,进程具有独立性,操作系统中的每个进程都拥有自己独立的运行环境和资源,彼此之间相互隔离,互不干扰的特性。这是现代操作系统设计中的一个基本原则。
但是,现代计算机不可能独立完成一项任务,进程与进程之间,也必须要出现交流。
那么如何进行这样的通信呢?我们比如面临着一个前提:得让不同的进程,看到同一份资源。
操作系统提供了打破这种独立性的机制:进程间通信(IPC)机制。
为什么要提供这种机制呢?因为我们面临以下问题:
:数据传输:一个进程将它的数据发送给另一个进程
:资源共享:多个进程之间需要共享一些资源
:进程控制:有些进程希望完全控制另外一个进程的执行(比如说debug进程),此时控制进程希望拦截另外一个进程的所有陷入和异常,并能够及时知道它的状态改变。
所以我们需要设计一套通信的接口,调用系统调用,设定接口标准
于是进程间通信通过时代的发展,主要形成以下三种形式:
其中前面两个可以归纳到本地通信的范畴,就是在同一台主机下,同一个操作系统中的不同进程之间实现通信。
我们可以把这三个具体划分为以下内容:
二、什么是管道呢?
管道属于linux系统比较古老的通信方式,最早可以溯源到unix操作系统上。我们把从一个进程连接到另外一个进程上的一个数据流,称为一个管道。、
在linux系统上我们通常通过 | 这个符号来形成两个进程之间的管道:
我们可以看到,我们刚才执行的两个sleep命令变成了两个进程,并且,这两个进程的关系是兄弟进程。(这里的&是让命令后台执行)。他们的本质其实就是进程间通信。

我们之前讲进程创建的时候说过,创建子进程,会把父进程的task_struct复制一份,也就是说,二者的文件描述符表也会被复制。那么父进程打开文件的struct file与文件的inode结构体与这个文件的内核级缓冲区呢?
也会被复制吗?
:只有struct file会被拷贝一份,但是新的struct file所指向的inode与文件缓冲区是一样的指针,也就是说,更靠近文件层面的inode与缓冲区是共享的,不会被复制,而靠近进程方面的struct file等结构体会被子进程拷贝一份。
为什么inode不会被子进程拷贝呢?
:inode 是文件系统层面的数据结构,存储文件的元信息(权限、大小、磁盘块位置等),而不是进程级别的数据。所有进程访问同一个文件时,共享同一个 inode,只是通过各自的 文件描述符来引用它。
为什么缓冲区不会被子进程拷贝呢?
:因为这个内核级缓冲区,是由操作系统提供并管理的,该文件的内核缓冲区由所有打开此文件的进程共用,不属于单个进程。文件缓冲区虽然被进程打开,但是他的整个资源管理属于操作系统,这也就是为什么我们把进程关掉了,文件需要的时候也会自动释放的根本原因。
那么同学们有没有想过一个问题,父子进程,算不算看到了同一份数据呢?
答案肯定是算的。所以父子进程就可以实现我们进程间的通信。
在进程间通信中,当我们将数据放在内核管理的缓冲区而不需要持久化到磁盘时,这种机制本质上创建了一个纯内存的通信通道。操作系统通过专门的数据结构(如环形缓冲区)和特定的系统调用接口(pipe()等)对这种通信方式进行封装和优化,最终形成了一种高效的进程间通信机制——管道。
三、匿名管道
我们有一个专门的系统调用接口来创建管道:
其中 pipefd是一个⽂件描述符数组,其中fd[0]表⽰读端, fd[1]表示写端,这个数组参数是一个输出型参数,我们需要自己创建一个数组然后把这个传进去。
没错,管道其实是一种单向通信的东西。
一般来说,我们都使用带有血缘关系的进程实现管道通信(尤其是父子,因为天生具备看到同一份文件的条件)
管道只能进行单向通信,并且我们必须先打开,随后创建子进程,利用的就是这个子进程继承父进程资源的特性。
为什么我们要父子进程一个关系写,另外一个就要关闭读呢?这样是防止fd泄露以及误操作。
而这种管道就不需要名字,所以不需要在操作系统下带路径(文件路径解析),我们把这种管道,称为匿名管道。
四、匿名管道的使用
我们有以下程序及其相应的Makefile:
#include<iostream>
#include<unistd.h>
#include<sys/wait.h>int main()
{int fd[2];int ret=pipe(fd);if(ret<0){std::cerr<<"pipe error"<<std::endl;return 1;}int id = fork();//创建进程后,父子进程需要各自关闭一个读写端//我们这里让子进程写,父进程读if(id==0){//子进程::close(fd[0]);int cnt=0;while(1){std::string message="hello world,";message+=std::to_string(getpid());message+=",";message+=std::to_string(cnt++);write(fd[1],message.c_str(),message.size());sleep(1);}exit(0);}else if(id>0){//父进程::close(fd[1]);char buffer[1024];while(1){ssize_t s=read(fd[0],buffer,1024);if(s>0){buffer[s]=0;//将读取到的内容结尾添加'\0',因为字符串以\0结尾时C语言的规定,系统调用write时可没这个规定//write写入的时候是不需要size+1的,我们就没有写入\0,这里需要手动补上std::cout<<"parent read:"<<buffer<<std::endl;}}pid_t pid=waitpid(id,nullptr,0);}return 0;
}
运行之后我们可以看见,这样就实现了一个简单的,父子进程的管道通信。
我们间隔一秒才写入一次,那么这个时候父进程一直在while循环读,没有内容写入时,父进程就处于堵塞状态。
现象1:管道为空&&管道正常,read会阻塞(read本身是系统调用)
因为我们是共享的一份资源,看到的是同一个缓冲区,那么既然是同一份,会不会出现,你读一半时,我就开始写,或者说我写一半时,你就开始读的情况呢?
这个情况就是数据不一致情况。
面对这个问题,管道内部进行了处理,所以才会出现刚刚父进程阻塞的情况(即系统调用read自己会处理)。
那么我们现在改一下代码,每次只输入一个字符,但是不限制写入时间,而读数据确实要等待十秒钟才开始读,我们看看会出现什么情况:
int main()
{int fd[2];int ret=pipe(fd);if(ret<0){std::cerr<<"pipe error"<<std::endl;return 1;}int id = fork();//创建进程后,父子进程需要各自关闭一个读写端//我们这里让子进程写,父进程读if(id==0){//子进程::close(fd[0]);int total = 0;int cnt=0;while(1){std::string message="h\n";total += ::write(fd[1], message.c_str(), message.size());cnt++;std::cout << "total: " << total << std::endl;}exit(0);}else if(id>0){//父进程::close(fd[1]);char buffer[1024];sleep(10);while(1){ssize_t s=read(fd[0],buffer,1024);if(s>0){buffer[s]=0;std::cout<<"parent read:"<<buffer<<std::endl;}}pid_t pid=waitpid(id,nullptr,0);}return 0;
}
我们一次只打印一个字符,居然发现,写入停留在了65536这个数字上。
如果我们把这个数字除以1024,你会发现,这个刚好等于64kb。
也就是说,我们的管道也会有写入上限的。
现象2:管道为满&&管道正常,write会阻塞(write本身是系统调用)
最后我们可以发现,哪怕我们是一个一个写入的,我要读数据,却不一定要一个一个的读。
管道根本不关心写了什么,也不管你写了多少次,只关心要多少个数据。
这个特性:叫做面向字节流
我们再改变一下代码:
使得子进程写入一次数据后,就break退出循环,关闭写端,我们在父进程新增s==0的检测:
int main()
{int fd[2];int ret=pipe(fd);if(ret<0){std::cerr<<"pipe error"<<std::endl;return 1;}int id = fork();//创建进程后,父子进程需要各自关闭一个读写端//我们这里让子进程写,父进程读if(id==0){//子进程::close(fd[0]);int total = 0;int cnt=0;while(1){std::string message="h\n";total += ::write(fd[1], message.c_str(), message.size());cnt++;std::cout << "total: " << total << std::endl;break;//结束循环}exit(0);}else if(id>0){//父进程::close(fd[1]);char buffer[1024];//sleep(10);while(1){ssize_t s=read(fd[0],buffer,1024);if(s>0){buffer[s]=0;std::cout<<"parent read:"<<buffer<<std::endl;}else if(s==0){std::cout<<"child quit"<<std::endl;}sleep(1);}pid_t pid=waitpid(id,nullptr,0);}return 0;
}
我们会发现,当写端关闭时,读段会进入返回值为0的判断语句中:
现象3:写端关闭&&读段正常,读端读到0就表示读到了结尾
那如果反着过来呢?
我们关闭读段只剩下写端:
同学们,操作系统不会做浪费时间浪费空间的事情,而这个情况就是浪费时间与空间的事情,所以操作系统会直接杀掉写端进程。
我们讲进程结束的时候提到,进程结束分为,代码执行完毕,任务完成与不完成,另外一种就是信号异常杀死。
而这个杀进程,就属于异常结束。
我们可以通过waitpid的输出参数status找到子进程退出的信号来验证:
int main()
{int fd[2];int ret=pipe(fd);if(ret<0){std::cerr<<"pipe error"<<std::endl;return 1;}int id = fork();//创建进程后,父子进程需要各自关闭一个读写端//我们这里让子进程写,父进程读if(id==0){//子进程::close(fd[0]);int total = 0;int cnt=0;while(1){std::string message="h\n";total += ::write(fd[1], message.c_str(), message.size());cnt++;std::cout << "total: " << total << std::endl;//继续无限循环break;//结束循环}exit(0);}else if(id>0){//父进程::close(fd[1]);// char buffer[1024];// //sleep(10);// while(1)// {// ssize_t s=read(fd[0],buffer,1024);// if(s>0)// {// buffer[s]=0;// std::cout<<"parent read:"<<buffer<<std::endl;// }// else if(s==0)// {// std::cout<<"child quit"<<std::endl;// }// sleep(1);// }::close(fd[0]);//关闭读端int status=0;pid_t pid=waitpid(id,&status,0);std::cout<<"child quit signal:"<<((status>>8)&0xFF) << " child quit code:"<<(status&0x7F)<<std::endl;}return 0;
}
我们可以看见,进程结束的非常快,所以没有进行子进程的无限循环,且子进程的退出信号为13!!
所以:
现象4:写端正常&&读段关闭,操作系统会杀死写端进程
根据以上实现,我们可以总结出匿名管道的五个特性:
1、面向字节流
2、用来进行具有血缘关系的进程,如父子
3、文件的声明周期随进程结束而结束,管道也是(所有相关进程结束时,内核自动回收管道缓冲区,未读取的数据永久丢失)
4、单向数据通信
5、管道自带同步互斥等保护机制 !(现象3、4)
以上就是我们匿名管道的四大现象五大特性!!!!
总结:
我们今天的文章主要就进程间通信进行了一个开篇,并介绍了一下匿名管道的特性与现象,希望对大家有所帮助。下篇文章我们将会为大家介绍的主要使用匿名管道的例子:进程池 ,有兴趣的可以关注一下。
有疑问的大家可以在评论区或者私信我,谢谢大家!!!