本文重点目标:
⭐进程间通信介绍⭐
⭐管道⭐
⭐消息队列⭐
⭐共享内存⭐
⭐信号量⭐
1.进程间通信介绍
什么是通信?
通信指的是数据传输、资源共享、通知事件和进程控制。
①数据传输:一个进程需要将它的数据发送给另一个进程
②资源共享:多个进程之间共享同样的资源。
③通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
④进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
为什么要通信?
有时候需要多进程协同,让每一个进程专注于自己的事,然后把结果交给另外一个进程去处理。比如使用管道,让多进程协同,简单的有:
cat file | grep "hello"
我们都知道,管道前面的cat是将file文件的内容现实出来,后面的grep通过管道,拿到file的内容,然后根据内容筛选"hello"指定的内容。也就是说cat负责打印文件内容,grep负责过滤内容,两个进程通过管道 | 连接起来,完成两个进程之间的通信!
因此,通信的目的就是让多进程协同,完成任务。
如何进行进程间通信?
主流的通信的办法有三种:管道、System V进程间通信和POSIX进程间通信。
System V是一种聚焦在本地的一种通信方法,即在一台计算机中进行多进程协同。
POSIX是让通信过程可以跨主机
管道:依托文件系统来处理通信的一套方案。
理解通信的本质
因为进程具有独立性,每个进程自己的资源都只属于自己,因此想要通信,就必须让双方的进程看到同一份资源,而这块资源或空间就是由操作系统直接或间接提供!这也意味着,即使通信的种类很多,但是其本质就是提供资源的模块,是属于操作系统中哪一个模块,比如是由文件系统提供的,那么这个通信的种类就是管道。
总结一下:进程间通信,就是要让不同的进程看到同一份资源,即能够协同使用这些资源,然后进行通信,最后完成任务!
管道
什么是管道?
管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。管道可以分有匿名管道和命名管道,接下来我们先从匿名管道开始学习!
匿名管道
父子进程是同时指向一个文件,此时这个文件,就是父子进程这两个进程能够同时看到的资源!这就满足了通信的前提。而文件里有文件缓冲区等等,父进程可以向文件缓冲区里面写入内容,子进程又通过文件缓冲区将内容读取出去,这个过程,就完成了一个进程将数据交给另外一个进程,即进程间通信!
其中,使用文件的方式来完成父子进程进行通信,这个文件,称为管道文件!
那么这个管道文件怎么来的呢?
如果是一个普通文件,我们往文件里面写入数据,那么除了把数据放在文件的缓冲区里面,还要把这些数据要刷新到磁盘中。接着进程间通信,一个进程往文件里面写数据,刷新到磁盘,然后操作系统从磁盘里面拿数据,加载到内存,另外一个进程再从内存中拿到进程的上下文,这种方法很慢。因此,对于进程间通信需要用到的文件,压根不需要将数据刷新到磁盘,这就意味着,这个文件是不需要真正的存在于磁盘中,文件对象的各种内容,比如文件的操作方法、内核缓冲区等等,都是操作系统申请的。普通文件被open后,操作系统就会创建这个普通文件的文件对象,然后申请各种东西,现在,即使没有open,操作系统也可以去创建文件对象!因此,我们的管道文件,是内存级别的文件!
此时就可以解释清除了,当一个管道文件的对象被创建出来后,然后把对象的地址填入到一个进程的文件描述符表里面,父进程就能看到这个文件了。接着这个进程fork一下创建子进程,子进程直接拥有跟父进程一模一样的文件描述符表,进而子进程也能跟父进程看到同一个文件。此时,父进程就能通过这个内存级别的文件进行通信!
一般要标定一个文件,是通过文件名来找到指定的文件的,可是当前这个管道文件没有名字,这也的管道文件,叫做匿名文件!
一般而言,管道只能用于单向通信,但父进程需要同时以读写方式打开文件,然后让子进程继承文件描述符表,这也才能灵活地进行通信。当父进程是写方式,那么就关掉读功能,子进程关掉写功能。反过来,父进程是读的方式,那么父进程关掉写功能,子进程关掉读功能。
总结:通过父进程fork创建子进程,让子进程继承父进程文件描述符,让两个进程看到同一个管道文件,那么这个管道文件是内存级文件,没有名字,这就是匿名管道!
此时,理解了这些,我们就完成了进程间通信的第一步:让不同进程看到同一个资源。那么第二步,自然就是通信啦!接下来,我们通过编写代码来认识通信需要用到的接口。
编写代码/实例代码
功能:创建一无名管道intpipe(intfd[2]); 参数fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端这个参数为输出型参数。在调用pipe的时候,操作系统会打开对于的文件,得到对应进程的文件描述符表中特点的位置,比如3,4,然后把3和4填充到fd[2]中。返回值:成功返回0,失败返回错误代码
usingnamespacestd; intmain() { //第一步:创建管道文件,打开读写端intfds[2]; intn=pipe(fds); assert(n==0); //第二步:创建子进程pid_tid=fork(); assert(id>=0); if(id==0) { //子进程要写入,即父进程是读,子进程关掉读功能close(fds[0]); //通信代码constchar*s="我是子进程,我正在写入数据"; intcnt=0; while(true) { cnt++; charbuffer[1024];//只有子进程才能看到的缓冲区snprintf(buffer,sizeof(buffer),"child->parent say: %s[%d][%d]",s,cnt,getpid()); write(fds[1],buffer,strlen(buffer)); sleep(1);//每隔一秒写一次 } close(fds[1]);//可以在最后关掉,也可以不关,因为最后子进程终止了,文件也会跟着关掉exit(0); } //父进程读close(fds[1]); //父进程的通信代码while(true) { charbuffer[1024]; ssize_ts=read(fds[0],buffer,sizeof(buffer)-1); if(s>0) { buffer[s] =0; } cout<<"Get Message# "<<buffer<<" | my pid: "<<getpid()<<endl; //注意这里父进程没有sleep } n=waitpid(id,nullptr,0); assert(n==id); close(fds[0]); //初步猜测,看到是3和4,但是谁3谁4?// cout<<"fds[0]: "<<fds[0]<<endl;//3 读端// cout<<"fds[1]: "<<fds[1]<<endl;//4 写端return0; }
结果如下:
从代码中我们可以看到,子进程是没有输出的代码的,是父进程在输出。从结果中我们可以看到,子进程负责写入,然后将数据交给了父进程,父进程负责读取!这种通信方式,就叫做管道通信方式!
读写特征:
①写入的时候有sleep间隔,读取没sleep间隔
上面的代码有一个细节,那就是我们在子进程的写入的时候,是每隔1秒写入一次,而在父进程的读取数据的时候,没有时间间隔。那么如果我们把写入的间隔改成5秒,结果会是怎么样的?结果会是读取的速度会变慢!
那么,在子进程不进行写入的那5秒之间,父进程在干啥子?父进程在阻塞状态!我们将代码中父进程的代码改写成如下:
//父进程读close(fds[1]); //父进程的通信代码while(true) { charbuffer[1024]; cout<<"AAAAAAAAAAA"<<endl; ssize_ts=read(fds[0],buffer,sizeof(buffer)-1); cout<<"BBBBBBBBBBB"<<endl; if(s>0) { buffer[s] =0; } cout<<"Get Message# "<<buffer<<" | my pid: "<<getpid()<<endl; //注意这里父进程没有sleep }
我们会看到结果:
第一次的时候,就会将A和B先打印输出,然后将子进程写入的内容进行打印输出,那么在接下来等待的5秒里面,父进程先是打印了第二次的A,然后在read那里进入了阻塞状态!这就证明了,如果管道中没有数据,读端在读的时候,默认阻塞当前在读的进程!
②写入没sleep间隔,读取sleep有间隔
反过来,如果在写入的时候,子进程没有sleep,而在读的时候,父进程每次读完,会sleep一段时间,或者甚至不读,先睡一大觉。那么此时,因为管道是有空间大小的,写满的时候,就不能写啦,再写的话可能会把原来的内容给覆盖了,此时写端会阻塞,等待读端的提取!
如果读端只是sleep一小段时间,而写端不停地写入,此时,因为读的时候,是按buffer的字节个数去读的,也就是说,字节个数有多少,在合法的范围内,读端就会马上读取多少。这就导致了下面这个结果,比如读端休眠2秒,而写端不休眠。
看到结果显示,它会按行读取,将所有内容全部读取出来!
③子进程写端只写一次数据,并且把自己的写端描述符给关掉。
如果子进程把自己的写端关掉,那么就代表着已经读完了。测试代码如下:
//......pid_tid=fork(); assert(id>=0); if(id==0) { //子进程要写入,即父进程是读,子进程关掉读功能close(fds[0]); //通信代码constchar*s="我是子进程,我正在写入数据"; intcnt=0; while(true) { cnt++; charbuffer[1024];//只有子进程才能看到的缓冲区snprintf(buffer,sizeof(buffer),"child->parent say: %s[%d][%d]",s,cnt,getpid()); write(fds[1],buffer,strlen(buffer)); //sleep(10);//每隔一秒写一次break; } close(fds[1]);//可以在最后关掉,也可以不关,因为最后子进程终止了,文件也会跟着关掉cout<<"子进程关闭了自己的写端"<<endl; exit(0); } //父进程读close(fds[1]); //父进程的通信代码while(true) { sleep(2); charbuffer[1024]; ssize_ts=read(fds[0],buffer,sizeof(buffer)-1); if(s>0) { buffer[s] =0; cout<<"Get Message# "<<buffer<<" | my pid: "<<getpid()<<endl; } elseif(s==0) { //读到文件结尾cout<<"read: "<<s<<endl; break; } //注意这里父进程没有sleep } //......
④读端关闭,写
读端已经关闭了,此时写是没有意义了,如果坚持下去,只会浪费操作系统的资源。因此对于这种情况,OS会给写的进程发送信号,去终止写端,子进程也会被杀掉,子进程一旦被杀掉,代表着异常退出,父进程就可以获取到子进程的退出码。下面是测试代码:
代码思路:先让读端读取一次,写端还是不停地写,读端读取一次后,关闭读端,子进程立即被终止,也就是被杀掉了,父进程就能读取到子进程的退出码,获取到子进程退出的信号。
#include <iostream> #include <string> #include <cstring> #include <cstdio> #include <unistd.h> #include <cassert> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> using namespace std; int main() { //第一步:创建管道文件,打开读写端 int fds[2]; int n = pipe(fds); assert(n==0); //第二步:创建子进程 pid_t id = fork(); assert(id >= 0); if(id==0) { //子进程要写入,即父进程是读,子进程关掉读功能 close(fds[0]); //通信代码 const char *s = "我是子进程,我正在写入数据"; int cnt = 0; while(true) { cnt++; char buffer[1024];//只有子进程才能看到的缓冲区 snprintf(buffer,sizeof(buffer),"child->parent say: %s[%d][%d]",s,cnt,getpid()); write(fds[1],buffer,strlen(buffer)); //sleep(10);//每隔一秒写一次 //break; } close(fds[1]);//可以在最后关掉,也可以不关,因为最后子进程终止了,文件也会跟着关掉 cout<<"子进程关闭了自己的写端"<<endl; exit(0); } //父进程读 close(fds[1]); //父进程的通信代码 while(true) { sleep(2); char buffer[1024]; ssize_t s = read(fds[0],buffer,sizeof(buffer)-1); if(s>0) { buffer[s] = 0; cout<<"Get Message# "<<buffer<<" | my pid: "<<getpid()<<endl; } else if(s==0) { //读到文件结尾 cout<<"read: "<<s<<endl; break; } break; } close(fds[0]);//父进程读端只读一次后,关闭读端 cout<<"父进程关闭读端"<<endl; int status = 0; n = waitpid(id,&status,0); assert(n==id); cout<<"pid->"<<n<<" : "<<(status & 0x7F) <<endl; return 0; }
从结果我们可以看到,先是等待两秒,再进行第一次的读取,这个读取是按行读取的。第二次的时候,读端被关闭了,子进程终止,并且发送的是13号信号。
管道特征
①只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
②管道提供流式服务
③一般而言,进程退出,管道释放,所以管道的生命周期随进程
④一般而言,内核会对管道操作进行同步与互斥,是对共享资源机制的一种包含方案。两个独立进程会很照顾对方的感受,你读累了,那我写满后就不写了;你写累了,不写了,那我也不催促你,我也不读,等你不累了写了再读。这也就可以避免资源出现错误。
⑤管道是半双工(单向通信的特殊概念)的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
命名管道
什么是命名管道?
管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。命名管道是一种特殊类型的文件。
创建一个命名管道
从命令行上创建:
$ mkfifo filename
从程序里面创建:
#include<sys/types.h> #include<sys/stat.h> int mkfifo(const char *filename,mode_t mode); 参数:第一个参数(const char *filename)填写的是我们要创建的管道文件的路径 第二个参数(mode_t mode)填写的是这个文件的权限,一般为0666. 返回值:创建成功返回0 比如下面的代码: int main() { mkfifo("p2", 0644); return 0; }
命名管道自然是一个独立文件,以p开头,以p开头的文件,称为管道,没错,这个文件,跟书华竖划线 | 一样,是管道!它可以让不是亲属关系的进程进行通信,让其看到同一份资源。那么,命名管道是如何做到的呢?
因为文件名+路径 = 唯一性。因此通过让不同进程打开指定名称(路径+文件名)的同一个文件,这样就能让不同进程找到同一份文件,能够看到同一份资源了,对比匿名管道,匿名管道是通过子进程继承文件描述符的方式确定管道文件的唯一性。
匿名管道:通过子进程继承父继承的文件描述符表来确定管道文件的唯一性。
命名管道:通过打开指定名称(路径+文件名 = 唯一性)的同一个文件。
编写代码
先创建2个cpp文件,表示两个没有亲属关系的进程,然后创建1个头文件,用于管道文件的创建。
两个cpp文件:server.cpp用于读取数据,client.cpp用于写入数据。
读取数读端的server.cpp代码:
#include "comm.hpp" using namespace std; int main() { //创建文件,并且判断是否创建成功 bool r = createFifo(NAMED_PIPE); assert(r); (void)r; int rfd = open(NAMED_PIPE,O_RDONLY); if(rfd < 0)exit(1);//创建失败 //read char buffer[1024]; while(true) { ssize_t s =read(rfd,buffer,sizeof(buffer)); if(s>0)//读取成功,打印 { buffer[s] = 0; cout<<buffer<<endl; } else//读取失败,退出 { std::cout << "client quit, me too!" << std::endl; break; } } close(rfd);//关闭文件 removeFifo(NAMED_PIPE);//最后删除文件 return 0; }
写端client.cpp代码:
#include "comm.hpp" int main() { int wfd = open(NAMED_PIPE,O_WRONLY); if(wfd < 0) exit(1); char buffer[1024]; while(true) { fgets(buffer,sizeof(buffer),stdin); if(strlen(buffer)>0) { buffer[strlen(buffer)-1] = 0;//去掉换行 } ssize_t w = write(wfd,buffer,sizeof(buffer)); assert(w==strlen(buffer)); (void)w; } close(wfd); return 0; }
头文件:
#include <iostream> #include <string> #include <cstring> #include <cerrno> #include <cassert> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #define NAMED_PIPE "/wjmhlh/name_pipe" //管道文件的路径,建立在当前路径 //创建管道文件 bool createFifo(const string &path) { umask(0); //创建管道文件 int n = mkfifo(path.c_str(),0600); if(n==0)//管道文件创建成功 { return true; } else { std::cout << "errno: " << errno << " err string: " << strerror(errno) << std::endl; return false; } } //删除文件 void removeFifo(const string &path) { int n = unlink(path.c_str()); assert(n==0); (void)n; }