从这一节开始,我们就进入到进程间通信的学习~~~
进程间通信介绍
1.进程为什么要通信呢?
之前我们学的进程都是一个一个独立的进程,每一个进程都是各自忙各自的,但是,进程也是需要某种协同的,比如,在学校里,有人负责上课,有人负责组织考试,有人负责行政管理,如何协同的前提条件是进程之间通信,进程之间需要通信数据,而数据是有类别的,有的数据是通知就绪的,还有的是单纯要传给我的数据,还有控制相关的信息等。
现在我们澄清一个事实,进程是有独立性的。后来我们学习到,进程=内核数据结构+代码和数据,无论在内核数据结构上还是在代码和数据上,不同进程之间都是独立的,一个进程崩掉了,无非是把该进程的代码和数据崩掉,内核数据结构释放掉,不会影响其他进程,所以进程具有独立性。
2.进程如何通信?
- 进程间通信,成本可能会稍微高一些!
这是由于进程之间具有独立性,天然无法共享资源,一个进程无法看到另一个进程的所有东西!
- 进程间通信的前提:先让不同的进程,看到同一份(一般是操作系统提供的)资源(“一段内存”)
为了让两个进程能够通信,需要让它们能看到同一块空间,所以OS说,我来给你们提供一块空间吧,这时两个进程就能看到同一块资源了!这份资源由OS提供,不属于两个进程,就不违背进程具有独立性的特性。
那么,什么时候创建这份资源呢?一定是某一个进程先需要通信,让OS创建一个共享资源,可是OS不能让你自己直接去创建资源,所以,OS必须提供很多的系统调用,让进程以系统调用的方式申请资源!因此,OS创建的共享资源的不同,系统调用接口的不同就会导致进程间通信会有不同的种类!
3.进程间通信常见方式
目前,主流的通信标准是 system V ,用于本地通信。主要有3种进程间通信的方式:a.消息队列 b.共享内存 c.信号量 。其中 共享内存 是我们学习的重点。 在这几种方式出来以前,人们不想单独设计一套标准来进行进程间通信(就是太懒了),因为成本太高,要大量修改OS的源代码,所以,人们想直接复用内核代码直接通信,就发明了管道,分为命名管道和匿名管道。
匿名管道
当我们以读方式和写方式分别打开同一个文件,需要创建两个struct file,struct file里可以让我们找到三个东西:1)文件所有属性inode 2)操作方法集 3)内核级文件缓冲区 未来在加载文件时,文件的内容加载到内核级文件缓冲区,文件的属性加载到inode。
当当前进程以读方式打开文件时,分配了文件描述符,创建文件对象,有inode,有操作方法集,以及内核级缓冲区,现在再以写方式打开该文件,必须也创建struct file,因为读写方式不一样,现在的问题是,需不需要把文件的inode、操作方法集、内核级缓冲区等在OS里再加载一次呢?不需要!只需要让新创建的struct file指向同一个inode、操作方法集、内核级缓冲区即可,因为无论读写方式打开,大家对应的属性和内容是一样的!也就是说,struct file需要创建两次,但文件只需要加载一次就可以了!文件属性和内容只存在一份就够了,OS不喜欢做浪费时间和空间的事情!
现在为了通信,OS说没必要创建进程间通信的方式,再创建一个子进程就行了,即以父进程为模版创建,PCB和文件描述符表都拷贝一份,那对应的struct file和文件的内容属性要不要拷贝一份给子进程?不需要!因为它们属于文件系统,进程要保证独立性,和我文件系统有什么关系???我文件系统又不需要保证独立性!把PCB和文件描述符表都拷贝一份,这就类似于浅拷贝,至此,父进程能看到的文件,子进程也能看到了!
现在我们可以理解一种现象:为什么父子进程会向同一个显示器终端打印数据?因为子进程会继承父进程的文件描述符表,进而父子会指向同一个文件,也就意味着父进程向这个文件打印,子进程也会向同一个文件打印,父子进程会把数据写到同一个缓冲区里,OS刷新会把数据刷新到同一个显示器中!除了这个问题,我们更重要的是还可以理解另外一个问题:进程默认会打开三个标准输入输出012,为什么呢?怎么做到的?因为所有的进程都是bash的子进程,只要bash打开了这三个,所有子进程就会默认打开!!!
实际上,struct file是一个结构体,它也有引用计数,当父子进程同时指向这个struct file的时候,其引用计数就变成2。当我们使用close();时,为什么我们子进程主动close(0/1/2),不影响父进程继续使用显示器文件呢? 其实,在子进程主动close(0/1/2)时,对应struct file的引用计数-1(file->ref_count--),当引用计数减到0的时候(if(ref_count==0)),才会释放文件资源。
至此,我们知道了父子进程可以看到同一块内存级缓冲区(同一块资源),我们把struct file、内核级文件缓冲区、文件的内容和属性叫做管道文件!!!所以,程序员不需要写过多的代码,只需要用父子进程的方式就可以看到公共资源了,已经符合通信的前提条件了,未来父进程往缓冲区里写,子进程从缓冲区里读,不就可以实现进程间通信了吗!
对于管道,需要说明的是,管道只允许单向通信(简单),可是但是父子进程都是以读方式和写方式分别打开了文件,那怎么做到单向通信呢?要么一直父->子,要么一直子->父,比如我们现在想要让父进程读文件,子进程写文件,那么只需关掉父进程写文件的文件描述符和子进程读的文件描述符,这样未来父进程就可以通过3号文件描述符访问对应的文件缓冲区,子进程可以通过4号文件描述符进行写入。另外,只需要父子进程间通信就行,不需要刷新到磁盘,因此要重新设计一下通信接口。
现在的问题是,既然父进程要关掉不需要的文件描述符,那么之前为什么要打开呢?可以不关吗? 因为子进程要从父进程那里继承,如果父进程只以写方式打开,那么子进程也只会进程到写方式,父子进程不能同时写,就出现了异常。OS系统选择了父进程把读方式和写方式都打开,然后父进程关闭写,子进程关闭读,这种方式比较简单!子进程继承后,也可以不关闭不需要的文件描述符,但是建议关闭,因为一个进程能打开的文件是有上限的,文件描述符数组资源也是有上限的,占着文件描述符又不用,这不是在浪费资源吗,而且万一误写了呢?所以,为了避免这样的麻烦,建议不需要的文件描述符关掉。
工程师设计了一套设计管道的接口(pipe),在Linux中使用man pipe查看,
pipe这个系统调用底层其实就是open,只不过重新设计了一下,使用时不需要文件路径和文件名,pipe的参数pipefd[2]是一个输出型参数,它会把以读方式和以写方式打开的两个文件描述符以数组的方式给我们带出来,在内存中创建一个没有名字的文件,因此叫匿名管道!
1.如果我们想要双向通信怎么办?可以设计两个管道!
2.为什么要单向通信?为了简单。如果父子进程都可以向管道中写,那么管道里面既有父进程的数据,又有子进程的数据,那管道注定要区分父子进程的数据,那管道内部一定要做调整,涉及到的技术点一定会增多,但是我只想复用之前的代码,之前的代码是把进程把数据写到文件缓冲区里,再刷新到磁盘上,这个过程本身就是单向的,无非未来我不把数据刷新到磁盘而交给其他进程就可以了,所以单向通信符合我们文件系统本身的特征。之所以命名为管道,一般来说就是单向的!
管道通信实例代码
我们先来创建管道,看一看管道长什么样子,
#include <iostream>
#include <cerrno> // C++版本的errno.h
#include <cstring> // C++版本的string.h
#include <unistd.h>
int main()
{
//1.创建管道
int pipefd[2];
int n = pipe(pipefd);// 输出型参数,rfd wfd
if(n != 0)
{
std::cerr<<"errno: "<<errno<<": "<<"errstring: "<<strerror(errno)<<std::endl;
return 1;
}
std::cout<<"pipefd[0]:"<<pipefd[0]<<",pipefd[1]:"<<pipefd[1]<<std::endl;
return 0;
}
和我们预想的一样,分配给管道的是3号和4号文件描述符,证明管道创建成功。
如果把0号文件描述符先关掉(close(0)),
分配给管道的文件描述符就是0和3,也符合预期!在原理上给我们创建了管道,在底层上给我们创建两个struct file。
那pipefd[0]和pipefd[1]对应什么呢?
pipefd[0] --> 0 --> r(读端) pipefd[1] --> 1 --> w(写端)
快速记忆:0像一张嘴巴,用来读;1像一支笔,用来写。
接着,我们需要创建父子进程,关闭父子进程不需要的fd,在父进程中等待回收子进程:
int main()
{
close(0);
//1.创建管道
int pipefd[2];
int n = pipe(pipefd);// 输出型参数,rfd wfd
if(n != 0)
{
std::cerr<<"errno: "<<errno<<": "<<"errstring: "<<strerror(errno)<<std::endl;
return 1;
}
std::cout<<"pipefd[0]:"<<pipefd[0]<<",pipefd[1]:"<<pipefd[1]<<std::endl;
//2.创建子进程
pid_t id = fork();
if(id == 0)
{
//子进程 -- write
//3.关闭不需要的fd
close(pipefd[0]);
SubProcessWrite(pipefd[1]);
close(pipefd[1]);
exit(0);
}
//父进程 -- read
//3.关闭不需要的fd
close(pipefd[1]);
FatherProcessRead(pipefd[0]);
close(pipefd[0]);
pid_t rid = waitpid(id,nullptr,0);
if(rid > 0)
{
std::cout<<"wait child process done!"<<std::endl;
}
return 0;
}
然后,子进程在SubProcessWrite里写入,父进程在FatherProcessRead里读。我们知道,管道也是文件,那么就可以使用read/write进行管道的读写。我们在使用write写入管道时,没有写入\0,也没有必要!\0为结尾只是C语言的规定,并不是文件的规定。所以,在使用read读取时,最后一个参数要是sizeof(inbuffer)-1,预留出来一个空间。
const int size = 1024;
std::string GetOtherMessage()
{
static int count = 0;
std::string messageid = std::to_string(count);
count++;
pid_t self_id = getpid();
std::string stringid = std::to_string(self_id);
std::string message = "messageid: ";
message += messageid;
message += "my pid is : ";
message += stringid;
return message;
}
void SubProcessWrite(int wfd)
{
std::string message = "Father,I am your child process~";
while(true)
{
std::string info = message + GetOtherMessage();//这条消息,就是我们子进程发给父进程的消息
write(wfd,info.c_str(),info.size());
sleep(1); //子进程写慢一点
}
}
void FatherProcessRead(int rfd)
{
char inbuffer[size]; //c99
while(true)
{
ssize_t n = read(rfd,inbuffer,sizeof(inbuffer)-1);
if(n>0)
{
inbuffer[n] = 0;// '\0'
std::cout<<"father get message: "<<inbuffer<< std::endl;
}
}
}
运行以上程序,我们观察一下打印结果,同时打开另一个检测窗口,
while :; do ps ajx | head -1 && ps ajx | grep testpipe | grep -v grep;echo "---------------------------";sleep 1;done
这样就可以监视进程的变化。
我们发现,进程运行起来后,父进程打印的消息显示在了显示器上!
管道的5种特征
1.匿名管道,只能用来进行具有血缘关系的进程之间进行通信,常用于父子进程之间通信
我们知道,匿名管道是通过fork一个子进程让两个进程看到同一块资源,通过父子进程继承内核级的文件描述符对象让两个进程看到同一个文件,因此,只能用来父子进程之间通信。也就是说,两个毫不相关的进程无法使用匿名管道。
2.管道内部,自带进程之间的同步机制
我们知道,像管道这种公共资源,可能存在被多个进程同时访问的情况,可能会出现一个进程还没写完,另一个进程就读走了的情况,这种情况叫数据不一致问题!在匿名管道这里,我们发现的现象是,子进程写一条,父进程读一条。那子进程sleep的期间,父进程在干什么呢?在等子进程写,因为管道里已经没数据了,这叫做进程之间的同步机制。同步,就是多执行流执行代码的时候,具有明显的顺序性!
3.管道文件的生命周期是随进程的。
4.管道文件在通信的时候,是面向字节流的。 write的次数和读取的字数不是一一匹配的。
5.管道的通信模式,是一种特殊的半双工模式。
管道的4种情况
1.如果管道内部是空的&&write fd没有关闭,读取条件不具备,读进程会被阻塞,会一直等读条件具备(写入数据)。
2.管道被写满&&read fd不读且没有关闭,管道被写满,写进程会被阻塞(管道被写满,写条件不具备),一直等待直到写条件具备(读取数据)。
3.管道一直在读&&写端关闭了wfd,读端read返回值会读到0,表示读到了文件结尾。
4.rfd关闭 && 写端wfd一直在进行写入,写端进程会被操作系统直接使用13号信号关掉,相当于进程出现了异常。
第4种情况,此时再往管道写入已经不合理不合法,这时的管道是一个broken pipe,OS会杀掉对应的进程,即给目标发送信号:13)SIGPIPE,
我们来查一下man 7 pipe,然后/PIPE_BUF,发现这段话:
如果写入小于PIPE_BUF,那么写入就是原子的,也就是读写时安全的,不会出现写了一半就被读走了的情况,换言之,如果写入大于PIPE_BUF,读写时有可能就是不安全的!在Linux中,PIPE_BUF一般是4096字节。
我们再来看一个问题:我们在shell中运行命令sleep 10000 | sleep 20000 | sleep 30000, 通过三个‘|’连接了三个子命令,通过查看进程ps ajx | grep sleep,我们发现:
三个子命令变成了三个进程,这三个进程的父进程的pid都是191487,我们查看一下191487是谁ps ajx | grep 191487,发现是我们的bash父进程,
换言之,我们在命令行启动的用管道连接起来的多条命令都会变成进程,而这几个进程之间是兄弟关系,也就意味着这三个进程之间是有血缘关系的!时间上,我们之前在命令行写的“ | ”是匿名管道,我们在命令行输入的所有信息都是字符串,可以识别字符串识别出有几个“ | ”,也就是有几个管道,就可以让我们的父进程提前创建几个管道。
版权归原作者 核动力C++选手 所有, 如有侵权,请联系我们删除。