管道通信
进程通信的概念
**我们要明白进程是具有独立性的!**那么如果我们需要通信——我们要明白一点==通信的成本绝对不低==
那么什么是通信呢?
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止 时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(Debug进程,使用gdb去控制另我们的程序),此时控制进程希望能够拦截另 一个进程的所有陷入和异常,并能够及时知道它的状态改变。
为什么要有通信?
当我们需要进行==多进程协同==的时候——我们要让一个进程去专注处理一个问题,然后将处理好的数据的结果交给下一个进程!例如linux指令
cat file | grep "hello"
cat就是一个指令(进程)将文件的内容打印出来,grep就是从特定的数据源中按关键字过滤
中间的是管道——意思就是将文件的内容通过管道交给grep让grep去过滤!
==通过管道,我们就完成了两个进程之间通信的目的,完成了一种协同的工作==
==这就是管道的目的,通过多进程协调去完成某种业务内容!==
如何进行进程间通信呢?
目前——有两套最广泛使用的通信标准
- POSIX 进程间通信
- System V进程通信
这两套标准已经被在操作系统里面的实现了——我们要做的就是学习怎么使用它
这两套标准的不用在于
==System V——聚焦在本地通信——分别有是三种共享内存,消息队列,信号量==
==POSIX ——让通信可以跨主机==
不过SystemV这套标准现在也很少被使用了——因为现在的世界已经是一个互联网的时代,SystemV只能在本地进行通信!
而且现在主流的通信接口都是和文件描述符相关的!而SystemV的接口是自起炉灶和文件操作其实没有什么关系——所以这就是导致了使用不方便!所以也是也是被慢慢抛弃的原因
除了这两套标准之外还有其他的通信方案——例如:管道(这是依赖于文件系统的来实现的通信方案)
通信的本质
==我们该理解进程的本质问题是什么==
如果进程要进行通信——A进程把数据交给B进程
那么A肯定要将数据保存起来!然后在特定的时间点把数据交给B进程!
但是保存数据肯定要缓冲区!等在需要的时候B进程再将数据拿出来!
==那么这个缓冲区应该放在那里?——因为进程的大部分数据都是私有的!所以必须要有一个所有进程都看得到的区域,一个进程将数据拷贝到区域,另一个进程再从这个区域将数据拿走!==
所以因为进程具有独立性——这个保存临时数据的缓冲区就不能是由某个单独的进程提供(即不能存在于进程内部!)必须由第三方来提供——那只能是操作系统了!
所以进程通信的本质
- 就是OS需要直接或者间接给通信的双方提供“内存空间“
- 要通信的进程必须看到一份公共的资源(这个资源就是操作系统提供的!)
==不同的通信种类!其实本质上来说就是——上面所的资源,是由操作系统的那个模块来提供的!==
如果这个资源由——文件系统来提供那么这就是——管道!
如果是由System V的通信模块来提供——那么就是System V的通信方式
如果是一个内存——那么就是共享内存
如果是一个队列——那么就是通信队列!
==这就是为什么我们说进程通信的成不不低!==
- 我们首先就要让不同的进程看到同一份的资源!
- 然后才是进程通信!
我们后面讲的各种接口与其说通信的接口不如说是让不同的进程看到同一份资源的接口!
管道的概念和本质
什么是管道?
管道是Unix中最古老的进程间通信的形式
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
==上面我们说过如果这个资源由——文件系统来提供那么这就是——管道!==
下面我们看一个现象
创建子进程后
==这时候我们就让不同的进程看到了同一份的资源!==
此时如果父进程向文件缓冲区里面的写入,子进程向文件的缓冲区里面读取——这不就完成了==进程间的通信吗?==
==这个内核级文件——我们就称之为管道文件!——本质也是文件==
管道文件与普通文件有什么不同吗?
通信的目的是将数据从一个进程到另一个进程!——即从==内存到内存==
所以根本不需要将数据从缓冲区刷新到磁盘里面!因为数据不需要刷新到磁盘所以磁盘里面也根本不需要真正有这个文件让其被打开!
所以我们这里说的管道文件——是一种==内存级文件!==由操作系统自己在内存里面为我们创建的文件——不需要我们自己去open!
申请出来之后操作系统会自动的把这个文件的地址填入进程的文件描述符表中!——管道文化不关心自己在磁盘的那个路径下,这些都是没有必要的!
struct file这个结构体里面也有相关的联合体——用来给文件区分这是一个管道文件还是一个普通文件
匿名管道
管道分两类——==匿名管道==和==命名管道==
关于匿名管道
我们让两个不同的进程看到同一份管道文件
首先就是让父进程打开管道文件,通过fork创建子进程,因为自进程的文件描述符表也是拷贝自父进程,所以父子进程可以看到同一个管道文件
在以前,我们表示访问同一个文件的方式就是通过访问同一个路径下的==同名文件==
但是==管道这里访问同一个文件的方式是通过fork创建子进程来实现的!——因为文件描述符表的地址相同所以访问的文件也相同==——我们是不知道这个文件叫什么的!——所以这个管道文件就叫做==匿名管道==(这个内存级文件是没有所谓的名称!)
==从文件描述符的角度理解管道==
我们一般都是使用open打开文件的!——那么我们该如何保证我们打开的是一个内存级文件呢?
如果父进程以读的方式打开文件——那么子进程也只会继承以读的方式打开文件
==所以管道自身也有自己的特点==
管道之所以一般用来进行单向通——因为如果要进行双向通信我们就得来去识别数据究竟是谁的!是别人的我们才能拿,自己的我们不能拿,那样子设计起来就更麻烦了!
如果想要双向通信——==创建两个管道不就可以了?只在一个管道上实现就太麻烦了==
==所以第三步才是关闭父进程和子进程不需要关系的文件描述符!(无论是父读子写还是父写子读都可以!——但是必须是一个读一个写!)==
虽然我们可以不关,但是最好建议都关掉,防止被别人使用了不需要被使用的文件描述符!
所以对于管道我们可以怎么描述——就是父进程调用管道特定的系统调用,以读方式和写方式打开一个内存级文件,并通过fork创建子进程的方式被继承下去之后,各自关闭对应读写端形成的一条通信信道
==上面的只是做一件事情——让不同的进程看到同一份资源!还没有进行通信==
==匿名管道一般都是用来让父子进程进行通信的!==
匿名管道的函数接口
pipe函数接口
pipe这个函数就是操作系统操作系统给我们提供的接口!如果成功那么就返回0,如果失败那么就返回-1.并且错误码被设置!
==最重要的是它的参数——是一个输出型参数==
==这样子我们就可以得到读端和写端的文件描述符了!==
我们想要创建一个管道调用一下pipe就可以!
#include<iostream> #include<unistd.h> #include<cassert> int main() { int fds[2]; int n = pipe(fds); assert(n == 0); //0 1 2一般都是被占用 std::cout << "fsd[0]:"<<fds[0]<<std::endl; std::cout << "fsd[1]:"<<fds[1]<<std::endl; return 0; }
==我们可以看到fsd[0]是3 fsd[4]——那么谁是读谁是写呢?==
==从标准文档我们看到[0]是读,[1]是写==
接下来我们就可以写一个例子:
#include<iostream> #include<unistd.h> #include<cstring> #include<cassert> #include<sys/wait.h> #include<sys/types.h> using namespace std; //父进程进行读取,子进程写入 int main() { //管道通信的第一步——打开读写端 int fds[2]; int n = pipe(fds); assert(n == 0); //第二步:fork创建子进程 pid_t id = fork(); assert(id >= 0); if (id == 0) { // 子进程关闭读端 close(fds[0]); // 子进程的通信代码 const char* s = "i am child process,i am sending messege to you!"; int cnt = 0; while(true) { ++cnt; char buffer[1024];//这个缓冲区只有子进程能看到 snprintf(buffer,sizeof buffer,"child ->parent say:%s[%d] child pid is %d",s,cnt,getpid()); //向管道文件写入!——本质也是向文件写入!所以使用write既可以!就可以! write(fds[1],buffer,strlen(buffer)); //写管道代码不能写文件描述符常数!要使用fds数组的下标! //strlen最多只会写1023个字节!因为最后的\0是不会被读取的! sleep(5);//每隔5秒写一次! } close(fds[1]);//不一定需要,因为进程退出后自己也会关闭 exit(0); } //父进程关闭写端 close(fds[1]); //父进程的通信代码 while(true) { char buffer[1024]; //父进程读管道文件也是一样!——本质也是读文件!使用系统调用接口的即可 ssize_t s = read(fds[0],buffer,sizeof(buffer)- 1); //防止buffer被填满!因为系统调用不会给我们自动的加上\0 //如果不知道要+1/-1还是不用最简单的处理就是都-1!预留一个这叫做防御性编程 if(s > 0) buffer[s] = 0; cout << "getmessge#:"<< buffer <<"| my pid:"<< getpid()<< endl; //细节父进程没有进行sleep! //当父进程读走管道的数据后的,管道里面的被读取的数据就会被设置为无效 } pid_t pid = waitpid(id,nullptr,0); assert(pid == id); return 0; }
==我们可以看到父进程确实收到了子进程的消息!——这种通信方式就是管道通信!==
匿名管道的通信方案其实是这样的!
虽然我们看上去只有一份代码!但是有两个进程!
管道的读写特点
上面的代码中,我们发现,子进程的写入是有sleep暂停休眠的,但是父进程的读取却没有!——而且时间是漫长的5s,那么在子进程没有往管道文件写入的期间==父进程在干什么呢?==
#include<iostream> #include<unistd.h> #include<cstring> #include<cassert> #include<sys/wait.h> #include<sys/types.h> using namespace std; //父进程进行读取,子进程写入 int main() { //.... //父进程关闭写端 close(fds[1]); //父进程的通信代码 while(true) { //我们把代码改成这样,然后把sleep时间改成10s观察一下现象 char buffer[1024]; cout << "AAAAAAAAAAAAAAAAAAAAAAAAA"; ssize_t s = read(fds[0],buffer,sizeof(buffer)- 1); cout << "BBBBBBBBBBBBBBBBBBBBBBBBB"; if(s > 0) buffer[s] = 0; cout << "getmessge#:"<< buffer <<"| my pid:"<< getpid()<< endl; } pid_t pid = waitpid(id,nullptr,0); assert(pid == id); return 0; }
在10s的休眠期间——父进程只打印了一次A!没有打印B!——说明了父进程在read哪里进行了阻塞等待
==这说明一个管道读取的一个特点——当管道没有数据的,但是读端在读取的时候,默认会直接阻塞当前正在读取的进程!(将R状态变成S状态),所以read有两个功能一个是读取,一个是阻塞==
如果反过来会怎么样?让父进程休,而子进程不休眠?
int main() { //... pid_t id = fork(); assert(id >= 0); if (id == 0) { close(fds[0]); const char* s = "i am child process,i am sending messege to you!"; int cnt = 0; //子进程 while(true) { //.... write(fds[1],buffer,strlen(buffer)); ///sleep(5);//每隔5秒写一次! cout << "count:" <<cnt<<endl; } close(fds[1]);/ exit(0); } sleep(100);//父进程直接不读取了! close(fds[1]); //父进程 while(true) { //... } pid_t pid = waitpid(id,nullptr,0); assert(pid == id); return 0; }
==我们可以看到到写端写到一定的次数之后就不写了!——当缓冲区被写满后,就不允许被写入了!就是说此时写端就会被阻塞!==
如果我们让写端不休眠,读端每隔只休眠2s会看到什么呢?
int main() { //... pid_t id = fork(); assert(id >= 0); if (id == 0) { close(fds[0]); const char* s = "i am child process,i am sending messege to you!"; int cnt = 0; //子进程 while(true) { //.... write(fds[1],buffer,strlen(buffer)); cout << "count:" <<cnt<<endl; } close(fds[1]); exit(0); } close(fds[1]); //父进程 while(true) { sleep(2);//只休眠2s //... } pid_t pid = waitpid(id,nullptr,0); assert(pid == id); return 0; }
==我们发现结果变成了这样!因为我们写入的时候,就是直接将数据塞进管道里面!而读取的时候也不是按照行来读取!而是直接按照buffer的大小来读取!你有多少字节的空间,就直接读多少字节!==
开始写端休眠导致了发送的慢,那么就能让读端及时的将数据从管道里面读取出来!所以才能做到一行行的打印!
如果此时写端不仅不写入了,而且还关闭了文件描述符会发生什么?
#include<iostream> #include<unistd.h> #include<string> #include<cstring> #include<cassert> #include<sys/wait.h> #include<sys/types.h> using namespace std; //父进程进行读取,子进程写入 int main() { //管道通信的第一步——打开读写端 int fds[2]; int n = pipe(fds); assert(n == 0); //第二步:fork创建子进程 pid_t id = fork(); assert(id >= 0); if (id == 0) { //... // 子进程 while(true) { ++cnt; char buffer[1024]; snprintf(buffer,sizeof buffer,"child ->parent say:%s[%d] child pid is %d",s,cnt,getpid()); write(fds[1],buffer,strlen(buffer)); cout << "count:" <<cnt<<endl; //写入一次后调出循环 break; } close(fds[1]);//子进程不仅不在写入,而且关闭fd cout << "child process had close fd!\n" <<endl; exit(0); } //父进程关闭写端 close(fds[1]); //父进程的通信代码 while(true) { sleep(2); char buffer[1024]; cout << "AAAAAAAAAAAAAAAAAAAAAAAAAAA"<<endl; ssize_t s = read(fds[0],buffer,sizeof(buffer)- 1); cout << "BBBBBBBBBBBBBBBBBBBBBBBBBBB"<<endl; if(s > 0) { buffer[s] = 0; cout << "getmessge#:" << buffer << "| my pid:" << getpid() << endl; } else if(s == 0) { cout << "finish\n" <<endl; break; } } pid_t pid = waitpid(id,nullptr,0); assert(pid == id); return 0; }
==如果写端被关闭了,那么读端就会直接读取到0!这样读端也就会关闭了!——相比不关写端,读端就会被阻塞,而不是会读取到0==
如果读取关闭,再去写会发生什么呢?
==这时候因为读端已经关闭,写端再写已经没有任何的意义了!所以此时操作系统会终止写端——通过发生信号的形式==
int main() { //... if (id == 0) { // 子进程关闭读端 close(fds[0]); // 子进程的通信代码 const char* s = "i am child process,i am sending messege to you!"; int cnt = 0; while(true) { //.... write(fds[1],buffer,strlen(buffer)); cout << "count:" <<cnt<<endl; sleep(2); } close(fds[1]); exit(0); } //父进程关闭写端 close(fds[1]); //父进程的通信代码 while(true) { char buffer[1024]; cout << "AAAAAAAAAAAAAAAAAAAAAAAAAAA"<<endl; ssize_t s = read(fds[0],buffer,sizeof(buffer)- 1); cout << "BBBBBBBBBBBBBBBBBBBBBBBBBBB"<<endl; if(s > 0) { buffer[s] = 0; cout << "getmessge#:" << buffer << "| my pid:" << getpid() << endl; } else if(s == 0) { cout << "finish\n" <<endl; break; } break;//读取一次后就不读了 } close(fds[0]); cout << "parent process had close fd!" <<endl; int status = 0; pid_t pid = waitpid(id,&status,0); assert(pid == id); if(!WEXITSTATUS(status))//判断是不是异常退出,是正常退出返回真!,不是返回假 { cout << "child process had be killed"<<endl; cout << "child pid :"<< pid << " exit code " << (int)(status&0x7F) <<endl;//获取退出码 } return 0; }
相关退出码表
==我们可以看到如我们所料,读取一次之后,读端就关闭了!——此时写端出现了异常退出!我们可以看懂写端的错误码是13——是管道信号的意思!==
读写特征总结
一共有4种情况
- 写端慢,读端快——这种情况就是正常的读取,但是在写端没有写入的时候,读端会进入阻塞状态
- 写端快,读端慢——这种情况就会出现缓冲区被写满,一次性读取buffer缓冲区大小的数据
- 写端关闭,读端存在——读端会一直的读取0字节的大小
- 写端存在,读端关闭——此时操作系统会直接发出信号杀死写端!
管道的特征
- 管道的生命周期是随着进程的!
- 管道可以用来进行具有“血缘关系”的进程间的通信——常用与父子通信
例如兄弟进程,因为,都是继承自同一个父进程的文件描述符表,也都指向同一个的管道文件,又例如孙子进程,也可以和父进程通信!原因是子进程的文件描述符表继承自父进程,而孙子进程的文件描述表继承自子进程,和父进程是一样的!
- 管道是面向字节流的(网络)
- 管道通信是——半双工通信(即任意时刻只允许一个进程向另一个进程发送消息),单向通信是半双工的一种特殊概念
- 互斥与同步机制
我们发现管道并不会出现管道写满了之后就一直继续写,而是会停下来,如果读空了,那么就不会继续读了
但是一般来说==因为两个进程彼此独立==,所以一般来说就是如果要求一个进程写入那么它就是会一直写入,一个进程读取,那么就会一直读取!但是管道却可以做到——这就是管道文件内容实现的==互斥与同步机制==——是一种对共享资源保护的方案!
命名管道概念
匿名管道的缺点就是只能进行具有“血缘关系”的进程之间的通信!——而不能进行毫无关系之间进程的通信!
==所以为了解决这个缺点于是有了命名管道!==
==mkfifo——这个指令我们可以使用命令行创建一个管道文件出来==
我们可以看到以p开头的文件——这就是管道文件!
==这个文件的最大特点就是——一个进程可以往文件写入,另一个进程可以向文件读取!==
我们可以先看一下命令行中的命名管道的使用!
==我们发现在被写入和被读取的试试管道文件的大小永远都是0——为什么呢==
==在内核里,两个进程其实已经看懂了同一份的资源!——命名管道文件的特点就是不会刷新磁盘中!—— 任何进程在管道里面写都不会刷新到磁盘里面!——这是为什么管道文件的大小都是0,因为压根没有刷新到磁盘里面!==
这个和匿名管道是一样的!不用IO,也不需要IO!
==匿名管道是通过继承文件描述符的方式来看到同一份文件资源的!而命名管道则是通过不同的进程打开指定名称(路径+文件名)的同一个文件的方式看到同一份文件资源的!==
因为路径+文件名具有唯一性!
==关于在代码中创建管道的函数和指令中创建的函数名字是一样的!(系统调用接口)==
创建命名管道的函数接口
mkfifo(创建命名管道)
创建成功返回0,失败返回-1!
==现在我们可以用这个代码写一个样例出来==
//comm.hpp #pragma cone #include<iostream> #include<sys/types.h> #include<sys/stat.h> #include <string> #include <cerrno> #include <cstring> #include<cassert> #define NAMED_PIPE "/tmp/mypipe.hhh"//在tmp文件下创建管道文件 bool creatfifo(const std::string &path) { umask(0);//设置该进程的文件掩码 int n = mkfifo(path.c_str(),0666); if(n == 0) return true; else { std::cout << "errno :" << errno << "error string " << std::strerror(errno) <<std::endl; return false; } }
#include "comm.hpp" //server对管道文件整体负责(负责创建和删除) int main() { bool ret = creatfifo(NAMED_PIPE); assert(ret); return 0; }
==我们就找了这个管道文件!==
我们再次运行就会直接报错——说明文件存在!(所以使用这个接口要记得要么首先判断一下该文件存在,要么就在使用后删除这个文件)
==也有对应的删除该文件的接口==
unlink(删除文件)
这个函数接口的作用就是删除一个命名的存在的文件
成功则返回0 ,不成功则返回-1,并且设置错误码
#include "comm.hpp" #pragma cone #include<iostream> #include<sys/types.h> #include<sys/stat.h> #include <string> #include <cerrno> #include <cstring> #include<cassert> #include <unistd.h> #define NAMED_PIPE "/tmp/mypipe.hhh"//在tmp文件下创建管道文件 //我们可以写一个删除文件的函数 void removefifo(const std::string& path) { int ret = unlink(path.c_str()); assert(ret == 0); (void)ret; }
//server.cc #include "comm.hpp" //server对管道文件整体负责(负责创建和删除) int main() { bool ret = creatfifo(NAMED_PIPE); assert(ret); removefifo(NAMED_PIPE); return 0; }
命名管道使用
//comm.hpp #pragma cone #include<iostream> #include <string> #include <cerrno> #include <cstring> #include<cassert> #include<cstdio> #include <unistd.h> #include<sys/types.h> #include<sys/stat.h> #include<fcntl.h> #define NAMED_PIPE "/tmp/mypipe.hhh"//在tmp文件下创建管道文件 bool creatfifo(const std::string &path) { umask(0);//设置该进程的文件掩码 int n = mkfifo(path.c_str(),0666); if(n == 0) return true; else { std::cout << "errno :" << errno << "error string " << std::strerror(errno) <<std::endl; return false; } } void removefifo(const std::string& path) { int ret = unlink(path.c_str()); assert(ret == 0); (void)ret; }
//server.cc #include "comm.hpp" //server对管道文件整体负责(负责创建和删除) int main() { bool ret = creatfifo(NAMED_PIPE); assert(ret); int rfd = open(NAMED_PIPE,O_RDONLY); if(rfd < 0) exit(1); char buffer[1024]; while(true) { ssize_t s = read(rfd,buffer,sizeof buffer); if(s > 0) { buffer[s] = 0; std::cout << "clent -> server# " << buffer <<std::endl; } else if(s == 0) { std::cout << "clienr quit me too" <<std::endl; break; } else { std::cout << "read error!" << strerror(errno) << std::endl;; break; } } close(rfd); removefifo(NAMED_PIPE); return 0; }
//client.cc #include "comm.hpp" int main() { int wfd = open(NAMED_PIPE,O_WRONLY); char buffer[1024]; if(wfd<0) exit(-1); while(true) { std::cout <<"please Say#"; fgets(buffer,sizeof buffer,stdin);//从缓冲区获取 ssize_t n = write(wfd,buffer,strlen(buffer)); assert(n == strlen(buffer)); } close(wfd); return 0; }
==我们发现两个进程就可以进行通讯了!==
==细节1——只有先运行server后,才能运行client!否则无法输入!==
==细节2——我们要先关闭写端(client),如果先关闭读端会导致文件无法被删除!==
==细节3——我们在输入时候我们可以发现每次server端打印都多了一个换行!——这是因为我们把\n也读取进去了!==
//client.cc #include "comm.hpp" int main() { int wfd = open(NAMED_PIPE,O_WRONLY); char buffer[1024]; if(wfd<0) exit(-1); while(true) { std::cout <<"please Say#"; fgets(buffer,sizeof buffer,stdin);//从缓冲区获取 if(strlen(buffer) > 0) buffer[strlen(buffer) - 1] = 0; // 去除掉\n //要放在n之前否则会影响assert的判断! ssize_t n = write(wfd,buffer,strlen(buffer)); assert(n == strlen(buffer)); } close(wfd); return 0; }